/**
 * Per-user notification SSE stream.
 *
 * Mirrors `preview/sse-stream.ts` for the notification bus: subscribe
 * to the user's channel, forward every `NotificationDTO` to the
 * connected `EventSource` client, emit periodic heartbeats so proxies
 * don't kill the idle connection, and close cleanly after a bounded
 * lifetime so the client reconnects before platform max-duration caps
 * fire.
 *
 * Wire format:
 *
 *   event: ready                    (sent once on connect)
 *   data: {"userId":"<uid>"}
 *
 *   event: notification             (sent for each published DTO)
 *   data: <NotificationDTO JSON>
 *
 *   : heartbeat\n\n                 (every 25 s, no `event:` line)
 *
 * The `ready` frame is the client's signal that the server-side
 * subscription is wired up. Until it arrives, the client must NOT
 * stop polling — there's still a window where the open() handshake
 * has fired but the bus subscription hasn't been attached yet.
 */
import { subscribeUserNotifications } from './notification-bus.js';
import { encodeSseEvent } from './sse.js';
export { encodeSseEvent };
const encoder = new TextEncoder();
/**
 * Heartbeat interval. 25 s sits safely under the most aggressive
 * proxy/idle timeouts we've encountered in production (Cloudflare 30 s,
 * Vercel Serverless 30 s, most reverse-proxies 60 s). Identical to the
 * preview stream's value so both channels behave the same way under
 * the same proxy fleet.
 */
export const NOTIFICATION_SSE_HEARTBEAT_MS = 25_000;
/**
 * Maximum stream lifetime in ms. After this, the server closes the
 * stream cleanly so `EventSource` reconnects — important because some
 * platforms (Vercel Fluid Compute, Cloudflare Workers) cap any single
 * response at a few minutes regardless of activity.
 */
export const NOTIFICATION_SSE_MAX_LIFETIME_MS = 4 * 60 * 1000;
/**
 * Build a `ReadableStream<Uint8Array>` of notification SSE events for
 * the given userId. See the file header for the wire format.
 *
 * The stream:
 *
 *   1. Emits the `ready` frame.
 *   2. Subscribes to the notification bus for `userId`.
 *   3. Forwards every `publishNotification(...)` to the client.
 *   4. Sends heartbeats every `heartbeatMs`.
 *   5. Closes cleanly after `maxLifetimeMs`, letting `EventSource`
 *      auto-reconnect (the browser does this for us at the HTTP/2
 *      stream layer).
 *
 * On `cancel()` (client disconnect or `controller.close()`), every
 * timer is cleared and the bus subscription is dropped — no leaked
 * listener after a hang-up.
 */
export function createNotificationStream(userId, opts = {}) {
    if (!userId) {
        throw new Error('createNotificationStream requires a non-empty userId');
    }
    const heartbeatMs = opts.heartbeatMs ?? NOTIFICATION_SSE_HEARTBEAT_MS;
    const maxLifetimeMs = opts.maxLifetimeMs ?? NOTIFICATION_SSE_MAX_LIFETIME_MS;
    let heartbeat;
    let lifetime;
    let unsubscribe;
    let torn = false;
    // Single teardown path shared by:
    //   - the lifetime timer (server-initiated close before the
    //     platform's max-duration cap fires), and
    //   - the consumer's reader.cancel() (client disconnect).
    // `cancel()` is NOT invoked when the controller closes itself —
    // only on consumer-initiated cancellation. Without this shared
    // helper we'd leak the bus subscription whenever the lifetime
    // timer fires.
    const teardown = () => {
        if (torn)
            return;
        torn = true;
        if (heartbeat)
            clearInterval(heartbeat);
        if (lifetime)
            clearTimeout(lifetime);
        if (unsubscribe)
            unsubscribe();
    };
    return new ReadableStream({
        start(controller) {
            const enqueue = (chunk) => {
                try {
                    controller.enqueue(encoder.encode(chunk));
                }
                catch {
                    // Controller can be closed mid-emit if the client disconnected
                    // between the publishNotification call and the enqueue.
                    // Swallow — teardown() takes care of the rest.
                }
            };
            enqueue(encodeSseEvent({ userId }, 'ready'));
            unsubscribe = subscribeUserNotifications(userId, (notification) => {
                enqueue(encodeSseEvent(notification, 'notification'));
            });
            heartbeat = setInterval(() => {
                enqueue(`: heartbeat\n\n`);
            }, heartbeatMs);
            lifetime = setTimeout(() => {
                // Drop the bus subscription BEFORE closing the controller —
                // otherwise the listener would still be attached for the
                // microtask between close() and the next event loop tick.
                teardown();
                try {
                    controller.close();
                }
                catch {
                    /* already closed */
                }
            }, maxLifetimeMs);
        },
        cancel() {
            teardown();
        },
    });
}
/**
 * Standard SSE response headers. Identical to the preview stream's
 * header set — same proxy fleet, same X-Accel-Buffering disable, same
 * cache-control directives.
 */
export const NOTIFICATION_SSE_HEADERS = {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
    // Discourage proxies/CDNs from buffering — without this nginx will
    // hold events back until 4 KB of payload accumulates.
    'X-Accel-Buffering': 'no',
};
export function createNotificationStreamResponse(userId, opts) {
    return new Response(createNotificationStream(userId, opts), {
        status: 200,
        headers: NOTIFICATION_SSE_HEADERS,
    });
}
//# sourceMappingURL=notification-stream.js.map