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 +62 -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/index.d.ts +2 -0
- package/dist/server/index.js +2 -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,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,
|
|
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';
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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 (
|
|
103
|
+
if (activeTheme === 'swagger') {
|
|
102
104
|
html = renderSwaggerHtml(specJson, spec.info.title);
|
|
103
105
|
}
|
|
104
|
-
else if (
|
|
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 @@
|
|
|
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 {};
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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.
|
package/dist/testing/index.js
CHANGED
|
@@ -124,11 +124,13 @@ export function createMockRequestEvent(options = {}) {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
-
const request =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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",
|