@molecule/app-ai-chat-http

Provider bond · ai-chat · App (browser) · v1.4.0 · Apache-2.0

HTTP/SSE AI chat provider implementation

npm install @molecule/app-ai-chat-http

npm · Source on GitHub · Implements @molecule/app-ai-chat

How it works

@molecule/app-ai-chat-http is a provider bond on the app (browser) side: it implements the ai-chat core interface (@molecule/app-ai-chat) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

// npm install @molecule/app-ai-chat-http --workspace=app
import { setProvider, requireProvider } from '@molecule/app-ai-chat'
import { createProvider } from '@molecule/app-ai-chat-http'

setProvider(createProvider()) // same-origin base URL by default

const chat = requireProvider()
const config = { endpoint: '/api/ai/chat' } // YOUR backend route (SSE) — the AI key lives there
await chat.sendMessage('Summarize my open orders', config, (event) => {
  if (event.type === 'text') appendTokens(event.content) // streamed chunks
  if (event.type === 'error') showError(event.message)
})
const history = await chat.loadHistory(config)

Works with: @molecule/app-i18n

Reference

HTTP/SSE AI chat provider for molecule.dev.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

// npm install @molecule/app-ai-chat-http --workspace=app
import { setProvider, requireProvider } from '@molecule/app-ai-chat'
import { createProvider } from '@molecule/app-ai-chat-http'

setProvider(createProvider()) // same-origin base URL by default

const chat = requireProvider()
const config = { endpoint: '/api/ai/chat' } // YOUR backend route (SSE) — the AI key lives there
await chat.sendMessage('Summarize my open orders', config, (event) => {
  if (event.type === 'text') appendTokens(event.content) // streamed chunks
  if (event.type === 'error') showError(event.message)
})
const history = await chat.loadHistory(config)

Type

provider

Installation

npm install @molecule/app-ai-chat-http @molecule/app-ai-chat @molecule/app-i18n

API

Interfaces

HttpChatConfig

Configuration for http chat.

interface HttpChatConfig {
  /** Base URL for API requests. Defaults to '' (same origin). */
  baseUrl?: string
  /** Custom headers to include in requests. */
  headers?: Record<string, string>
}

Classes

HttpChatProvider

HTTP/SSE-based implementation of ChatProvider. Sends messages via POST to a backend endpoint and reads SSE (Server-Sent Events) streams for real-time AI responses.

class HttpChatProvider implements ChatProvider {
  readonly name = 'http'
  private config: HttpChatConfig
  private abortController: AbortController | null = null

  /**
   * Whether the server reported an active stream for the conversation
   * on the last `loadHistory` call. Used by the client to decide whether
   * to poll for updates after a page refresh.
   */
  isServerStreaming = false

  /**
   * Extra top-level fields the server returned on the last `loadHistory` GET,
   * beyond `messages` and `streaming` — carried through verbatim so this shared
   * bond stays agnostic to any consuming app's vocabulary (Anti-Pattern 14).
   *
   * The bond names NO app-specific concept here. A consuming app reads whatever
   * it persisted from this bag, e.g. molecule.dev restores its agent mode via
   * `provider.lastMeta?.mode` (the value its server puts on the GET response).
   */
  lastMeta: Record<string, unknown> = {}

  /** Maximum number of retries for HTTP 409 (server-side lock not yet released). */
  private static readonly CONFLICT_MAX_RETRIES = 10

  /** Base delay in ms between 409 retries (doubled each attempt). */
  private static readonly CONFLICT_BASE_DELAY = 500

  /**
   * The last SSE `id:` seen on this provider's stream. Sent as `Last-Event-ID`
   * on a resume so a server that supports resumption can continue from the
   * event after it instead of replaying the whole turn.
   */
  private lastEventId: string | null = null

  /**
   * Statuses treated as transient while RE-ATTACHING to a running turn. A
   * fresh send is never retried: the POST may create a turn, and repeating it
   * without a server-side idempotency key could start two.
   */
  private static readonly RESUME_RETRY_STATUSES = [502, 503, 504]

  /** Maximum retries when re-attaching during a drain. */
  private static readonly RESUME_MAX_RETRIES = 2

  constructor(config: HttpChatConfig = {}) {
    this.config = config
  }

  /**
   * The last SSE event id seen on this provider's stream, or `null`.
   *
   * A caller that reconnects after a drop passes it as `lastEventId` on the
   * resume so the server can continue from the next event.
   *
   * @returns The last event id.
   */
  getLastEventId(): string | null {
    return this.lastEventId
  }

  /**
   * Sends a chat message to the backend and streams back SSE events.
   * @param message - The user message text.
   * @param config - Chat configuration including the API endpoint and model.
   * @param onEvent - Callback invoked for each SSE event (content chunks, errors, done signals).
   * @param attachments - Optional file attachments (images, PDFs, audio, video) as base64.
   */
  async sendMessage(
    message: string,
    config: ChatConfig,
    onEvent: ChatEventHandler,
    attachments?: ChatAttachment[],
  ): Promise<void> {
    // Abort any previous in-flight request before starting a new one.
    // Prevents stale streaming events from corrupting state when called concurrently
    // (e.g., React StrictMode double-mount or rapid message sends).
    this.abortController?.abort()
    const controller = new AbortController()
    this.abortController = controller
    const { signal } = controller
    const url = `${this.config.baseUrl ?? ''}${config.endpoint}`

    // Single try-catch wraps everything including the initial fetch so that
    // AbortErrors thrown at any stage (fetch, response.text(), reader.read())
    // are all handled uniformly rather than propagating to the caller.
    try {
      // Retry loop for HTTP 409 — the server holds a per-conversation lock
      // that may not be released yet after a page refresh or rapid resend.
      let response: Response | undefined
      // Body of a 409, read exactly once per attempt (a body can be consumed only
      // once, so the error path below reuses this instead of reading it again).
      let conflictText: string | undefined
      for (let attempt = 0; attempt <= HttpChatProvider.CONFLICT_MAX_RETRIES; attempt++) {
        response = await HttpChatProvider.fetchResumeAware(
          url,
          {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              // A resume re-POSTs a turn that may still be RUNNING server-side —
              // asking for the event stream lets the server attach this request to
              // that live turn (the `attached` frame) instead of 409ing. New sends
              // must NOT ask: attaching never reads the body, so a new message has
              // to keep the 409-retry path below to run after the current turn.
              ...(config.resume ? { Accept: 'text/event-stream' } : {}),
              // Continue from the last event this client received. A server that
              // honors Last-Event-ID replays only what was missed — the standard
              // SSE resume contract during a drain/restart.
              ...((config.resume ? (config.lastEventId ?? this.lastEventId) : null)
                ? { 'Last-Event-ID': (config.lastEventId ?? this.lastEventId) as string }
                : {}),
              ...this.config.headers,
            },
            body: JSON.stringify({
              message,
              model: config.model,
              ...(attachments?.length ? { attachments } : {}),
              ...(config.resume ? { resume: true } : {}),
              // Per-send intent flags — the server tags the persisted user message
              // so a suppressed driver message is hidden on read and an auto-sent
              // message keeps its distinct style. Without forwarding these, a
              // suppressed message would persist as an ordinary user message and
              // reappear on refresh.
              ...(config.suppressUserMessage ? { suppressUserMessage: true } : {}),
              ...(config.automatic ? { automatic: true } : {}),
              ...(config.userInitiated ? { userInitiated: true } : {}),
              ...(config.viaDictation ? { viaDictation: true } : {}),
            }),
            signal,
          },
          signal,
          Boolean(config.resume),
        )

        if (response.status !== 409) break

        // Read the 409 body once. The lock-held answer carries no `errorKey`; a
        // 409 with one (for example `project.deleting`) is a final answer from the
        // server, shown at once instead of retried.
        conflictText = await response.text().catch(() => undefined)
        let hasErrorKey = false
        try {
          const parsed = conflictText ? JSON.parse(conflictText) : undefined
          hasErrorKey = typeof parsed?.errorKey === 'string' && parsed.errorKey.length > 0
        } catch (_error) {
          // Not JSON — treat as the legacy lock-held shape.
        }
        if (hasErrorKey) break

        // 409 Conflict — server lock still held. Wait with exponential backoff.
        if (attempt < HttpChatProvider.CONFLICT_MAX_RETRIES) {
          const delay = HttpChatProvider.CONFLICT_BASE_DELAY * 2 ** attempt
          await new Promise<void>((resolve) => {
            const timer = setTimeout(resolve, delay)
            // If aborted while waiting, resolve immediately so the AbortError
            // is thrown on the next fetch attempt rather than hanging.
            signal.addEventListener(
              'abort',
              () => {
                clearTimeout(timer)
                resolve()
              },
              { once: true },
            )
          })
          // If aborted during the delay, exit cleanly instead of retrying
          if (signal.aborted) return
        }
      }

      if (!response!.ok) {
        const text =
          (response!.status === 409
            ? conflictText
            : await response!.text().catch(() => undefined)) ??
          t('chat.error.unknownError', undefined, { defaultValue: 'Unknown error' })
        // Parse structured limit error metadata from JSON responses. `limitType`
// … truncated — the full declaration is in node_modules/@molecule/app-ai-chat-http/dist/provider.d.ts

Functions

createProvider(config)

Creates an HttpChatProvider instance with optional base URL and custom headers.

function createProvider(config?: HttpChatConfig): HttpChatProvider
  • config — HTTP-specific chat configuration (base URL, headers).

Returns: An HttpChatProvider that communicates with the backend via HTTP/SSE.

Constants

provider

Pre-instantiated provider singleton.

const provider: HttpChatProvider

Core Interface

Implements @molecule/app-ai-chat interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setProvider } from '@molecule/app-ai-chat'
import { provider } from '@molecule/app-ai-chat-http'

export function setupAiChatHttp(): void {
  setProvider(provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/app-ai-chat ^1.0.1
  • @molecule/app-i18n ^1.0.1

Runtime Dependencies

  • @molecule/app-ai-chat
  • @molecule/app-i18n

POSTs each message to YOUR backend chat endpoint (config.endpoint, a RELATIVE path like /api/ai/chat on the app's baseUrl) and reads the reply as an SSE stream — it does NOT talk to an AI provider directly and holds NO AI key. Point endpoint at your own API, where the provider key + @molecule/api-ai live; auth rides the session via the HTTP client (cookie/bearer), so never attach a provider key or an absolute AI-provider URL here. See @molecule/app-ai-chat for the safe-render rules.

Server contract (all on the ONE config.endpoint route): POST { message, model?, attachments?, resume?, suppressUserMessage?, automatic?, userInitiated? } → SSE data: <ChatStreamEvent JSON> lines; GET → { messages, streaming? } plus any app-specific top-level fields (this bond only reads messages + streaming; every other field rides through in provider.lastMeta — e.g. an app that persists an agent mode reads it back as provider.lastMeta?.mode); DELETE → clear history. Two conventions beyond that route: a POST answered 409 means "conversation locked, still streaming" — this bond retries automatically (up to 10 tries, 500 ms doubling backoff) so return 409 rather than erroring; and Stop/unload aborts POST to <endpoint>-abort (suffix on the pathname, query kept) with { conversationId?, userInitiated? } via sendBeacon.

The 409 and the resume flag work together: a resume send (resume: true, empty body) carries Accept: text/event-stream, so a server whose turn is STILL RUNNING can attach the request to that live turn as a stream subscriber (an attached frame, then the turn's remaining frames) instead of 409ing. NEW sends deliberately do NOT send that Accept header — the attach path never reads the request body, so a new message must keep the 409-retry path to run after the current turn; serving it the attach response would silently drop it. abortOnServer(), isServerStreaming, and lastMeta are extensions on HttpChatProvider beyond the core ChatProvider type. loadHistory() returns [] on HTTP errors but REJECTS on network failure; wrap it.

A dropped stream is resumable, and a rolling server restart is survivable. The client remembers the newest SSE id: line it saw and sends it as Last-Event-ID on the next resume POST; a server that supports resumption continues from the event AFTER it instead of replaying the turn (one that does not simply ignores the header — the app still converges from persisted history). A resume POST answered "502/503/504" (an instance restarting during a drain) is retried twice with jittered backoff, honoring Retry-After; a FRESH send is never retried, because it may create a turn and there is no idempotency key to collapse a duplicate. A transport drop mid-stream is delivered as { type: 'error', transport: true } so the caller resumes rather than showing a terminal error.

A non-ok response becomes an error event, and a JSON body's error, limitType, requiresSignup, billingAction and upgradeTier are copied onto it — so a limit refusal (402/429/403) reaches the UI with both the rule that fired (limitType) and the remedy the backend resolved (billingAction, e.g. add_funds vs add_payment_method vs upgrade, with upgradeTier: null meaning there is no higher plan). Send those fields from your API and the client's call-to-action is yours to decide; omit them and the client can only guess.

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Sending a message renders it in the thread and a streamed assistant reply appears incrementally (visible tokens while generating — not a frozen UI that dumps one blob).
  • The reply flows through the app's OWN backend: the browser's network log shows no direct calls to an AI provider and no provider key anywhere client-side.
  • Model output renders as sanitized markdown — a reply containing HTML or <script> displays as text and never executes.
  • If the app claims conversation persistence, reloading restores the thread history.
  • A backend failure (endpoint down, missing API key) surfaces a readable, actionable error — not an infinite spinner.
  • Sending again while a reply streams is handled sanely (queued, blocked, or parallel — never corrupted/interleaved text).