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,136 @@
|
|
|
1
|
+
import type { AnyDb } from '../db';
|
|
2
|
+
import type { JobStatus } from '../db/types';
|
|
3
|
+
/**
|
|
4
|
+
* Persisted Job record structure.
|
|
5
|
+
*/
|
|
6
|
+
export interface JobRecord<TPayload = any> {
|
|
7
|
+
id: string;
|
|
8
|
+
queue: string;
|
|
9
|
+
name: string;
|
|
10
|
+
payload: TPayload;
|
|
11
|
+
status: JobStatus;
|
|
12
|
+
priority: number;
|
|
13
|
+
attempts: number;
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
runAt: Date;
|
|
16
|
+
lockedAt?: Date | null;
|
|
17
|
+
lockedBy?: string | null;
|
|
18
|
+
failedReason?: string | null;
|
|
19
|
+
createdAt: Date;
|
|
20
|
+
updatedAt: Date;
|
|
21
|
+
completedAt?: Date | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Delay specifier:
|
|
25
|
+
* - String format: '30s', '5m', '2h', '1d'
|
|
26
|
+
* - Number: delay in milliseconds
|
|
27
|
+
* - Date: absolute execution timestamp
|
|
28
|
+
*/
|
|
29
|
+
export type JobDelay = string | number | Date;
|
|
30
|
+
export interface EnqueueJobOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Queue partition name (default: 'default').
|
|
33
|
+
*/
|
|
34
|
+
queue?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Execution delay or scheduled Date.
|
|
37
|
+
*/
|
|
38
|
+
delay?: JobDelay;
|
|
39
|
+
/**
|
|
40
|
+
* Job execution priority (higher number = higher priority). Default: 0.
|
|
41
|
+
*/
|
|
42
|
+
priority?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Maximum retry attempts before routing to Dead Letter Queue (DLQ). Default: 3.
|
|
45
|
+
*/
|
|
46
|
+
maxAttempts?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Optional Drizzle transaction object for the Transactional Outbox pattern.
|
|
49
|
+
*/
|
|
50
|
+
tx?: any;
|
|
51
|
+
/**
|
|
52
|
+
* Optional custom Drizzle DB client instance.
|
|
53
|
+
*/
|
|
54
|
+
db?: AnyDb;
|
|
55
|
+
}
|
|
56
|
+
export interface WorkerContext<TPayload = any> {
|
|
57
|
+
job: JobRecord<TPayload>;
|
|
58
|
+
logger: {
|
|
59
|
+
info: (msg: string, ...args: any[]) => void;
|
|
60
|
+
warn: (msg: string, ...args: any[]) => void;
|
|
61
|
+
error: (msg: string, ...args: any[]) => void;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export type WorkerHandler<TPayload = any> = (payload: TPayload, context: WorkerContext<TPayload>) => Promise<void> | void;
|
|
65
|
+
export interface WorkerDefinition<TPayload = any> {
|
|
66
|
+
name: string;
|
|
67
|
+
handler: WorkerHandler<TPayload>;
|
|
68
|
+
queue?: string;
|
|
69
|
+
}
|
|
70
|
+
export interface JobRunnerOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Drizzle database instance (Neon or Turso).
|
|
73
|
+
*/
|
|
74
|
+
db?: AnyDb;
|
|
75
|
+
/**
|
|
76
|
+
* Database driver ('neon' | 'turso' | 'sqlite').
|
|
77
|
+
*/
|
|
78
|
+
driver?: 'neon' | 'turso' | 'sqlite';
|
|
79
|
+
/**
|
|
80
|
+
* List of registered worker handlers.
|
|
81
|
+
*/
|
|
82
|
+
workers: WorkerDefinition<any>[];
|
|
83
|
+
/**
|
|
84
|
+
* Target queue name to poll (default: 'default').
|
|
85
|
+
*/
|
|
86
|
+
queue?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Maximum jobs to lease and process per batch (default: 10).
|
|
89
|
+
*/
|
|
90
|
+
batchSize?: number;
|
|
91
|
+
/**
|
|
92
|
+
* Polling interval in milliseconds for daemon mode (default: 3000ms).
|
|
93
|
+
*/
|
|
94
|
+
pollIntervalMs?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Lock timeout in milliseconds before a processing job is considered stale (default: 5 minutes).
|
|
97
|
+
*/
|
|
98
|
+
lockTimeoutMs?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Worker instance identifier (default: generated worker id).
|
|
101
|
+
*/
|
|
102
|
+
workerId?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface JobBatchResult {
|
|
105
|
+
processed: number;
|
|
106
|
+
succeeded: number;
|
|
107
|
+
failed: number;
|
|
108
|
+
}
|
|
109
|
+
export interface JobRunner {
|
|
110
|
+
/**
|
|
111
|
+
* Processes the next batch of pending jobs. Perfect for serverless cron endpoints (/api/cron/jobs).
|
|
112
|
+
*/
|
|
113
|
+
processNextBatch: () => Promise<JobBatchResult>;
|
|
114
|
+
/**
|
|
115
|
+
* Starts the continuous background polling loop (for long-lived Node.js servers).
|
|
116
|
+
*/
|
|
117
|
+
start: () => void;
|
|
118
|
+
/**
|
|
119
|
+
* Stops the background polling loop.
|
|
120
|
+
*/
|
|
121
|
+
stop: () => void;
|
|
122
|
+
/**
|
|
123
|
+
* Returns true if the polling runner is currently active.
|
|
124
|
+
*/
|
|
125
|
+
isRunning: () => boolean;
|
|
126
|
+
}
|
|
127
|
+
export type EventHandler<TData = any> = (data: TData) => Promise<void> | void;
|
|
128
|
+
export interface EventSubscription {
|
|
129
|
+
unsubscribe: () => void;
|
|
130
|
+
}
|
|
131
|
+
export interface IEventBus {
|
|
132
|
+
publish: <TData = any>(eventName: string, data: TData) => Promise<void>;
|
|
133
|
+
subscribe: <TData = any>(eventName: string, handler: EventHandler<TData>) => EventSubscription;
|
|
134
|
+
once: <TData = any>(eventName: string, handler: EventHandler<TData>) => EventSubscription;
|
|
135
|
+
clear: () => void;
|
|
136
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
};
|