yaxa-svelte 2.3.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.
Files changed (41) hide show
  1. package/README.md +107 -15
  2. package/dist/composables/useSSE.svelte.d.ts +53 -0
  3. package/dist/composables/useSSE.svelte.js +209 -0
  4. package/dist/composables/useSSE.test.d.ts +1 -0
  5. package/dist/composables/useSSE.test.js +122 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +1 -0
  8. package/dist/server/db/schema-pg.d.ts +262 -0
  9. package/dist/server/db/schema-pg.js +20 -0
  10. package/dist/server/db/schema-sqlite.d.ts +276 -0
  11. package/dist/server/db/schema-sqlite.js +20 -0
  12. package/dist/server/db/types.d.ts +18 -0
  13. package/dist/server/index.d.ts +4 -0
  14. package/dist/server/index.js +4 -0
  15. package/dist/server/jobs/events.d.ts +29 -0
  16. package/dist/server/jobs/events.js +74 -0
  17. package/dist/server/jobs/index.d.ts +4 -0
  18. package/dist/server/jobs/index.js +3 -0
  19. package/dist/server/jobs/jobs.test.d.ts +1 -0
  20. package/dist/server/jobs/jobs.test.js +189 -0
  21. package/dist/server/jobs/queue.d.ts +14 -0
  22. package/dist/server/jobs/queue.js +90 -0
  23. package/dist/server/jobs/runner.d.ts +12 -0
  24. package/dist/server/jobs/runner.js +176 -0
  25. package/dist/server/jobs/types.d.ts +136 -0
  26. package/dist/server/jobs/types.js +1 -0
  27. package/dist/server/openapi/handler.js +5 -3
  28. package/dist/server/openapi/openapi.test.js +13 -0
  29. package/dist/server/realtime/broadcaster.d.ts +31 -0
  30. package/dist/server/realtime/broadcaster.js +76 -0
  31. package/dist/server/realtime/index.d.ts +3 -0
  32. package/dist/server/realtime/index.js +3 -0
  33. package/dist/server/realtime/realtime.test.d.ts +1 -0
  34. package/dist/server/realtime/realtime.test.js +210 -0
  35. package/dist/server/realtime/sse.d.ts +28 -0
  36. package/dist/server/realtime/sse.js +225 -0
  37. package/dist/server/realtime/types.d.ts +116 -0
  38. package/dist/server/realtime/types.js +1 -0
  39. package/dist/testing/index.d.ts +2 -0
  40. package/dist/testing/index.js +7 -5
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -295,27 +295,119 @@ 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
  ---
302
303
 
304
+ ## ⏱️ Embedded Background Workflows & Persistent Job Queue
305
+
306
+ Persistent, zero-infrastructure background task processing backed by your existing Drizzle database (Postgres or SQLite/Turso). Features atomic row leasing, exponential retry backoff with jitter, Dead-Letter Queue (DLQ), and dual execution models (Serverless Cron or long-running worker):
307
+
308
+ ```ts
309
+ import { enqueueJob, defineWorker, createJobRunner, eventBus } from 'yaxa-svelte/server';
310
+
311
+ // 1. Decoupled domain events
312
+ eventBus.on('user.created', async ({ userId, email }) => {
313
+ // 2. Enqueue background job (optionally pass active transaction { tx } for transactional outbox)
314
+ await enqueueJob(
315
+ 'send-welcome-email',
316
+ { userId, email },
317
+ {
318
+ delay: '5m', // '30s', '5m', '2h', or ms
319
+ priority: 10,
320
+ maxAttempts: 5
321
+ }
322
+ );
323
+ });
324
+
325
+ // 3. Define worker
326
+ const emailWorker = defineWorker({
327
+ name: 'send-welcome-email',
328
+ handler: async (payload, { attempt, log }) => {
329
+ log(`Sending welcome email (attempt ${attempt})`);
330
+ await sendEmail(payload.email);
331
+ }
332
+ });
333
+
334
+ // 4. Run in Serverless Cron (Vercel Cron) or background container
335
+ export const runner = createJobRunner({
336
+ workers: [emailWorker],
337
+ batchSize: 10
338
+ });
339
+
340
+ // In src/routes/api/cron/jobs/+server.ts:
341
+ export const GET = async () => {
342
+ const result = await runner.processNextBatch();
343
+ return Response.json(result);
344
+ };
345
+ ```
346
+
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
+
303
395
  ## 📦 Subpath Exports Architecture
304
396
 
305
- | Subpath | Description |
306
- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
307
- | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.) |
308
- | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
309
- | `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar/Swagger Docs, RFC 7807, Universal Server Hooks, SEO endpoints, Drizzle DB, Storage & Email |
310
- | `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
311
- | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
312
- | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
313
- | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
314
- | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
315
- | `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
316
- | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
317
- | `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
318
- | `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'` |
319
411
 
320
412
  ---
321
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';