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
|
@@ -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",
|