{"version":3,"file":"create-event-stream.cjs","names":[],"sources":["../../src/sse/create-event-stream.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the body is the SSE line protocol — accumulate\n * `data:` lines until a blank line, track `id:` for Last-Event-ID, honour `retry:` —\n * plus the reconnect that uses the id it just tracked.\n */\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\nexport type EventStreamStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\" | \"error\";\n\nexport interface EventStreamMessage<T> {\n    /** Server-named event (default `\"message\"`). */\n    event: string;\n    /** Parsed payload — validated when `schema` is set, JSON-decoded when possible, raw string otherwise. */\n    data: T;\n    /** Server-supplied id, if any. */\n    id?: string;\n    /** Raw `MessageEvent` for advanced cases. */\n    raw: MessageEvent;\n}\n\nexport interface CreateEventStreamOptions<T> {\n    /** Send cookies with the EventSource handshake. Default: false. */\n    withCredentials?: boolean;\n    /** Subscribe to named events in addition to `message`. */\n    namedEvents?: readonly string[];\n    /** Treat these named events as heartbeat-only (no callback). Default: `[\"ping\"]`. */\n    heartbeatEvents?: readonly string[];\n    /** Max reconnect attempts. Default: 10. Pass 0 to disable reconnect. */\n    maxRetries?: number;\n    /** Initial backoff in ms; doubles per attempt, capped at `maxBackoff`. Default: 1000. */\n    initialBackoff?: number;\n    /** Maximum backoff in ms. Default: 30000. */\n    maxBackoff?: number;\n    /** Parse `event.data`. Defaults to JSON with raw-string fallback. */\n    parser?: (raw: string) => T;\n    /**\n     * A frame arrived that is not valid JSON, and no `parser` was supplied.\n     *\n     * Registering this drops the frame instead of delivering it: the previous\n     * behaviour handed `onMessage` the raw `string` announced as your message\n     * type, so the failure surfaced later, at the first property access, with\n     * nothing left pointing at the parse. Leave it out and that behaviour is\n     * kept, with a one-time warning in development builds.\n     */\n    onParseError?: (error: unknown, raw: string) => void;\n    /**\n     * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n     * anything else exposing `~standard` or `.safeParse`.\n     *\n     * Without it nothing changes: the payload reaches `onMessage` announced as\n     * `T` on the strength of the type argument alone, which is a promise about\n     * the server that TypeScript cannot keep. With it, a frame that does not\n     * match is **not** delivered — the same rule `onParseError` already follows —\n     * and `onValidationError` hears why. The value delivered is the schema's\n     * output, so coercions and defaults are honoured.\n     *\n     * When `parser` is also supplied, it decodes first and the schema validates\n     * what it returned.\n     *\n     * The validation must be synchronous. A frame is decoded inside the\n     * `message` handler and delivered from it, so an async schema would deliver\n     * frames in whatever order their validations settled; that case is reported\n     * through `onValidationError` instead of awaited.\n     */\n    schema?: SchemaLike<T>;\n    /**\n     * A frame was decoded but the `schema` refused it, so it was dropped.\n     *\n     * The one signal that does not depend on how the app's bundler resolves\n     * `process`: the one-time development warning behind `onParseError` needs\n     * `isDevBuild()` to be able to answer, and this callback is the app's own.\n     */\n    onValidationError?: (issues: SchemaIssue[], raw: string) => void;\n    onOpen?: () => void;\n    onMessage?: (message: EventStreamMessage<T>) => void;\n    onError?: (error: Event) => void;\n    onStatusChange?: (status: EventStreamStatus) => void;\n}\n\nexport interface EventStreamController {\n    close: () => void;\n    /** Force an immediate reconnect, resetting the retry counter. */\n    reconnect: () => void;\n    /** Current connection status. */\n    readonly status: EventStreamStatus;\n}\n\n/**\n * Open a Server-Sent Events stream with automatic exponential-backoff reconnect.\n *\n * Heartbeat events (default `\"ping\"`) keep the socket alive without firing\n * `onMessage`. Pass `withCredentials: true` when the backend authenticates via\n * cookies. Call `close()` from the returned controller to tear down.\n *\n * @param url - Full SSE endpoint URL.\n * @param options - Stream configuration and callbacks.\n * @returns A controller exposing `close`, `reconnect` and the current `status`.\n */\nexport function createEventStream<T = unknown>(\n    url: string,\n    options: CreateEventStreamOptions<T> = {},\n): EventStreamController {\n    const {\n        withCredentials = false,\n        namedEvents = [],\n        heartbeatEvents = [\"ping\"],\n        maxRetries = 10,\n        initialBackoff = 1000,\n        maxBackoff = 30000,\n        parser,\n        onOpen,\n        onMessage,\n        onError,\n        onParseError,\n        schema,\n        onValidationError,\n        onStatusChange,\n    } = options;\n\n    let source: EventSource | null = null;\n    let retryTimer: ReturnType<typeof setTimeout> | null = null;\n    let retries = 0;\n    let status: EventStreamStatus = \"idle\";\n    let closed = false;\n\n    function setStatus(next: EventStreamStatus): void {\n        if (status === next) return;\n        status = next;\n        onStatusChange?.(next);\n    }\n\n    function emit(eventName: string, event: MessageEvent): void {\n        if (heartbeatEvents.includes(eventName)) return;\n        const decoded = decodeFrame<T>(\n            typeof event.data === \"string\" ? event.data : \"\",\n            \"createEventStream\",\n            { parser, onParseError, schema, onValidationError },\n        );\n        if (!decoded.delivered) return;\n        onMessage?.({\n            event: eventName,\n            data: decoded.data,\n            id: event.lastEventId || undefined,\n            raw: event,\n        });\n    }\n\n    function scheduleReconnect(): void {\n        if (closed) return;\n        if (retries >= maxRetries) {\n            setStatus(\"error\");\n            return;\n        }\n        const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n        retries += 1;\n        retryTimer = setTimeout(connect, delay);\n    }\n\n    function connect(): void {\n        if (closed) return;\n        if (source) source.close();\n        setStatus(\"connecting\");\n\n        const es = new EventSource(url, { withCredentials });\n        source = es;\n\n        es.onopen = () => {\n            retries = 0;\n            setStatus(\"open\");\n            onOpen?.();\n        };\n\n        es.onmessage = (event) => emit(\"message\", event);\n        for (const name of namedEvents) {\n            es.addEventListener(name, (event) => emit(name, event as MessageEvent));\n        }\n        for (const name of heartbeatEvents) {\n            es.addEventListener(name, () => {\n                /* heartbeat — keep socket alive */\n            });\n        }\n\n        es.onerror = (event) => {\n            onError?.(event);\n            es.close();\n            source = null;\n            setStatus(\"closed\");\n            scheduleReconnect();\n        };\n    }\n\n    function close(): void {\n        closed = true;\n        if (retryTimer) {\n            clearTimeout(retryTimer);\n            retryTimer = null;\n        }\n        retries = 0;\n        if (source) {\n            source.close();\n            source = null;\n        }\n        setStatus(\"closed\");\n    }\n\n    function reconnect(): void {\n        if (retryTimer) {\n            clearTimeout(retryTimer);\n            retryTimer = null;\n        }\n        retries = 0;\n        closed = false;\n        connect();\n    }\n\n    connect();\n\n    return {\n        close,\n        reconnect,\n        get status() {\n            return status;\n        },\n    };\n}\n"],"mappings":"2CAkGA,SAAgB,EACZ,EACA,EAAuC,CAAC,EACnB,CACrB,GAAM,CACF,kBAAkB,GAClB,cAAc,CAAC,EACf,kBAAkB,CAAC,MAAM,EACzB,aAAa,GACb,iBAAiB,IACjB,aAAa,IACb,SACA,SACA,YACA,UACA,eACA,SACA,oBACA,kBACA,EAEA,EAA6B,KAC7B,EAAmD,KACnD,EAAU,EACV,EAA4B,OAC5B,EAAS,GAEb,SAAS,EAAU,EAA+B,CAC1C,IAAW,IACf,EAAS,EACT,IAAiB,CAAI,EACzB,CAEA,SAAS,EAAK,EAAmB,EAA2B,CACxD,GAAI,EAAgB,SAAS,CAAS,EAAG,OACzC,IAAM,EAAU,EAAA,YACZ,OAAO,EAAM,MAAS,SAAW,EAAM,KAAO,GAC9C,oBACA,CAAE,SAAQ,eAAc,SAAQ,mBAAkB,CACtD,EACK,EAAQ,WACb,IAAY,CACR,MAAO,EACP,KAAM,EAAQ,KACd,GAAI,EAAM,aAAe,IAAA,GACzB,IAAK,CACT,CAAC,CACL,CAEA,SAAS,GAA0B,CAC/B,GAAI,EAAQ,OACZ,GAAI,GAAW,EAAY,CACvB,EAAU,OAAO,EACjB,MACJ,CACA,IAAM,EAAQ,KAAK,IAAI,EAAiB,GAAK,EAAS,CAAU,EAChE,GAAW,EACX,EAAa,WAAW,EAAS,CAAK,CAC1C,CAEA,SAAS,GAAgB,CACrB,GAAI,EAAQ,OACR,GAAQ,EAAO,MAAM,EACzB,EAAU,YAAY,EAEtB,IAAM,EAAK,IAAI,YAAY,EAAK,CAAE,iBAAgB,CAAC,EACnD,EAAS,EAET,EAAG,WAAe,CACd,EAAU,EACV,EAAU,MAAM,EAChB,IAAS,CACb,EAEA,EAAG,UAAa,GAAU,EAAK,UAAW,CAAK,EAC/C,IAAK,IAAM,KAAQ,EACf,EAAG,iBAAiB,EAAO,GAAU,EAAK,EAAM,CAAqB,CAAC,EAE1E,IAAK,IAAM,KAAQ,EACf,EAAG,iBAAiB,MAAY,CAEhC,CAAC,EAGL,EAAG,QAAW,GAAU,CACpB,IAAU,CAAK,EACf,EAAG,MAAM,EACT,EAAS,KACT,EAAU,QAAQ,EAClB,EAAkB,CACtB,CACJ,CAEA,SAAS,GAAc,CACnB,EAAS,GACT,AAEI,KADA,aAAa,CAAU,EACV,MAEjB,EAAU,EACV,AAEI,KADA,EAAO,MAAM,EACJ,MAEb,EAAU,QAAQ,CACtB,CAEA,SAAS,GAAkB,CACvB,AAEI,KADA,aAAa,CAAU,EACV,MAEjB,EAAU,EACV,EAAS,GACT,EAAQ,CACZ,CAIA,OAFA,EAAQ,EAED,CACH,QACA,YACA,IAAI,QAAS,CACT,OAAO,CACX,CACJ,CACJ"}