@mars-stack/cli 0.2.0 → 0.2.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.
- package/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- 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
|