@veloxts/events 0.6.51

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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Events Plugin
3
+ *
4
+ * Fastify plugin for real-time event broadcasting.
5
+ * Extends BaseContext with events manager access.
6
+ */
7
+ import type { FastifyInstance } from 'fastify';
8
+ import '@veloxts/core';
9
+ import type { EventsManager, EventsPluginOptions } from './types.js';
10
+ /**
11
+ * Extend BaseContext with events manager.
12
+ *
13
+ * After registering the events plugin, `ctx.events` becomes available
14
+ * in all procedures:
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const createOrder = procedure
19
+ * .input(CreateOrderSchema)
20
+ * .mutation(async ({ input, ctx }) => {
21
+ * const order = await ctx.db.order.create({ data: input });
22
+ *
23
+ * // Broadcast order creation
24
+ * await ctx.events.broadcast(`orders.${order.userId}`, 'order.created', order);
25
+ *
26
+ * return order;
27
+ * });
28
+ * ```
29
+ */
30
+ declare module '@veloxts/core' {
31
+ interface BaseContext {
32
+ /** Events manager for real-time broadcasting */
33
+ events: EventsManager;
34
+ }
35
+ }
36
+ /**
37
+ * Events plugin for Fastify.
38
+ *
39
+ * Registers an events manager and sets up WebSocket/SSE endpoints.
40
+ *
41
+ * @param options - Events plugin options (driver configuration)
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * import { eventsPlugin } from '@veloxts/events';
46
+ *
47
+ * // WebSocket driver (recommended)
48
+ * app.register(eventsPlugin, {
49
+ * driver: 'ws',
50
+ * path: '/ws',
51
+ * redis: process.env.REDIS_URL, // For horizontal scaling
52
+ * authorizer: async (channel, request) => {
53
+ * if (channel.type === 'public') return { authorized: true };
54
+ * const user = await getUserFromRequest(request);
55
+ * if (!user) return { authorized: false, error: 'Not authenticated' };
56
+ * return { authorized: true, member: { id: user.id, info: { name: user.name } } };
57
+ * },
58
+ * });
59
+ *
60
+ * // SSE driver (fallback for environments without WebSocket)
61
+ * app.register(eventsPlugin, {
62
+ * driver: 'sse',
63
+ * path: '/events',
64
+ * });
65
+ * ```
66
+ */
67
+ export declare function eventsPlugin(options?: EventsPluginOptions): (fastify: FastifyInstance) => Promise<void>;
68
+ /**
69
+ * Get the events manager from a Fastify instance.
70
+ *
71
+ * @param fastify - Fastify instance
72
+ * @returns Events manager
73
+ * @throws If events plugin is not registered
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * const events = getEventsFromInstance(fastify);
78
+ * await events.broadcast('notifications', 'alert', { message: 'Hello!' });
79
+ * ```
80
+ */
81
+ export declare function getEventsFromInstance(fastify: FastifyInstance): EventsManager;
82
+ /**
83
+ * Get or create a standalone events manager.
84
+ *
85
+ * This is useful when you need events access outside of a Fastify request
86
+ * context, such as in background jobs or CLI commands.
87
+ *
88
+ * @param options - Events options (only used on first call)
89
+ * @returns Events manager instance
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * import { getEvents } from '@veloxts/events';
94
+ *
95
+ * // In a background job
96
+ * const events = await getEvents({ driver: 'ws' });
97
+ * await events.broadcast('jobs', 'completed', { jobId: '123' });
98
+ * ```
99
+ */
100
+ export declare function getEvents(options?: EventsPluginOptions): Promise<EventsManager>;
101
+ /**
102
+ * Reset the standalone events instance.
103
+ * Primarily used for testing.
104
+ */
105
+ export declare function _resetStandaloneEvents(): Promise<void>;
package/dist/plugin.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Events Plugin
3
+ *
4
+ * Fastify plugin for real-time event broadcasting.
5
+ * Extends BaseContext with events manager access.
6
+ */
7
+ import fp from 'fastify-plugin';
8
+ // Side-effect import for declaration merging
9
+ import '@veloxts/core';
10
+ import { createChannelAuthSigner } from './auth.js';
11
+ import { createSseDriver } from './drivers/sse.js';
12
+ import { createWsDriver } from './drivers/ws.js';
13
+ import { createManagerFromDriver } from './manager.js';
14
+ import { formatValidationErrors, SseSubscribeBodySchema, SseUnsubscribeBodySchema, validateBody, WsAuthBodySchema, } from './schemas.js';
15
+ /**
16
+ * Symbol for accessing events manager from Fastify instance.
17
+ */
18
+ const EVENTS_KEY = Symbol.for('velox.events');
19
+ /**
20
+ * Default channel authorizer that allows public channels and denies private/presence.
21
+ */
22
+ const defaultAuthorizer = (channel) => {
23
+ // Public channels (no prefix) are allowed
24
+ if (!channel.name.startsWith('private-') && !channel.name.startsWith('presence-')) {
25
+ return { authorized: true };
26
+ }
27
+ // Private/presence channels require explicit authorization
28
+ return { authorized: false, error: 'Authentication required' };
29
+ };
30
+ /**
31
+ * Events plugin for Fastify.
32
+ *
33
+ * Registers an events manager and sets up WebSocket/SSE endpoints.
34
+ *
35
+ * @param options - Events plugin options (driver configuration)
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * import { eventsPlugin } from '@veloxts/events';
40
+ *
41
+ * // WebSocket driver (recommended)
42
+ * app.register(eventsPlugin, {
43
+ * driver: 'ws',
44
+ * path: '/ws',
45
+ * redis: process.env.REDIS_URL, // For horizontal scaling
46
+ * authorizer: async (channel, request) => {
47
+ * if (channel.type === 'public') return { authorized: true };
48
+ * const user = await getUserFromRequest(request);
49
+ * if (!user) return { authorized: false, error: 'Not authenticated' };
50
+ * return { authorized: true, member: { id: user.id, info: { name: user.name } } };
51
+ * },
52
+ * });
53
+ *
54
+ * // SSE driver (fallback for environments without WebSocket)
55
+ * app.register(eventsPlugin, {
56
+ * driver: 'sse',
57
+ * path: '/events',
58
+ * });
59
+ * ```
60
+ */
61
+ export function eventsPlugin(options = {}) {
62
+ return fp(async (fastify) => {
63
+ const authorizer = options.authorizer ?? defaultAuthorizer;
64
+ let driver;
65
+ if (options.driver === 'sse') {
66
+ const sseOptions = options;
67
+ const sse = createSseDriver(sseOptions);
68
+ driver = sse;
69
+ // Register SSE endpoint
70
+ const path = sseOptions.path ?? '/events';
71
+ fastify.get(path, async (request, reply) => {
72
+ await sse.handler(request, reply);
73
+ });
74
+ // Register subscription endpoint with Zod validation
75
+ fastify.post(`${path}/subscribe`, async (request, reply) => {
76
+ const validation = validateBody(request.body, SseSubscribeBodySchema);
77
+ if (!validation.success) {
78
+ return reply.status(400).send(formatValidationErrors(validation.errors));
79
+ }
80
+ const { connectionId, channel, member } = validation.data;
81
+ // Authorize the channel
82
+ const channelInfo = parseChannel(channel);
83
+ const authResult = await authorizer(channelInfo, request);
84
+ if (!authResult.authorized) {
85
+ return reply.status(403).send({ error: authResult.error ?? 'Unauthorized' });
86
+ }
87
+ sse.subscribe(connectionId, channel, authResult.member ?? member);
88
+ return { success: true };
89
+ });
90
+ // Register unsubscribe endpoint with Zod validation
91
+ fastify.post(`${path}/unsubscribe`, async (request, reply) => {
92
+ const validation = validateBody(request.body, SseUnsubscribeBodySchema);
93
+ if (!validation.success) {
94
+ return reply.status(400).send(formatValidationErrors(validation.errors));
95
+ }
96
+ const { connectionId, channel } = validation.data;
97
+ sse.unsubscribe(connectionId, channel);
98
+ return { success: true };
99
+ });
100
+ }
101
+ else {
102
+ // WebSocket driver (default)
103
+ const wsOptions = (options.driver === 'ws' ? options : { ...options, driver: 'ws' });
104
+ const ws = await createWsDriver(wsOptions, { server: fastify.server });
105
+ driver = ws;
106
+ // Initialize auth signer if authSecret is provided
107
+ let authSigner = null;
108
+ if (options.authSecret) {
109
+ authSigner = createChannelAuthSigner(options.authSecret);
110
+ }
111
+ // Handle WebSocket upgrade
112
+ const path = wsOptions.path ?? '/ws';
113
+ fastify.server.on('upgrade', (request, socket, head) => {
114
+ const url = new URL(request.url ?? '', `http://${request.headers.host}`);
115
+ if (url.pathname === path) {
116
+ ws.handleUpgrade(request, socket, head);
117
+ }
118
+ });
119
+ // Register auth endpoint for private/presence channels with Zod validation
120
+ fastify.post(`${path}/auth`, async (request, reply) => {
121
+ const validation = validateBody(request.body, WsAuthBodySchema);
122
+ if (!validation.success) {
123
+ return reply.status(400).send(formatValidationErrors(validation.errors));
124
+ }
125
+ const { socketId, channel } = validation.data;
126
+ const channelInfo = parseChannel(channel);
127
+ const authResult = await authorizer(channelInfo, request);
128
+ if (!authResult.authorized) {
129
+ return reply.status(403).send({ error: authResult.error ?? 'Unauthorized' });
130
+ }
131
+ // Require authSecret for private/presence channels
132
+ if (!authSigner && channelInfo.type !== 'public') {
133
+ return reply.status(500).send({
134
+ error: 'Server configuration error: authSecret required for private channels',
135
+ });
136
+ }
137
+ // For public channels, no signature needed
138
+ if (channelInfo.type === 'public') {
139
+ return { auth: null };
140
+ }
141
+ // Generate HMAC-SHA256 signature for secure auth
142
+ const channelData = authResult.member ? JSON.stringify(authResult.member) : undefined;
143
+ const signature = authSigner?.sign(socketId, channel, channelData) ?? '';
144
+ return {
145
+ auth: `${socketId}:${signature}`,
146
+ channel_data: channelData,
147
+ };
148
+ });
149
+ }
150
+ // Create events manager
151
+ const events = createManagerFromDriver(driver);
152
+ // Store on fastify instance
153
+ fastify[EVENTS_KEY] = events;
154
+ // Decorate request with events accessor
155
+ fastify.decorateRequest('events', {
156
+ getter() {
157
+ return events;
158
+ },
159
+ });
160
+ // Register cleanup hook
161
+ fastify.addHook('onClose', async () => {
162
+ await events.close();
163
+ });
164
+ }, {
165
+ name: '@veloxts/events',
166
+ fastify: '5.x',
167
+ });
168
+ }
169
+ /**
170
+ * Parse channel name to determine type.
171
+ */
172
+ function parseChannel(name) {
173
+ if (name.startsWith('presence-')) {
174
+ return { name, type: 'presence' };
175
+ }
176
+ if (name.startsWith('private-')) {
177
+ return { name, type: 'private' };
178
+ }
179
+ return { name, type: 'public' };
180
+ }
181
+ /**
182
+ * Get the events manager from a Fastify instance.
183
+ *
184
+ * @param fastify - Fastify instance
185
+ * @returns Events manager
186
+ * @throws If events plugin is not registered
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * const events = getEventsFromInstance(fastify);
191
+ * await events.broadcast('notifications', 'alert', { message: 'Hello!' });
192
+ * ```
193
+ */
194
+ export function getEventsFromInstance(fastify) {
195
+ const events = fastify[EVENTS_KEY];
196
+ if (!events) {
197
+ throw new Error('Events plugin not registered. Register it with: app.register(eventsPlugin, { ... })');
198
+ }
199
+ return events;
200
+ }
201
+ // =============================================================================
202
+ // Standalone Usage (outside Fastify context)
203
+ // =============================================================================
204
+ /**
205
+ * Singleton events instance for standalone usage.
206
+ */
207
+ let standaloneEvents = null;
208
+ /**
209
+ * Get or create a standalone events manager.
210
+ *
211
+ * This is useful when you need events access outside of a Fastify request
212
+ * context, such as in background jobs or CLI commands.
213
+ *
214
+ * @param options - Events options (only used on first call)
215
+ * @returns Events manager instance
216
+ *
217
+ * @example
218
+ * ```typescript
219
+ * import { getEvents } from '@veloxts/events';
220
+ *
221
+ * // In a background job
222
+ * const events = await getEvents({ driver: 'ws' });
223
+ * await events.broadcast('jobs', 'completed', { jobId: '123' });
224
+ * ```
225
+ */
226
+ export async function getEvents(options) {
227
+ if (!standaloneEvents) {
228
+ const { createEventsManager } = await import('./manager.js');
229
+ standaloneEvents = await createEventsManager(options ?? {});
230
+ }
231
+ return standaloneEvents;
232
+ }
233
+ /**
234
+ * Reset the standalone events instance.
235
+ * Primarily used for testing.
236
+ */
237
+ export async function _resetStandaloneEvents() {
238
+ if (standaloneEvents) {
239
+ await standaloneEvents.close();
240
+ standaloneEvents = null;
241
+ }
242
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Validation Schemas
3
+ *
4
+ * Zod schemas for validating request bodies and WebSocket messages.
5
+ */
6
+ import { z } from 'zod';
7
+ /**
8
+ * Presence member schema for channel subscriptions.
9
+ */
10
+ export declare const PresenceMemberSchema: z.ZodObject<{
11
+ id: z.ZodString;
12
+ info: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
13
+ }, "strip", z.ZodTypeAny, {
14
+ id: string;
15
+ info?: Record<string, unknown> | undefined;
16
+ }, {
17
+ id: string;
18
+ info?: Record<string, unknown> | undefined;
19
+ }>;
20
+ /**
21
+ * Schema for SSE subscribe endpoint.
22
+ * POST /events/subscribe
23
+ */
24
+ export declare const SseSubscribeBodySchema: z.ZodObject<{
25
+ connectionId: z.ZodString;
26
+ channel: z.ZodString;
27
+ member: z.ZodOptional<z.ZodObject<{
28
+ id: z.ZodString;
29
+ info: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
30
+ }, "strip", z.ZodTypeAny, {
31
+ id: string;
32
+ info?: Record<string, unknown> | undefined;
33
+ }, {
34
+ id: string;
35
+ info?: Record<string, unknown> | undefined;
36
+ }>>;
37
+ }, "strip", z.ZodTypeAny, {
38
+ connectionId: string;
39
+ channel: string;
40
+ member?: {
41
+ id: string;
42
+ info?: Record<string, unknown> | undefined;
43
+ } | undefined;
44
+ }, {
45
+ connectionId: string;
46
+ channel: string;
47
+ member?: {
48
+ id: string;
49
+ info?: Record<string, unknown> | undefined;
50
+ } | undefined;
51
+ }>;
52
+ export type SseSubscribeBody = z.infer<typeof SseSubscribeBodySchema>;
53
+ /**
54
+ * Schema for SSE unsubscribe endpoint.
55
+ * POST /events/unsubscribe
56
+ */
57
+ export declare const SseUnsubscribeBodySchema: z.ZodObject<{
58
+ connectionId: z.ZodString;
59
+ channel: z.ZodString;
60
+ }, "strip", z.ZodTypeAny, {
61
+ connectionId: string;
62
+ channel: string;
63
+ }, {
64
+ connectionId: string;
65
+ channel: string;
66
+ }>;
67
+ export type SseUnsubscribeBody = z.infer<typeof SseUnsubscribeBodySchema>;
68
+ /**
69
+ * Schema for WebSocket auth endpoint.
70
+ * POST /ws/auth
71
+ */
72
+ export declare const WsAuthBodySchema: z.ZodObject<{
73
+ socketId: z.ZodString;
74
+ channel: z.ZodString;
75
+ }, "strip", z.ZodTypeAny, {
76
+ channel: string;
77
+ socketId: string;
78
+ }, {
79
+ channel: string;
80
+ socketId: string;
81
+ }>;
82
+ export type WsAuthBody = z.infer<typeof WsAuthBodySchema>;
83
+ /**
84
+ * Schema for WebSocket client messages.
85
+ * Uses discriminated union for type-safe message handling.
86
+ */
87
+ export declare const ClientMessageSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
88
+ type: z.ZodLiteral<"subscribe">;
89
+ channel: z.ZodString;
90
+ auth: z.ZodOptional<z.ZodString>;
91
+ channelData: z.ZodOptional<z.ZodString>;
92
+ data: z.ZodOptional<z.ZodObject<{
93
+ id: z.ZodString;
94
+ info: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
95
+ }, "strip", z.ZodTypeAny, {
96
+ id: string;
97
+ info?: Record<string, unknown> | undefined;
98
+ }, {
99
+ id: string;
100
+ info?: Record<string, unknown> | undefined;
101
+ }>>;
102
+ }, "strip", z.ZodTypeAny, {
103
+ channel: string;
104
+ type: "subscribe";
105
+ auth?: string | undefined;
106
+ channelData?: string | undefined;
107
+ data?: {
108
+ id: string;
109
+ info?: Record<string, unknown> | undefined;
110
+ } | undefined;
111
+ }, {
112
+ channel: string;
113
+ type: "subscribe";
114
+ auth?: string | undefined;
115
+ channelData?: string | undefined;
116
+ data?: {
117
+ id: string;
118
+ info?: Record<string, unknown> | undefined;
119
+ } | undefined;
120
+ }>, z.ZodObject<{
121
+ type: z.ZodLiteral<"unsubscribe">;
122
+ channel: z.ZodString;
123
+ }, "strip", z.ZodTypeAny, {
124
+ channel: string;
125
+ type: "unsubscribe";
126
+ }, {
127
+ channel: string;
128
+ type: "unsubscribe";
129
+ }>, z.ZodObject<{
130
+ type: z.ZodLiteral<"ping">;
131
+ }, "strip", z.ZodTypeAny, {
132
+ type: "ping";
133
+ }, {
134
+ type: "ping";
135
+ }>, z.ZodObject<{
136
+ type: z.ZodLiteral<"message">;
137
+ channel: z.ZodString;
138
+ event: z.ZodString;
139
+ data: z.ZodOptional<z.ZodUnknown>;
140
+ }, "strip", z.ZodTypeAny, {
141
+ channel: string;
142
+ type: "message";
143
+ event: string;
144
+ data?: unknown;
145
+ }, {
146
+ channel: string;
147
+ type: "message";
148
+ event: string;
149
+ data?: unknown;
150
+ }>]>;
151
+ export type ValidatedClientMessage = z.infer<typeof ClientMessageSchema>;
152
+ /**
153
+ * Validation result type - discriminated union for type-safe error handling.
154
+ */
155
+ export type ValidationResult<T> = {
156
+ success: true;
157
+ data: T;
158
+ } | {
159
+ success: false;
160
+ errors: z.ZodIssue[];
161
+ };
162
+ /**
163
+ * Validate request body against a Zod schema.
164
+ * Returns a discriminated union for type-safe error handling.
165
+ *
166
+ * @template TSchema - The Zod schema type
167
+ */
168
+ export declare function validateBody<TSchema extends z.ZodTypeAny>(body: unknown, schema: TSchema): ValidationResult<z.infer<TSchema>>;
169
+ /**
170
+ * Format Zod validation errors for API response.
171
+ */
172
+ export declare function formatValidationErrors(errors: z.ZodIssue[]): {
173
+ error: string;
174
+ details: Array<{
175
+ path: string;
176
+ message: string;
177
+ }>;
178
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Validation Schemas
3
+ *
4
+ * Zod schemas for validating request bodies and WebSocket messages.
5
+ */
6
+ import { z } from 'zod';
7
+ // =============================================================================
8
+ // Presence Member Schema
9
+ // =============================================================================
10
+ /**
11
+ * Presence member schema for channel subscriptions.
12
+ */
13
+ export const PresenceMemberSchema = z.object({
14
+ id: z.string().min(1, 'Member ID is required'),
15
+ info: z.record(z.unknown()).optional(),
16
+ });
17
+ // =============================================================================
18
+ // SSE Endpoint Schemas
19
+ // =============================================================================
20
+ /**
21
+ * Schema for SSE subscribe endpoint.
22
+ * POST /events/subscribe
23
+ */
24
+ export const SseSubscribeBodySchema = z.object({
25
+ connectionId: z.string().min(1, 'Connection ID is required'),
26
+ channel: z.string().min(1, 'Channel name is required'),
27
+ member: PresenceMemberSchema.optional(),
28
+ });
29
+ /**
30
+ * Schema for SSE unsubscribe endpoint.
31
+ * POST /events/unsubscribe
32
+ */
33
+ export const SseUnsubscribeBodySchema = z.object({
34
+ connectionId: z.string().min(1, 'Connection ID is required'),
35
+ channel: z.string().min(1, 'Channel name is required'),
36
+ });
37
+ // =============================================================================
38
+ // WebSocket Endpoint Schemas
39
+ // =============================================================================
40
+ /**
41
+ * Schema for WebSocket auth endpoint.
42
+ * POST /ws/auth
43
+ */
44
+ export const WsAuthBodySchema = z.object({
45
+ socketId: z.string().min(1, 'Socket ID is required'),
46
+ channel: z.string().min(1, 'Channel name is required'),
47
+ });
48
+ // =============================================================================
49
+ // WebSocket Client Message Schemas
50
+ // =============================================================================
51
+ /**
52
+ * Schema for WebSocket client messages.
53
+ * Uses discriminated union for type-safe message handling.
54
+ */
55
+ export const ClientMessageSchema = z.discriminatedUnion('type', [
56
+ z.object({
57
+ type: z.literal('subscribe'),
58
+ channel: z.string().min(1),
59
+ auth: z.string().optional(),
60
+ channelData: z.string().optional(),
61
+ data: PresenceMemberSchema.optional(),
62
+ }),
63
+ z.object({
64
+ type: z.literal('unsubscribe'),
65
+ channel: z.string().min(1),
66
+ }),
67
+ z.object({
68
+ type: z.literal('ping'),
69
+ }),
70
+ z.object({
71
+ type: z.literal('message'),
72
+ channel: z.string().min(1),
73
+ event: z.string().min(1),
74
+ data: z.unknown().optional(),
75
+ }),
76
+ ]);
77
+ /**
78
+ * Validate request body against a Zod schema.
79
+ * Returns a discriminated union for type-safe error handling.
80
+ *
81
+ * @template TSchema - The Zod schema type
82
+ */
83
+ export function validateBody(body, schema) {
84
+ const result = schema.safeParse(body);
85
+ if (result.success) {
86
+ return { success: true, data: result.data };
87
+ }
88
+ return { success: false, errors: result.error.issues };
89
+ }
90
+ /**
91
+ * Format Zod validation errors for API response.
92
+ */
93
+ export function formatValidationErrors(errors) {
94
+ return {
95
+ error: 'Validation failed',
96
+ details: errors.map((issue) => ({
97
+ path: issue.path.join('.'),
98
+ message: issue.message,
99
+ })),
100
+ };
101
+ }