yaxa-svelte 2.4.0 → 2.5.0

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/README.md CHANGED
@@ -295,7 +295,8 @@ export const GET = createApiDocsHandler({
295
295
  });
296
296
  ```
297
297
 
298
- - **Interactive Reference UI:** Open `/api/docs` in your browser.
298
+ - **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default), `?theme=swagger`, or `?theme=redoc`).
299
+ - **Live Demo:** Try it live on [yaxa.vercel.app/api/docs](https://yaxa.vercel.app/api/docs).
299
300
  - **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
300
301
 
301
302
  ---
@@ -345,22 +346,68 @@ export const GET = async () => {
345
346
 
346
347
  ---
347
348
 
349
+ ## 📡 Real-Time Server-Sent Events (`yaxaSSE`, `broadcast`, & `useSSE`)
350
+
351
+ Zero-infrastructure real-time communication across serverless and Node.js without third-party subscriptions. Features declarative SSE streaming, automatic keep-alive heartbeats, multi-channel pub/sub broadcasting, and a reactive Svelte 5 client rune with exponential reconnect backoff:
352
+
353
+ ```ts
354
+ // 1. Server-Sent Events Endpoint (src/routes/api/events/+server.ts)
355
+ import { yaxaSSE, requireAuth } from 'yaxa-svelte/server';
356
+
357
+ export const GET = yaxaSSE({
358
+ guards: [requireAuth()],
359
+ channel: (event) => `org:${event.locals.orgId}`,
360
+ keepAliveIntervalMs: 15000, // Heartbeat ping prevents proxy & Vercel timeouts
361
+ onConnect: ({ send, user }) => {
362
+ send('connected', { userId: user.id });
363
+ }
364
+ });
365
+
366
+ // 2. Publish from anywhere (Endpoints, Webhooks, Background Workers)
367
+ import { broadcast } from 'yaxa-svelte/server';
368
+
369
+ await broadcast(`org:${orgId}`, 'invoice.settled', {
370
+ invoiceId: 'inv_4982',
371
+ amount: 29.0
372
+ });
373
+ ```
374
+
375
+ ```svelte
376
+ <!-- 3. Reactive Svelte 5 Client Rune -->
377
+ <script lang="ts">
378
+ import { useSSE } from 'yaxa-svelte';
379
+
380
+ const stream = useSSE('/api/events');
381
+
382
+ // Typed event listeners with automatic cleanup
383
+ stream.on('invoice.settled', (data) => {
384
+ toast.success(`Payment settled: $${data.amount}`);
385
+ });
386
+ </script>
387
+
388
+ <Badge color={stream.connected ? 'success' : 'neutral'}>
389
+ {stream.connected ? 'Live Stream' : 'Connecting...'}
390
+ </Badge>
391
+ ```
392
+
393
+ ---
394
+
348
395
  ## 📦 Subpath Exports Architecture
349
396
 
350
- | Subpath | Description |
351
- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
352
- | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.) |
353
- | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
354
- | `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, Drizzle DB, Storage & Email |
355
- | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
356
- | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
357
- | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
358
- | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
359
- | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
360
- | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
361
- | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
362
- | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
363
- | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
397
+ | Subpath | Description |
398
+ | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
399
+ | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
400
+ | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
401
+ | `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Real-Time SSE (`yaxaSSE`, `broadcast`), Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, DB, Storage & Email |
402
+ | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
403
+ | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
404
+ | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
405
+ | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
406
+ | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
407
+ | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
408
+ | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
409
+ | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
410
+ | `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
364
411
 
365
412
  ---
366
413
 
@@ -0,0 +1,53 @@
1
+ export type SSEStatus = 'idle' | 'connecting' | 'connected' | 'error' | 'closed';
2
+ export interface UseSSEOptions {
3
+ /** Whether the SSE connection is active (defaults to true) */
4
+ enabled?: boolean;
5
+ /** Whether to send credentials/cookies with cross-origin requests */
6
+ withCredentials?: boolean;
7
+ /** Enable automatic reconnection on disconnect/error (defaults to true) */
8
+ autoReconnect?: boolean;
9
+ /** Maximum reconnection attempts before stopping (defaults to 10) */
10
+ maxRetries?: number;
11
+ /** Base reconnect delay in ms before exponential backoff (defaults to 1,000ms) */
12
+ baseDelayMs?: number;
13
+ /** Maximum delay between reconnect attempts in ms (defaults to 30,000ms) */
14
+ maxDelayMs?: number;
15
+ /** Callback invoked when the connection opens */
16
+ onOpen?: () => void;
17
+ /** Callback invoked on error */
18
+ onError?: (err: Event) => void;
19
+ /** Callback invoked when any message is received */
20
+ onMessage?: (data: any, event: MessageEvent) => void;
21
+ }
22
+ export type SSEEventHandler<T = any> = (data: T, event: MessageEvent) => void;
23
+ export interface SSEResult {
24
+ readonly status: SSEStatus;
25
+ readonly connected: boolean;
26
+ readonly connecting: boolean;
27
+ readonly data: any;
28
+ readonly lastEvent: string | null;
29
+ readonly error: any;
30
+ on: <T = any>(event: string, handler: SSEEventHandler<T>) => () => void;
31
+ close: () => void;
32
+ reconnect: () => void;
33
+ }
34
+ /**
35
+ * Idiomatic Svelte 5 Composable Rune for Server-Sent Events (SSE).
36
+ * Provides automatic reconnect with jitter, typed event subscribers, and automatic teardown.
37
+ *
38
+ * @example
39
+ * ```svelte
40
+ * <script lang="ts">
41
+ * import { useSSE } from 'yaxa-svelte';
42
+ *
43
+ * const stream = useSSE('/api/events');
44
+ *
45
+ * stream.on('order.created', (order) => {
46
+ * console.log('New order received:', order);
47
+ * });
48
+ * </script>
49
+ *
50
+ * <p>Status: {stream.connected ? 'Live' : 'Connecting...'}</p>
51
+ * ```
52
+ */
53
+ export declare function useSSE(url: string | (() => string), options?: UseSSEOptions): SSEResult;
@@ -0,0 +1,209 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
2
+ /**
3
+ * Idiomatic Svelte 5 Composable Rune for Server-Sent Events (SSE).
4
+ * Provides automatic reconnect with jitter, typed event subscribers, and automatic teardown.
5
+ *
6
+ * @example
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { useSSE } from 'yaxa-svelte';
10
+ *
11
+ * const stream = useSSE('/api/events');
12
+ *
13
+ * stream.on('order.created', (order) => {
14
+ * console.log('New order received:', order);
15
+ * });
16
+ * </script>
17
+ *
18
+ * <p>Status: {stream.connected ? 'Live' : 'Connecting...'}</p>
19
+ * ```
20
+ */
21
+ export function useSSE(url, options = {}) {
22
+ const { enabled = true, withCredentials = false, autoReconnect = true, maxRetries = 10, baseDelayMs = 1000, maxDelayMs = 30000, onOpen, onError, onMessage } = options;
23
+ let status = $state('idle');
24
+ let data = $state(null);
25
+ let lastEvent = $state(null);
26
+ let error = $state(null);
27
+ const connected = $derived(status === 'connected');
28
+ const connecting = $derived(status === 'connecting');
29
+ const listeners = new SvelteMap();
30
+ let es = null;
31
+ let reconnectTimer = null;
32
+ let retryCount = 0;
33
+ let manualClose = false;
34
+ function parseEventData(raw) {
35
+ try {
36
+ return JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return raw;
40
+ }
41
+ }
42
+ function dispatchToListeners(eventType, parsed, rawEvent) {
43
+ const handlers = listeners.get(eventType);
44
+ if (handlers) {
45
+ for (const handler of handlers) {
46
+ try {
47
+ handler(parsed, rawEvent);
48
+ }
49
+ catch (err) {
50
+ console.error(`[useSSE Handler Error] Event '${eventType}':`, err);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ function cleanupConnection() {
56
+ if (reconnectTimer) {
57
+ clearTimeout(reconnectTimer);
58
+ reconnectTimer = null;
59
+ }
60
+ if (es) {
61
+ es.close();
62
+ es = null;
63
+ }
64
+ }
65
+ function scheduleReconnect(targetUrl) {
66
+ if (manualClose || !autoReconnect || retryCount >= maxRetries) {
67
+ status = 'closed';
68
+ return;
69
+ }
70
+ // Exponential backoff with jitter
71
+ const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(1.5, retryCount) + Math.random() * 500);
72
+ retryCount++;
73
+ status = 'connecting';
74
+ reconnectTimer = setTimeout(() => {
75
+ connect(targetUrl);
76
+ }, delay);
77
+ }
78
+ function connect(targetUrl) {
79
+ cleanupConnection();
80
+ if (typeof window === 'undefined' || typeof EventSource === 'undefined') {
81
+ status = 'idle';
82
+ return;
83
+ }
84
+ status = 'connecting';
85
+ manualClose = false;
86
+ try {
87
+ const source = new EventSource(targetUrl, { withCredentials });
88
+ es = source;
89
+ source.onopen = () => {
90
+ status = 'connected';
91
+ retryCount = 0;
92
+ error = null;
93
+ onOpen?.();
94
+ };
95
+ source.onmessage = (event) => {
96
+ const parsed = parseEventData(event.data);
97
+ data = parsed;
98
+ lastEvent = 'message';
99
+ onMessage?.(parsed, event);
100
+ dispatchToListeners('message', parsed, event);
101
+ };
102
+ source.onerror = (err) => {
103
+ status = 'error';
104
+ error = err;
105
+ onError?.(err);
106
+ cleanupConnection();
107
+ scheduleReconnect(targetUrl);
108
+ };
109
+ // Re-attach custom event listeners
110
+ for (const [eventName] of listeners) {
111
+ if (eventName !== 'message') {
112
+ source.addEventListener(eventName, (event) => {
113
+ const parsed = parseEventData(event.data);
114
+ data = parsed;
115
+ lastEvent = eventName;
116
+ dispatchToListeners(eventName, parsed, event);
117
+ });
118
+ }
119
+ }
120
+ }
121
+ catch (err) {
122
+ status = 'error';
123
+ error = err;
124
+ scheduleReconnect(targetUrl);
125
+ }
126
+ }
127
+ function close() {
128
+ manualClose = true;
129
+ status = 'closed';
130
+ cleanupConnection();
131
+ }
132
+ function reconnect() {
133
+ manualClose = false;
134
+ retryCount = 0;
135
+ const targetUrl = typeof url === 'function' ? url() : url;
136
+ connect(targetUrl);
137
+ }
138
+ function on(event, handler) {
139
+ let handlers = listeners.get(event);
140
+ if (!handlers) {
141
+ handlers = new SvelteSet();
142
+ listeners.set(event, handlers);
143
+ // If EventSource is currently connected, register native listener for named event
144
+ if (es && event !== 'message') {
145
+ es.addEventListener(event, (evt) => {
146
+ const parsed = parseEventData(evt.data);
147
+ data = parsed;
148
+ lastEvent = event;
149
+ dispatchToListeners(event, parsed, evt);
150
+ });
151
+ }
152
+ }
153
+ handlers.add(handler);
154
+ return () => {
155
+ handlers?.delete(handler);
156
+ if (handlers?.size === 0) {
157
+ listeners.delete(event);
158
+ }
159
+ };
160
+ }
161
+ // Svelte 5 effect for automated lifecycle management if called inside a component/effect root
162
+ try {
163
+ $effect(() => {
164
+ const targetUrl = typeof url === 'function' ? url() : url;
165
+ if (enabled && targetUrl) {
166
+ connect(targetUrl);
167
+ }
168
+ else {
169
+ close();
170
+ }
171
+ return () => {
172
+ manualClose = true;
173
+ cleanupConnection();
174
+ };
175
+ });
176
+ }
177
+ catch {
178
+ // When called outside an effect root (e.g. In unit tests), connect directly
179
+ if (enabled) {
180
+ const targetUrl = typeof url === 'function' ? url() : url;
181
+ if (targetUrl) {
182
+ connect(targetUrl);
183
+ }
184
+ }
185
+ }
186
+ return {
187
+ get status() {
188
+ return status;
189
+ },
190
+ get connected() {
191
+ return connected;
192
+ },
193
+ get connecting() {
194
+ return connecting;
195
+ },
196
+ get data() {
197
+ return data;
198
+ },
199
+ get lastEvent() {
200
+ return lastEvent;
201
+ },
202
+ get error() {
203
+ return error;
204
+ },
205
+ on,
206
+ close,
207
+ reconnect
208
+ };
209
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { useSSE } from './useSSE.svelte';
3
+ class MockEventSource {
4
+ url;
5
+ withCredentials;
6
+ onopen = null;
7
+ onmessage = null;
8
+ onerror = null;
9
+ listeners = new Map();
10
+ readyState = 0;
11
+ static instances = [];
12
+ constructor(url, init) {
13
+ this.url = url;
14
+ this.withCredentials = init?.withCredentials;
15
+ MockEventSource.instances.push(this);
16
+ }
17
+ addEventListener(type, handler) {
18
+ let handlers = this.listeners.get(type);
19
+ if (!handlers) {
20
+ handlers = new Set();
21
+ this.listeners.set(type, handlers);
22
+ }
23
+ handlers.add(handler);
24
+ }
25
+ removeEventListener(type, handler) {
26
+ this.listeners.get(type)?.delete(handler);
27
+ }
28
+ close() {
29
+ this.readyState = 2;
30
+ }
31
+ simulateOpen() {
32
+ this.readyState = 1;
33
+ this.onopen?.();
34
+ }
35
+ simulateMessage(data, type = 'message') {
36
+ const rawData = typeof data === 'object' ? JSON.stringify(data) : String(data);
37
+ const evt = { data: rawData, type };
38
+ if (type === 'message') {
39
+ this.onmessage?.(evt);
40
+ }
41
+ const handlers = this.listeners.get(type);
42
+ if (handlers) {
43
+ for (const handler of handlers) {
44
+ handler(evt);
45
+ }
46
+ }
47
+ }
48
+ simulateError(err = new Event('error')) {
49
+ this.onerror?.(err);
50
+ }
51
+ }
52
+ describe('useSSE Composable Rune', () => {
53
+ beforeEach(() => {
54
+ MockEventSource.instances = [];
55
+ vi.stubGlobal('EventSource', MockEventSource);
56
+ });
57
+ afterEach(() => {
58
+ vi.unstubAllGlobals();
59
+ vi.restoreAllMocks();
60
+ });
61
+ it('initializes with connecting or connected state when EventSource exists', () => {
62
+ const sse = useSSE('/api/events', { enabled: true });
63
+ expect(sse.status).toBe('connecting');
64
+ expect(sse.connecting).toBe(true);
65
+ expect(sse.connected).toBe(false);
66
+ expect(MockEventSource.instances.length).toBe(1);
67
+ const instance = MockEventSource.instances[0];
68
+ expect(instance.url).toBe('/api/events');
69
+ instance.simulateOpen();
70
+ expect(sse.status).toBe('connected');
71
+ expect(sse.connected).toBe(true);
72
+ expect(sse.connecting).toBe(false);
73
+ });
74
+ it('handles message events and parses JSON data reactively', () => {
75
+ const onMessage = vi.fn();
76
+ const sse = useSSE('/api/events', { onMessage });
77
+ const instance = MockEventSource.instances[0];
78
+ instance.simulateOpen();
79
+ instance.simulateMessage({ user: 'Alice', score: 100 });
80
+ expect(sse.data).toEqual({ user: 'Alice', score: 100 });
81
+ expect(sse.lastEvent).toBe('message');
82
+ expect(onMessage).toHaveBeenCalledWith({ user: 'Alice', score: 100 }, expect.any(Object));
83
+ });
84
+ it('subscribes to custom named events via .on(eventName, handler)', () => {
85
+ const sse = useSSE('/api/events');
86
+ const instance = MockEventSource.instances[0];
87
+ instance.simulateOpen();
88
+ const orderHandler = vi.fn();
89
+ const unsubscribe = sse.on('order.paid', orderHandler);
90
+ instance.simulateMessage({ orderId: 'ord_99', total: 29.5 }, 'order.paid');
91
+ expect(orderHandler).toHaveBeenCalledTimes(1);
92
+ expect(orderHandler).toHaveBeenCalledWith({ orderId: 'ord_99', total: 29.5 }, expect.any(Object));
93
+ expect(sse.lastEvent).toBe('order.paid');
94
+ // Test unsubscribe
95
+ unsubscribe();
96
+ instance.simulateMessage({ orderId: 'ord_100', total: 10 }, 'order.paid');
97
+ expect(orderHandler).toHaveBeenCalledTimes(1);
98
+ });
99
+ it('closes the connection cleanly on .close()', () => {
100
+ const sse = useSSE('/api/events');
101
+ const instance = MockEventSource.instances[0];
102
+ instance.simulateOpen();
103
+ sse.close();
104
+ expect(sse.status).toBe('closed');
105
+ expect(sse.connected).toBe(false);
106
+ expect(instance.readyState).toBe(2);
107
+ });
108
+ it('reconnects when .reconnect() is invoked', () => {
109
+ const sse = useSSE('/api/events');
110
+ expect(MockEventSource.instances.length).toBe(1);
111
+ sse.close();
112
+ expect(sse.status).toBe('closed');
113
+ sse.reconnect();
114
+ expect(MockEventSource.instances.length).toBe(2);
115
+ expect(sse.status).toBe('connecting');
116
+ });
117
+ it('stays idle when enabled is false', () => {
118
+ const sse = useSSE('/api/events', { enabled: false });
119
+ expect(sse.status).toBe('idle');
120
+ expect(MockEventSource.instances.length).toBe(0);
121
+ });
122
+ });
package/dist/index.d.ts CHANGED
@@ -156,6 +156,8 @@ export { useFormAction } from './composables/useFormAction.svelte';
156
156
  export type { UseFormActionOptions } from './composables/useFormAction.svelte';
157
157
  export { useIdle } from './composables/useIdle.svelte';
158
158
  export type { UseIdleOptions } from './composables/useIdle.svelte';
159
+ export { useSSE } from './composables/useSSE.svelte';
160
+ export type { SSEStatus, UseSSEOptions, SSEEventHandler, SSEResult } from './composables/useSSE.svelte';
159
161
  export { default as ThemeStudio } from './components/theme/ThemeStudio.svelte';
160
162
  export type { ThemeStudioProps } from './components/theme/ThemeStudio.svelte';
161
163
  export { theme, themeInitScript, ACCENT_PALETTES, NEUTRAL_PALETTES, FONT_PRESETS, RADIUS_PRESETS, FONT_SIZE_PRESETS, DEFAULT_CHART_COLORS } from './theme/theme.svelte';
package/dist/index.js CHANGED
@@ -103,6 +103,7 @@ export { Sortable, useSortable } from './composables/useSortable.svelte';
103
103
  export { useDataTable } from './composables/useDataTable.svelte';
104
104
  export { useFormAction } from './composables/useFormAction.svelte';
105
105
  export { useIdle } from './composables/useIdle.svelte';
106
+ export { useSSE } from './composables/useSSE.svelte';
106
107
  // Theming & Theme Studio
107
108
  export { default as ThemeStudio } from './components/theme/ThemeStudio.svelte';
108
109
  export { theme, themeInitScript, ACCENT_PALETTES, NEUTRAL_PALETTES, FONT_PRESETS, RADIUS_PRESETS, FONT_SIZE_PRESETS, DEFAULT_CHART_COLORS } from './theme/theme.svelte';
@@ -28,3 +28,5 @@ export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApi
28
28
  export type { OpenApiSpec31, OpenApiOperation, OpenApiParameter, OpenApiRequestBody, OpenApiResponse, GenerateOpenApiOptions, ApiDocsOptions, ApiDocsTheme, EndpointLike, EndpointMap } from './openapi/index';
29
29
  export { enqueueJob, defineWorker, createJobRunner, eventBus, EventBus, parseJobDelay } from './jobs/index';
30
30
  export type { JobRecord, JobDelay, EnqueueJobOptions, WorkerContext, WorkerHandler, WorkerDefinition, JobRunnerOptions, JobBatchResult, JobRunner, EventHandler, EventSubscription, IEventBus } from './jobs/index';
31
+ export { yaxaSSE, formatSSEFrame, broadcast, createBroadcaster, globalBroadcaster, MemoryBroadcaster } from './realtime/index';
32
+ export type { SSEMessage, SSEClientContext, YaxaSSEOptions, BroadcastMessage, BroadcastSubscriber, Broadcaster } from './realtime/index';
@@ -24,3 +24,5 @@ export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails,
24
24
  export { generateOpenApiSpec, createApiDocsHandler, createApiRegistry, globalApiRegistry, convertToJsonSchema } from './openapi/index';
25
25
  // Embedded Background Workflows, Job Queue & Domain Events (Milestone 6)
26
26
  export { enqueueJob, defineWorker, createJobRunner, eventBus, EventBus, parseJobDelay } from './jobs/index';
27
+ // Adapter-Agnostic Real-Time Abstractions & Server-Sent Events (Milestone 5)
28
+ export { yaxaSSE, formatSSEFrame, broadcast, createBroadcaster, globalBroadcaster, MemoryBroadcaster } from './realtime/index';
@@ -78,10 +78,12 @@ function renderRedocHtml(specJson, title) {
78
78
  * page (Scalar, Swagger UI, or Redoc) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
79
79
  */
80
80
  export function createApiDocsHandler(options = {}) {
81
- const theme = options.theme || 'scalar';
81
+ const defaultTheme = options.theme || 'scalar';
82
82
  return async (event) => {
83
83
  const { url, request } = event;
84
84
  const format = url.searchParams.get('format');
85
+ const themeQuery = url.searchParams.get('theme');
86
+ const activeTheme = themeQuery && ['scalar', 'swagger', 'redoc'].includes(themeQuery) ? themeQuery : defaultTheme;
85
87
  const acceptHeader = request.headers.get('accept') || '';
86
88
  const spec = generateOpenApiSpec(options);
87
89
  // If caller requests JSON via query param, accept header, or path ending in .json
@@ -98,10 +100,10 @@ export function createApiDocsHandler(options = {}) {
98
100
  // Render Interactive HTML Docs
99
101
  const specJson = JSON.stringify(spec);
100
102
  let html;
101
- if (theme === 'swagger') {
103
+ if (activeTheme === 'swagger') {
102
104
  html = renderSwaggerHtml(specJson, spec.info.title);
103
105
  }
104
- else if (theme === 'redoc') {
106
+ else if (activeTheme === 'redoc') {
105
107
  html = renderRedocHtml(specJson, spec.info.title);
106
108
  }
107
109
  else {
@@ -216,4 +216,17 @@ describe('createApiDocsHandler Endpoint', () => {
216
216
  expect(html).toContain('redoc.standalone.js');
217
217
  expect(html).toContain('redoc-container');
218
218
  });
219
+ it('allows overriding theme dynamically via ?theme query parameter', async () => {
220
+ const handler = createApiDocsHandler({
221
+ title: 'Dynamic Theme Test',
222
+ theme: 'scalar'
223
+ });
224
+ const event = createMockRequestEvent({
225
+ url: 'http://localhost:5173/api/docs?theme=swagger'
226
+ });
227
+ const response = await handler(event);
228
+ expect(response.status).toBe(200);
229
+ const html = await response.text();
230
+ expect(html).toContain('swagger-ui-dist');
231
+ });
219
232
  });
@@ -0,0 +1,31 @@
1
+ import type { Broadcaster, BroadcastSubscriber } from './types';
2
+ /**
3
+ * In-memory channel broadcaster suitable for single-node deployments and local development.
4
+ * Supports zero external dependencies with complete error isolation across subscribers.
5
+ */
6
+ export declare class MemoryBroadcaster implements Broadcaster {
7
+ private channels;
8
+ publish<T = unknown>(channel: string, event: string, payload: T, id?: string): void;
9
+ subscribe<T = unknown>(channel: string, subscriber: BroadcastSubscriber<T>): () => void;
10
+ unsubscribe<T = unknown>(channel: string, subscriber: BroadcastSubscriber<T>): void;
11
+ subscriberCount(channel: string): number;
12
+ clear(): void;
13
+ }
14
+ /**
15
+ * Global default broadcaster instance.
16
+ */
17
+ export declare const globalBroadcaster: Broadcaster;
18
+ /**
19
+ * Creates a broadcaster instance or wraps a custom adapter.
20
+ */
21
+ export declare function createBroadcaster(customAdapter?: Broadcaster): Broadcaster;
22
+ /**
23
+ * Broadcast an event to all connected clients on a channel.
24
+ * Callable from any endpoint, server action, webhook, or background worker.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * await broadcast(`org:${orgId}`, 'invoice.paid', { id: 'inv_123', total: 4900 });
29
+ * ```
30
+ */
31
+ export declare function broadcast<T = unknown>(channel: string, event: string, payload: T, id?: string, broadcasterInstance?: Broadcaster): Promise<void>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * In-memory channel broadcaster suitable for single-node deployments and local development.
3
+ * Supports zero external dependencies with complete error isolation across subscribers.
4
+ */
5
+ export class MemoryBroadcaster {
6
+ channels = new Map();
7
+ publish(channel, event, payload, id) {
8
+ const subscribers = this.channels.get(channel);
9
+ if (!subscribers || subscribers.size === 0) {
10
+ return;
11
+ }
12
+ const message = {
13
+ channel,
14
+ event,
15
+ payload,
16
+ id
17
+ };
18
+ // Isolate subscriber errors so one failure does not halt remaining subscribers
19
+ for (const subscriber of subscribers) {
20
+ try {
21
+ subscriber(message);
22
+ }
23
+ catch (err) {
24
+ console.error(`[Yaxa Broadcaster Error] Channel '${channel}', Event '${event}':`, err);
25
+ }
26
+ }
27
+ }
28
+ subscribe(channel, subscriber) {
29
+ let subscribers = this.channels.get(channel);
30
+ if (!subscribers) {
31
+ subscribers = new Set();
32
+ this.channels.set(channel, subscribers);
33
+ }
34
+ subscribers.add(subscriber);
35
+ return () => {
36
+ this.unsubscribe(channel, subscriber);
37
+ };
38
+ }
39
+ unsubscribe(channel, subscriber) {
40
+ const subscribers = this.channels.get(channel);
41
+ if (subscribers) {
42
+ subscribers.delete(subscriber);
43
+ if (subscribers.size === 0) {
44
+ this.channels.delete(channel);
45
+ }
46
+ }
47
+ }
48
+ subscriberCount(channel) {
49
+ return this.channels.get(channel)?.size ?? 0;
50
+ }
51
+ clear() {
52
+ this.channels.clear();
53
+ }
54
+ }
55
+ /**
56
+ * Global default broadcaster instance.
57
+ */
58
+ export const globalBroadcaster = new MemoryBroadcaster();
59
+ /**
60
+ * Creates a broadcaster instance or wraps a custom adapter.
61
+ */
62
+ export function createBroadcaster(customAdapter) {
63
+ return customAdapter || new MemoryBroadcaster();
64
+ }
65
+ /**
66
+ * Broadcast an event to all connected clients on a channel.
67
+ * Callable from any endpoint, server action, webhook, or background worker.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * await broadcast(`org:${orgId}`, 'invoice.paid', { id: 'inv_123', total: 4900 });
72
+ * ```
73
+ */
74
+ export async function broadcast(channel, event, payload, id, broadcasterInstance = globalBroadcaster) {
75
+ await broadcasterInstance.publish(channel, event, payload, id);
76
+ }
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export * from './broadcaster';
3
+ export * from './sse';
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export * from './broadcaster';
3
+ export * from './sse';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { formatSSEFrame, yaxaSSE } from './sse';
3
+ import { MemoryBroadcaster, broadcast } from './broadcaster';
4
+ import { requireAuth } from '../endpoint/guards';
5
+ import { createMockRequestEvent } from '../../testing/index.js';
6
+ describe('formatSSEFrame', () => {
7
+ it('formats simple text message without event name', () => {
8
+ const frame = formatSSEFrame({ data: 'hello world' });
9
+ expect(frame).toBe('data: hello world\n\n');
10
+ });
11
+ it('formats JSON object data and custom event name', () => {
12
+ const frame = formatSSEFrame({
13
+ event: 'order.created',
14
+ data: { id: 123, total: 49.99 }
15
+ });
16
+ expect(frame).toContain('event: order.created\n');
17
+ expect(frame).toContain('data: {"id":123,"total":49.99}\n\n');
18
+ });
19
+ it('includes id and retry fields when provided', () => {
20
+ const frame = formatSSEFrame({
21
+ id: 'evt_001',
22
+ event: 'status',
23
+ retry: 5000,
24
+ data: 'ok'
25
+ });
26
+ expect(frame).toContain('id: evt_001\n');
27
+ expect(frame).toContain('event: status\n');
28
+ expect(frame).toContain('retry: 5000\n');
29
+ expect(frame).toContain('data: ok\n\n');
30
+ });
31
+ it('splits multiline strings into multiple data lines per SSE spec', () => {
32
+ const multiline = 'line 1\nline 2\r\nline 3';
33
+ const frame = formatSSEFrame({ data: multiline });
34
+ expect(frame).toBe('data: line 1\ndata: line 2\ndata: line 3\n\n');
35
+ });
36
+ });
37
+ describe('MemoryBroadcaster Engine', () => {
38
+ let broadcaster;
39
+ beforeEach(() => {
40
+ broadcaster = new MemoryBroadcaster();
41
+ });
42
+ afterEach(() => {
43
+ broadcaster.clear();
44
+ });
45
+ it('dispatches published messages only to matching channel subscribers', () => {
46
+ const channel1Handler = vi.fn();
47
+ const channel2Handler = vi.fn();
48
+ broadcaster.subscribe('channel:1', channel1Handler);
49
+ broadcaster.subscribe('channel:2', channel2Handler);
50
+ broadcaster.publish('channel:1', 'ping', { test: true });
51
+ expect(channel1Handler).toHaveBeenCalledTimes(1);
52
+ expect(channel1Handler).toHaveBeenCalledWith({
53
+ channel: 'channel:1',
54
+ event: 'ping',
55
+ payload: { test: true },
56
+ id: undefined
57
+ });
58
+ expect(channel2Handler).not.toHaveBeenCalled();
59
+ });
60
+ it('unsubscribes cleanly using returned cleanup function', () => {
61
+ const handler = vi.fn();
62
+ const unsubscribe = broadcaster.subscribe('room:lobby', handler);
63
+ expect(broadcaster.subscriberCount('room:lobby')).toBe(1);
64
+ broadcaster.publish('room:lobby', 'msg', 'first');
65
+ expect(handler).toHaveBeenCalledTimes(1);
66
+ unsubscribe();
67
+ expect(broadcaster.subscriberCount('room:lobby')).toBe(0);
68
+ broadcaster.publish('room:lobby', 'msg', 'second');
69
+ expect(handler).toHaveBeenCalledTimes(1);
70
+ });
71
+ it('isolates subscriber errors without halting other subscribers', () => {
72
+ const failingSubscriber = vi.fn(() => {
73
+ throw new Error('Subscriber crashed');
74
+ });
75
+ const healthySubscriber = vi.fn();
76
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
77
+ broadcaster.subscribe('alerts', failingSubscriber);
78
+ broadcaster.subscribe('alerts', healthySubscriber);
79
+ broadcaster.publish('alerts', 'danger', { level: 'high' });
80
+ expect(failingSubscriber).toHaveBeenCalledTimes(1);
81
+ expect(healthySubscriber).toHaveBeenCalledTimes(1);
82
+ expect(consoleSpy).toHaveBeenCalled();
83
+ consoleSpy.mockRestore();
84
+ });
85
+ });
86
+ describe('yaxaSSE Server Handler', () => {
87
+ let broadcaster;
88
+ beforeEach(() => {
89
+ broadcaster = new MemoryBroadcaster();
90
+ });
91
+ afterEach(() => {
92
+ broadcaster.clear();
93
+ });
94
+ it('sets standard SSE response headers', async () => {
95
+ const handler = yaxaSSE({ broadcaster, keepAliveIntervalMs: 0 });
96
+ const event = createMockRequestEvent({ url: 'http://localhost:5173/api/events' });
97
+ const response = await handler(event);
98
+ expect(response.status).toBe(200);
99
+ expect(response.headers.get('content-type')).toBe('text/event-stream; charset=utf-8');
100
+ expect(response.headers.get('cache-control')).toBe('no-cache, no-transform');
101
+ expect(response.headers.get('connection')).toBe('keep-alive');
102
+ expect(response.headers.get('x-accel-buffering')).toBe('no');
103
+ expect(response.body).toBeInstanceOf(ReadableStream);
104
+ });
105
+ it('evaluates declarative guards and blocks unauthenticated requests with 401 Problem Details', async () => {
106
+ const handler = yaxaSSE({
107
+ guards: [requireAuth()],
108
+ broadcaster
109
+ });
110
+ // Event without authenticated user
111
+ const event = createMockRequestEvent({
112
+ url: 'http://localhost:5173/api/events',
113
+ locals: {}
114
+ });
115
+ const response = await handler(event);
116
+ expect(response.status).toBe(401);
117
+ expect(response.headers.get('content-type')).toContain('application/problem+json');
118
+ const body = await response.json();
119
+ expect(body.title).toContain('Unauthorized');
120
+ });
121
+ it('invokes onConnect and delivers messages through client send', async () => {
122
+ let onConnectCalled = false;
123
+ const handler = yaxaSSE({
124
+ broadcaster,
125
+ keepAliveIntervalMs: 0,
126
+ channel: 'dashboard',
127
+ onConnect: ({ send }) => {
128
+ onConnectCalled = true;
129
+ send('welcome', { initialized: true });
130
+ }
131
+ });
132
+ const event = createMockRequestEvent({
133
+ url: 'http://localhost:5173/api/events',
134
+ locals: { user: { id: 'usr_123', email: 'alex@example.com' } }
135
+ });
136
+ const response = await handler(event);
137
+ expect(response.status).toBe(200);
138
+ const reader = response.body.getReader();
139
+ const decoder = new TextDecoder();
140
+ const { value } = await reader.read();
141
+ const text = decoder.decode(value);
142
+ expect(onConnectCalled).toBe(true);
143
+ expect(text).toContain('event: welcome\n');
144
+ expect(text).toContain('data: {"initialized":true}\n\n');
145
+ await reader.cancel();
146
+ });
147
+ it('receives events published via broadcast() helper', async () => {
148
+ const handler = yaxaSSE({
149
+ broadcaster,
150
+ keepAliveIntervalMs: 0,
151
+ channel: 'tenant:acme'
152
+ });
153
+ const event = createMockRequestEvent({
154
+ url: 'http://localhost:5173/api/events'
155
+ });
156
+ const response = await handler(event);
157
+ const reader = response.body.getReader();
158
+ const decoder = new TextDecoder();
159
+ // Broadcast an event on the channel
160
+ await broadcast('tenant:acme', 'kpi.updated', { value: 99.4 }, undefined, broadcaster);
161
+ const { value } = await reader.read();
162
+ const text = decoder.decode(value);
163
+ expect(text).toContain('event: kpi.updated\n');
164
+ expect(text).toContain('data: {"value":99.4}\n\n');
165
+ await reader.cancel();
166
+ });
167
+ it('resolves dynamic channel from RequestEvent', async () => {
168
+ const handler = yaxaSSE({
169
+ broadcaster,
170
+ keepAliveIntervalMs: 0,
171
+ channel: (evt) => `user:${evt.locals.user.id}`
172
+ });
173
+ const event = createMockRequestEvent({
174
+ url: 'http://localhost:5173/api/events',
175
+ locals: { user: { id: 'usr_abc' } }
176
+ });
177
+ const response = await handler(event);
178
+ const reader = response.body.getReader();
179
+ const decoder = new TextDecoder();
180
+ // Broadcast to matching dynamic channel
181
+ await broadcast('user:usr_abc', 'notification', { msg: 'Test' }, undefined, broadcaster);
182
+ const { value } = await reader.read();
183
+ const text = decoder.decode(value);
184
+ expect(text).toContain('event: notification\n');
185
+ expect(text).toContain('data: {"msg":"Test"}\n\n');
186
+ await reader.cancel();
187
+ });
188
+ it('invokes onDisconnect when stream is cancelled or client aborts', async () => {
189
+ let disconnected = false;
190
+ const abortController = new AbortController();
191
+ const handler = yaxaSSE({
192
+ broadcaster,
193
+ keepAliveIntervalMs: 0,
194
+ channel: 'session',
195
+ onDisconnect: () => {
196
+ disconnected = true;
197
+ }
198
+ });
199
+ const event = createMockRequestEvent({
200
+ url: 'http://localhost:5173/api/events',
201
+ signal: abortController.signal
202
+ });
203
+ const response = await handler(event);
204
+ expect(response.status).toBe(200);
205
+ // Trigger stream cancel
206
+ const reader = response.body.getReader();
207
+ await reader.cancel();
208
+ expect(disconnected).toBe(true);
209
+ });
210
+ });
@@ -0,0 +1,28 @@
1
+ import type { RequestHandler } from '@sveltejs/kit';
2
+ import type { SSEMessage, YaxaSSEOptions } from './types';
3
+ /**
4
+ * Formats a payload into a valid Server-Sent Events text frame.
5
+ */
6
+ export declare function formatSSEFrame<T = unknown>(message: SSEMessage<T>): string;
7
+ /**
8
+ * Creates a turnkey SvelteKit Server-Sent Events (SSE) RequestHandler with
9
+ * guard evaluation, automatic keep-alive pings, and channel broadcasting.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // src/routes/api/events/+server.ts
14
+ * import { yaxaSSE, requireAuth } from 'yaxa-svelte/server';
15
+ *
16
+ * export const GET = yaxaSSE({
17
+ * guards: [requireAuth()],
18
+ * channel: (event) => `org:${event.locals.orgId}`,
19
+ * keepAliveIntervalMs: 15000,
20
+ * onConnect: ({ send }) => {
21
+ * send('welcome', { time: Date.now() });
22
+ * }
23
+ * });
24
+ * ```
25
+ */
26
+ export declare function yaxaSSE(options?: YaxaSSEOptions): RequestHandler & {
27
+ _yaxaConfig: any;
28
+ };
@@ -0,0 +1,225 @@
1
+ import { globalBroadcaster } from './broadcaster';
2
+ import { problemResponse } from '../endpoint/problem';
3
+ /**
4
+ * Formats a payload into a valid Server-Sent Events text frame.
5
+ */
6
+ export function formatSSEFrame(message) {
7
+ let output = '';
8
+ if (message.id !== undefined) {
9
+ output += `id: ${message.id}\n`;
10
+ }
11
+ if (message.event !== undefined) {
12
+ output += `event: ${message.event}\n`;
13
+ }
14
+ if (message.retry !== undefined) {
15
+ output += `retry: ${message.retry}\n`;
16
+ }
17
+ const dataStr = typeof message.data === 'object' && message.data !== null
18
+ ? JSON.stringify(message.data)
19
+ : String(message.data ?? '');
20
+ // SSE specification requires multiple 'data: ' lines if data contains newlines
21
+ const lines = dataStr.split(/\r\n|\r|\n/);
22
+ for (const line of lines) {
23
+ output += `data: ${line}\n`;
24
+ }
25
+ output += '\n';
26
+ return output;
27
+ }
28
+ /**
29
+ * Creates a turnkey SvelteKit Server-Sent Events (SSE) RequestHandler with
30
+ * guard evaluation, automatic keep-alive pings, and channel broadcasting.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * // src/routes/api/events/+server.ts
35
+ * import { yaxaSSE, requireAuth } from 'yaxa-svelte/server';
36
+ *
37
+ * export const GET = yaxaSSE({
38
+ * guards: [requireAuth()],
39
+ * channel: (event) => `org:${event.locals.orgId}`,
40
+ * keepAliveIntervalMs: 15000,
41
+ * onConnect: ({ send }) => {
42
+ * send('welcome', { time: Date.now() });
43
+ * }
44
+ * });
45
+ * ```
46
+ */
47
+ export function yaxaSSE(options = {}) {
48
+ const keepAliveIntervalMs = options.keepAliveIntervalMs ?? 15_000;
49
+ const keepAliveComment = options.keepAliveComment ?? ': ping\n\n';
50
+ const broadcaster = options.broadcaster ?? globalBroadcaster;
51
+ const sseHandler = async (event) => {
52
+ const { request, locals } = event;
53
+ // 1. Evaluate guards if configured
54
+ if (options.guards && options.guards.length > 0) {
55
+ for (const guard of options.guards) {
56
+ const result = await guard(event);
57
+ if (result instanceof Response) {
58
+ return result;
59
+ }
60
+ if (result && typeof result === 'object' && 'status' in result && 'title' in result) {
61
+ return problemResponse(result);
62
+ }
63
+ if (result === false) {
64
+ return problemResponse({
65
+ status: 403,
66
+ title: 'Forbidden',
67
+ detail: 'Access denied by endpoint guard.'
68
+ });
69
+ }
70
+ }
71
+ }
72
+ // 2. Resolve target channel
73
+ let channel = 'default';
74
+ if (typeof options.channel === 'function') {
75
+ channel = await options.channel(event);
76
+ }
77
+ else if (typeof options.channel === 'string' && options.channel.length > 0) {
78
+ channel = options.channel;
79
+ }
80
+ const user = locals?.user ?? null;
81
+ const orgId = locals?.orgId ?? locals?.org?.id ?? null;
82
+ const connectionId = `sse_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
83
+ const encoder = new TextEncoder();
84
+ let unsubscribe = null;
85
+ let pingTimer = null;
86
+ let isClosed = false;
87
+ let clientCloseFn = null;
88
+ const stream = new ReadableStream({
89
+ start(controller) {
90
+ const safeEnqueue = (text) => {
91
+ if (isClosed)
92
+ return false;
93
+ try {
94
+ controller.enqueue(encoder.encode(text));
95
+ return true;
96
+ }
97
+ catch {
98
+ isClosed = true;
99
+ return false;
100
+ }
101
+ };
102
+ const clientSend = (eventOrMessage, data) => {
103
+ if (isClosed)
104
+ return false;
105
+ let msg;
106
+ if (typeof eventOrMessage === 'string') {
107
+ msg = { event: eventOrMessage, data: data };
108
+ }
109
+ else {
110
+ msg = eventOrMessage;
111
+ }
112
+ return safeEnqueue(formatSSEFrame(msg));
113
+ };
114
+ const clientClose = () => {
115
+ if (isClosed)
116
+ return;
117
+ isClosed = true;
118
+ if (pingTimer) {
119
+ clearInterval(pingTimer);
120
+ pingTimer = null;
121
+ }
122
+ if (unsubscribe) {
123
+ unsubscribe();
124
+ unsubscribe = null;
125
+ }
126
+ try {
127
+ controller.close();
128
+ }
129
+ catch {
130
+ // Stream might already be closed by client
131
+ }
132
+ if (options.onDisconnect) {
133
+ Promise.resolve(options.onDisconnect({
134
+ connectionId,
135
+ channel,
136
+ event,
137
+ user,
138
+ orgId
139
+ })).catch((err) => {
140
+ console.error('[Yaxa SSE onDisconnect Error]:', err);
141
+ });
142
+ }
143
+ };
144
+ clientCloseFn = clientClose;
145
+ // Subscribe client to channel broadcast events
146
+ unsubscribe = broadcaster.subscribe(channel, (msg) => {
147
+ clientSend({
148
+ event: msg.event,
149
+ data: msg.payload,
150
+ id: msg.id
151
+ });
152
+ });
153
+ // Heartbeat loop to keep connections open through proxies/firewalls
154
+ if (keepAliveIntervalMs > 0) {
155
+ pingTimer = setInterval(() => {
156
+ safeEnqueue(keepAliveComment);
157
+ }, keepAliveIntervalMs);
158
+ }
159
+ // Abort handling when client closes connection or navigates away
160
+ if (request.signal) {
161
+ request.signal.addEventListener('abort', clientClose, { once: true });
162
+ }
163
+ // Context provided to onConnect
164
+ const clientContext = {
165
+ connectionId,
166
+ channel,
167
+ event,
168
+ user,
169
+ orgId,
170
+ send: clientSend,
171
+ close: clientClose
172
+ };
173
+ // Invoke onConnect callback
174
+ if (options.onConnect) {
175
+ Promise.resolve(options.onConnect(clientContext)).catch((err) => {
176
+ console.error('[Yaxa SSE onConnect Error]:', err);
177
+ });
178
+ }
179
+ },
180
+ cancel() {
181
+ if (clientCloseFn) {
182
+ clientCloseFn();
183
+ }
184
+ else {
185
+ isClosed = true;
186
+ if (pingTimer) {
187
+ clearInterval(pingTimer);
188
+ pingTimer = null;
189
+ }
190
+ if (unsubscribe) {
191
+ unsubscribe();
192
+ unsubscribe = null;
193
+ }
194
+ }
195
+ }
196
+ });
197
+ return new Response(stream, {
198
+ status: 200,
199
+ headers: {
200
+ 'content-type': 'text/event-stream; charset=utf-8',
201
+ 'cache-control': 'no-cache, no-transform',
202
+ connection: 'keep-alive',
203
+ 'x-accel-buffering': 'no'
204
+ }
205
+ });
206
+ };
207
+ sseHandler._yaxaConfig = {
208
+ summary: options.summary || 'Server-Sent Events Stream',
209
+ description: options.description ||
210
+ 'Real-time Server-Sent Events stream with multi-channel pub/sub and keep-alive heartbeats.',
211
+ guards: options.guards,
212
+ status: 200,
213
+ responses: {
214
+ '200': {
215
+ description: 'Active event stream',
216
+ content: {
217
+ 'text/event-stream': {
218
+ schema: { type: 'string' }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ };
224
+ return sseHandler;
225
+ }
@@ -0,0 +1,116 @@
1
+ import type { RequestEvent } from '@sveltejs/kit';
2
+ import type { Guard } from '../endpoint/types';
3
+ /**
4
+ * Structured Server-Sent Event message payload.
5
+ */
6
+ export interface SSEMessage<T = unknown> {
7
+ /** Optional unique event ID for client Last-Event-ID tracking */
8
+ id?: string;
9
+ /** Optional named event type (defaults to 'message') */
10
+ event?: string;
11
+ /** Payload data to transmit. Objects/arrays are JSON-encoded automatically */
12
+ data: T;
13
+ /** Optional reconnection delay in milliseconds suggested to the client */
14
+ retry?: number;
15
+ }
16
+ /**
17
+ * Context provided to onConnect and event callbacks.
18
+ */
19
+ export interface SSEClientContext {
20
+ /** Unique connection ID */
21
+ connectionId: string;
22
+ /** Active channel name */
23
+ channel: string;
24
+ /** The incoming SvelteKit RequestEvent */
25
+ event: RequestEvent;
26
+ /** Resolved user from guards/locals */
27
+ user?: any;
28
+ /** Active organization ID from guards/locals */
29
+ orgId?: string | null;
30
+ /** Send an SSE message directly to this client */
31
+ send: <T = unknown>(eventOrMessage: string | SSEMessage<T>, data?: T) => boolean;
32
+ /** Explicitly close this SSE connection */
33
+ close: () => void;
34
+ }
35
+ /**
36
+ * Configuration options for yaxaSSE endpoint handler.
37
+ */
38
+ export interface YaxaSSEOptions {
39
+ /** Summary for OpenAPI documentation */
40
+ summary?: string;
41
+ /** Description for OpenAPI documentation */
42
+ description?: string;
43
+ /**
44
+ * Declarative guards executed before establishing the SSE connection.
45
+ * If a guard fails, an RFC 7807 Problem Details response (e.g. 401, 403, 429) is returned.
46
+ */
47
+ guards?: Guard[];
48
+ /**
49
+ * Channel name to subscribe this client to. Can be a static string or a dynamic function
50
+ * of the incoming RequestEvent (e.g. `(event) => \`org:\${event.locals.orgId}\``).
51
+ * Defaults to `'default'`.
52
+ */
53
+ channel?: string | ((event: RequestEvent) => string | Promise<string>);
54
+ /**
55
+ * Keep-alive ping interval in milliseconds to prevent reverse proxies, firewalls,
56
+ * and serverless platforms (e.g. Vercel) from terminating idle connections.
57
+ * Set to 0 to disable. Defaults to 15,000ms (15s).
58
+ */
59
+ keepAliveIntervalMs?: number;
60
+ /**
61
+ * Custom comment or payload for keep-alive pings.
62
+ * Defaults to `: ping\n\n`.
63
+ */
64
+ keepAliveComment?: string;
65
+ /**
66
+ * Hook called immediately after connection establishment and headers transmission.
67
+ */
68
+ onConnect?: (ctx: SSEClientContext) => void | Promise<void>;
69
+ /**
70
+ * Hook called when the client disconnects or the request is aborted.
71
+ */
72
+ onDisconnect?: (ctx: Omit<SSEClientContext, 'send' | 'close'>) => void | Promise<void>;
73
+ /**
74
+ * Custom broadcaster instance. If omitted, uses the global broadcaster singleton.
75
+ */
76
+ broadcaster?: Broadcaster;
77
+ }
78
+ /**
79
+ * Broadcast message contract.
80
+ */
81
+ export interface BroadcastMessage<T = unknown> {
82
+ channel: string;
83
+ event: string;
84
+ payload: T;
85
+ id?: string;
86
+ }
87
+ /**
88
+ * Broadcaster subscription callback.
89
+ */
90
+ export type BroadcastSubscriber<T = unknown> = (msg: BroadcastMessage<T>) => void;
91
+ /**
92
+ * Broadcaster adapter interface.
93
+ * Can be implemented by MemoryBroadcaster, RedisBroadcaster, ValkeyBroadcaster, etc.
94
+ */
95
+ export interface Broadcaster {
96
+ /**
97
+ * Publish an event to all subscribers listening on the specified channel.
98
+ */
99
+ publish: <T = unknown>(channel: string, event: string, payload: T, id?: string) => Promise<void> | void;
100
+ /**
101
+ * Subscribe to events on a specific channel. Returns an unsubscribe function.
102
+ */
103
+ subscribe: <T = unknown>(channel: string, subscriber: BroadcastSubscriber<T>) => () => void;
104
+ /**
105
+ * Unsubscribe a callback from a channel.
106
+ */
107
+ unsubscribe: <T = unknown>(channel: string, subscriber: BroadcastSubscriber<T>) => void;
108
+ /**
109
+ * Returns current number of active subscribers on a channel (optional).
110
+ */
111
+ subscriberCount?: (channel: string) => number;
112
+ /**
113
+ * Remove all subscribers across all channels.
114
+ */
115
+ clear?: () => void;
116
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -85,6 +85,8 @@ export interface MockRequestEventOptions {
85
85
  params?: Record<string, string>;
86
86
  locals?: Record<string, any>;
87
87
  cookies?: Record<string, string>;
88
+ signal?: AbortSignal;
89
+ request?: Request;
88
90
  }
89
91
  /**
90
92
  * Creates a mocked typed SvelteKit RequestEvent for unit testing endpoints and server hooks.
@@ -124,11 +124,13 @@ export function createMockRequestEvent(options = {}) {
124
124
  }
125
125
  }
126
126
  }
127
- const request = new Request(url.toString(), {
128
- method,
129
- headers,
130
- body: method !== 'GET' && method !== 'HEAD' ? bodyContent : undefined
131
- });
127
+ const request = options.request ||
128
+ new Request(url.toString(), {
129
+ method,
130
+ headers,
131
+ body: method !== 'GET' && method !== 'HEAD' ? bodyContent : undefined,
132
+ signal: options.signal
133
+ });
132
134
  const cookieStore = new Map(Object.entries(options.cookies || {}));
133
135
  const cookies = {
134
136
  get: (name) => cookieStore.get(name),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaxa-svelte",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "The Intuitive Svelte UI Library — Nuxt UI v4 equivalent for SvelteKit 2 & 3 & Svelte 5 with automated SEO, dynamic OpenGraph cards, sitemaps, robots.txt, and Tailwind CSS v4.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://yaxa.vercel.app",