yaxa-svelte 2.4.0 → 2.6.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 +111 -15
- package/dist/client/client.d.ts +27 -0
- package/dist/client/client.js +239 -0
- package/dist/client/client.test.d.ts +1 -0
- package/dist/client/client.test.js +197 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/client/types.d.ts +222 -0
- package/dist/client/types.js +1 -0
- 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 +4 -0
- package/dist/index.js +3 -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
|
@@ -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
|
+
}
|