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.
- package/README.md +107 -15
- package/dist/composables/useSSE.svelte.d.ts +53 -0
- package/dist/composables/useSSE.svelte.js +209 -0
- package/dist/composables/useSSE.test.d.ts +1 -0
- package/dist/composables/useSSE.test.js +122 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/server/db/schema-pg.d.ts +262 -0
- package/dist/server/db/schema-pg.js +20 -0
- package/dist/server/db/schema-sqlite.d.ts +276 -0
- package/dist/server/db/schema-sqlite.js +20 -0
- package/dist/server/db/types.d.ts +18 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/jobs/events.d.ts +29 -0
- package/dist/server/jobs/events.js +74 -0
- package/dist/server/jobs/index.d.ts +4 -0
- package/dist/server/jobs/index.js +3 -0
- package/dist/server/jobs/jobs.test.d.ts +1 -0
- package/dist/server/jobs/jobs.test.js +189 -0
- package/dist/server/jobs/queue.d.ts +14 -0
- package/dist/server/jobs/queue.js +90 -0
- package/dist/server/jobs/runner.d.ts +12 -0
- package/dist/server/jobs/runner.js +176 -0
- package/dist/server/jobs/types.d.ts +136 -0
- package/dist/server/jobs/types.js +1 -0
- package/dist/server/openapi/handler.js +5 -3
- package/dist/server/openapi/openapi.test.js +13 -0
- package/dist/server/realtime/broadcaster.d.ts +31 -0
- package/dist/server/realtime/broadcaster.js +76 -0
- package/dist/server/realtime/index.d.ts +3 -0
- package/dist/server/realtime/index.js +3 -0
- package/dist/server/realtime/realtime.test.d.ts +1 -0
- package/dist/server/realtime/realtime.test.js +210 -0
- package/dist/server/realtime/sse.d.ts +28 -0
- package/dist/server/realtime/sse.js +225 -0
- package/dist/server/realtime/types.d.ts +116 -0
- package/dist/server/realtime/types.js +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +7 -5
- 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
|
|
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';
|