npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@composable-svelte/chat

v0.4.1

Published

Streaming chat component with collaborative features for Composable Svelte - Built for LLM interactions with transport-agnostic design

Readme

@composable-svelte/chat

Streaming chat components with collaborative features for Composable Svelte. Built for LLM interactions with a transport-agnostic design.

Features

  • Transport-agnostic - Bring your own streaming backend (WebSocket, SSE, REST, etc.)
  • Three UI tiers - Minimal, Standard, and Full chat variants for different complexity needs
  • Markdown rendering - via marked, with optional Prism syntax highlighting
  • File attachments - Attach images, documents, and media to messages
  • Message reactions - Emoji reactions on messages
  • Message editing - Edit and delete sent messages
  • Collaborative - Real-time presence, typing indicators, and live cursors
  • Bring your own socket - You supply connectWebSocket; the store owns its teardown, including across reconnects
  • State-driven - Full Composable Architecture integration with testable reducers
  • Customizable - Custom sender names, avatars, labels, and message rendering

Installation

pnpm add @composable-svelte/chat

Peer dependencies:

pnpm add @composable-svelte/core svelte

Optional peer dependencies (for enhanced features):

pnpm add @composable-svelte/code   # Code block syntax highlighting
pnpm add @composable-svelte/media  # YouTube/Vimeo/Twitch embeds detected in markdown
pnpm add prismjs                   # Prism.js syntax highlighting
pnpm add pdfjs-dist                # PDF attachment previews

Quick Start

<script lang="ts">
  import { createStore } from '@composable-svelte/core';
  import {
    FullStreamingChat,
    streamingChatReducer,
    createInitialStreamingChatState
  } from '@composable-svelte/chat';

  const store = createStore({
    initialState: createInitialStreamingChatState(),
    reducer: streamingChatReducer,
    dependencies: {
      streamMessage: (message, onChunk, onComplete, onError, attachments) => {
        const controller = new AbortController();

        (async () => {
          try {
            const response = await fetch('/api/chat', {
              method: 'POST',
              headers: { 'content-type': 'application/json' },
              body: JSON.stringify({ message, attachments }),
              signal: controller.signal
            });
            if (!response.ok || !response.body) {
              throw new Error(`Chat request failed (${response.status})`);
            }
            // This example's endpoint streams plain UTF-8 text, not SSE frames.
            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            try {
              while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                onChunk(decoder.decode(value, { stream: true }));
              }
              const tail = decoder.decode();
              if (tail) onChunk(tail);
            } finally {
              reader.releaseLock();
            }
            onComplete();
          } catch (e) {
            if (!controller.signal.aborted) onError(String(e));
          }
        })();

        return controller; // Returned so `stopGeneration` can abort
      }
    }
  });
</script>

<FullStreamingChat {store} />

The application supplies POST /api/chat: this example sends JSON and expects a plain UTF-8 streaming response. For SSE or another protocol, decode its frames in streamMessage before calling onChunk. Non-success HTTP responses become errors.

Chat Variants

MinimalStreamingChat

Bare-bones chat with just messages and input. Best for embedding in tight spaces.

<MinimalStreamingChat {store} />

StandardStreamingChat

Adds message metadata (timestamps, sender info), typing indicators, and scroll management.

<StandardStreamingChat {store} userLabel="You" assistantLabel="AI" />

FullStreamingChat

Complete chat experience with attachments, reactions, editing, and all features enabled.

Attachments and reactions are not feature flags — FullStreamingChat is the variant that has them.

<FullStreamingChat
  {store}
  userLabel="You"
  assistantLabel="Assistant"
  maxFileSizeMB={10}
  acceptedFileTypes={['image/*', 'application/pdf']}
/>

SimpleChatMessage / ChatMessage

Individual message components for custom layouts. The role comes from the message, not from a prop:

<ChatMessage message={msg} userLabel="You" assistantLabel="Assistant" />

ChatMessage renders markdown, attachments, reactions, image galleries and video embeds. SimpleChatMessage renders markdown for assistant messages too, with copy buttons on code blocks — it leaves out the attachment, reaction and embed machinery, not the formatting.

Collaborative Features

Collaboration is a second store. collaborativeReducer has its own state — CollaborativeStreamingChatState does not extend StreamingChatState, and the chat store has no users field. Build both and let each do its own job.

<script lang="ts">
  import { onMount } from 'svelte';
  import { createStore } from '@composable-svelte/core';
  import {
    collaborativeReducer,
    createInitialCollaborativeState,
    PresenceAvatarStack,
    TypingIndicator,
    getActiveUsers,
    getTypingUsers
  } from '@composable-svelte/chat';

  const currentUserId = 'me';

  const collabStore = createStore({
    initialState: createInitialCollaborativeState(),
    reducer: collaborativeReducer,
    dependencies: {
      connectWebSocket: (conversationId, userId, onMessage, onConnectionChange) => {
        // See "Supplying the connection" below. A cleanup is required.
        const socket = new WebSocket(`wss://chat.example.com/${conversationId}`);
        socket.onmessage = (e) => onMessage(JSON.parse(e.data));
        socket.onopen = () => onConnectionChange({ status: 'connected', connectedAt: Date.now() });
        return () => socket.close();
      },
      sendWebSocketMessage: async (message) => {
        /* send it */
      }
    }
  });

  // Nothing works until this: it is what opens the socket and what tells the
  // store who you are. Presence, typing and cursors all no-op without it.
  onMount(() => {
    collabStore.dispatch({
      type: 'connectToConversation',
      conversationId: 'room-1',
      userId: currentUserId
    });
    return () => collabStore.destroy();
  });

  // Users live in one flat Map — there is no `presence` sub-object. Both
  // selectors take your own id and leave you out, so nobody is shown their own
  // presence dot or told that they are typing.
  const online = $derived(getActiveUsers($collabStore.users, currentUserId));
  const typing = $derived(getTypingUsers($collabStore.users, currentUserId, 'message'));
</script>

<PresenceAvatarStack users={online} />
<TypingIndicator users={typing} />

Live cursors

CursorOverlay floats other users' carets over your composer. It measures a single line, so give it an <input> rather than a wrapping <textarea>, and it must not take pointer events — which is why each marker's name flag is always visible rather than shown on hover.

Continuing from the collaborative store above:

<script lang="ts">
  import { CursorOverlay, getCursorPositions, useCursorTracking } from '@composable-svelte/chat';

  let inputElement = $state<HTMLInputElement | undefined>(undefined);
  let draft = $state('');

  // Returns its own teardown, which is what an effect wants returned.
  $effect(() => {
    if (!inputElement) return;
    return useCursorTracking(collabStore, inputElement);
  });
</script>

<input type="text" bind:this={inputElement} bind:value={draft} />
{#if inputElement}
  <CursorOverlay {inputElement} text={draft}
    cursors={getCursorPositions($collabStore.users, currentUserId)} />
{/if}

examples/styleguide's Collaborative Chat page is the full working version — same wiring, plus seeded users and a mock socket so it runs without a server.

Collaborative Hooks

Each takes the collaborative store, and each only transmits once connectToConversation has run. Three return a teardown function; useTypingEmitter returns an object with one on it.

| Hook | Signature | Purpose | |------|-----------|---------| | usePresenceTracking | (store) | Watches activity and broadcasts active / idle / away. It never reports offline — that is a disconnect, not an idle timer. | | useTypingEmitter | (store, target, messageId?) | Returns { start, stop, update, cleanup } rather than a bare teardown | | useCursorTracking | (store, element, throttleMs?) | Shares the caret position in element | | useHeartbeat | (store, intervalMs?) | Periodic keep-alive so a server that drops idle connections does not drop a quiet one |

Supplying the connection

There is no WebSocketManager. The socket is yours: pass a connectWebSocket dependency that opens it and returns a cleanup function. The store owns that cleanup — disconnectFromConversation runs it, re-connecting runs it before opening the next one, and destroying the store runs it too.

dependencies: {
  connectWebSocket: (conversationId, userId, onMessage, onConnectionChange) => {
    const socket = new WebSocket(`wss://chat.example.com/${conversationId}`);
    socket.onmessage = (e) => onMessage(JSON.parse(e.data));
    socket.onopen = () => onConnectionChange({ status: 'connected', connectedAt: Date.now() });
    return () => socket.close();
  }
}

The return type is () => void, not optional: a cleanup is required on every path, including the failure path, because the store is what runs it. Reports made from inside a cleanup are ignored, so a socket's onclose cannot overwrite the state of the connection that replaced it.

State Management

State Shape

interface StreamingChatState {
  messages: Message[];
  currentStreaming: { content: string; abortController?: AbortController } | null;
  isWaitingForResponse: boolean;
  error: string | null;
  editingMessage: { id: string; content: string } | null;
  pendingAttachments: MessageAttachment[];
  /** The message just sent — the one thing on screen that is new rather than
      merely present, so a restored session does not animate every message in. */
  lastAppendedId: string | null;
  attachmentPreview: AttachmentPreviewState;
  /** One picker for the whole conversation; `content` is the message id. */
  reactionPicker: PresentationState<string>;
}

createInitialStreamingChatState() returns all of it. The last three fields are lifecycles rather than data — see Animation below.

Key Actions

| Action | Description | |--------|-------------| | sendMessage | Send a user message (message, plus optional attachments) and start streaming | | stopGeneration | Abort streaming in progress | | addAttachment / removeAttachment / clearAttachments | Manage pending attachments; removal is by attachmentId | | startEditingMessage / updateEditingContent / submitEditedMessage / cancelEditing | The edit cycle — there is no single editMessage | | deleteMessage | Remove a message. Deleting a user message also drops everything after it | | addReaction / removeReaction | Toggle an emoji. addReaction is idempotent and removeReaction refuses a reaction that is not yours | | reactionPickerOpened / reactionPickerDismissed | Open and close the picker | | attachmentPreviewOpened / attachmentPreviewDismissed / attachmentPreviewRemoveRequested | The preview modal | | restoreMessages | Restore a previous session | | clearMessages / clearError | Reset |

Dependencies

interface StreamingChatDependencies {
  streamMessage: (
    message: string,
    onChunk: (chunk: string) => void,
    onComplete: () => void,
    onError: (error: string) => void,
    /** Attachments, with `uploadFile`'s URLs already resolved. Trailing and
        optional, so an existing four-parameter implementation still fits. */
    attachments?: MessageAttachment[]
  ) => AbortController | void;

  /** Both default: `crypto.randomUUID()` and `Date.now()`. */
  generateId?: () => string;
  getTimestamp?: () => number;

  /**
   * Upload a file and return its URL. Called on **send**, not on attach, so
   * nothing is uploaded until the user commits. Without it, attachments keep
   * the blob URLs they were created with, which do not survive a reload.
   */
  uploadFile?: (
    file: File,
    onProgress?: (loaded: number, total: number) => void
  ) => Promise<string>;
}

An upload that fails does not block the send: the attachment keeps its local URL, uploadStatus becomes 'error', and the message goes out — the sender still sees their file, and uploadError says why nobody else will.

Reactions

interface MessageReaction {
  emoji: string;
  count: number;
  /** Whether the current user is one of them. */
  reactedByMe?: boolean;
}

One bit rather than the list of who reacted: a popular message would otherwise ship thousands of user ids to render "👍 12". It is also why nothing here needs a current-user identity — the flag is the answer to "did I react?".

Animation

This package runs no CSS lifecycle animations. Anything that appears, disappears, expands or collapses uses a Motion One helper from @composable-svelte/core/animation, so the store can sequence on it and a test can observe it. The two overlays — the attachment preview and the reaction picker — carry a PresentationState and animate both halves; message entry and the image/video fades are fire-and-forget.

guides/ANIMATION-GUIDELINES.md is the rule, and packages/core/tests/repo/animation-policy.test.ts enforces it.

Testing

import { describe, it, expect } from 'vitest';
import { createTestStore } from '@composable-svelte/core/test';
import { streamingChatReducer, createInitialStreamingChatState } from '@composable-svelte/chat';

describe('StreamingChat', () => {
  it('sends a message and receives the reply', async () => {
    let chunk!: (text: string) => void;
    let complete!: () => void;

    const store = createTestStore({
      initialState: createInitialStreamingChatState(),
      reducer: streamingChatReducer,
      dependencies: {
        // Hand the callbacks out rather than calling them here: `send` starts
        // the effect *before* running its assertion, so a fake that streams
        // synchronously means the whole reply lands first and every line of
        // that assertion is wrong.
        streamMessage: (_message, onChunk, onComplete) => {
          chunk = onChunk;
          complete = onComplete;
        },
        generateId: () => 'test-id',
        getTimestamp: () => 1000
      }
    });

    await store.send({ type: 'sendMessage', message: 'Hello' }, (state) => {
      expect(state.messages).toHaveLength(1);
      expect(state.isWaitingForResponse).toBe(true);
    });

    chunk('Hi');
    await store.receive({ type: 'chunkReceived', chunk: 'Hi' });

    complete();
    await store.receive({ type: 'streamComplete' });
    await store.finish();
  });
});

The full version of this is packages/chat/tests/teststore-example.test.ts, which runs on every build.

createMockStreamingChat() supplies streamMessage, generateId and getTimestamp, faking a streamed reply — for demos and for tests that do not care about the transport. It supplies no uploadFile, so attachments keep their local URLs under it.

It fakes a slow reply — two to three seconds. TestStore.receive times out after one second and finish() refuses any unasserted action, so a TestStore driven by this mock needs its own streamMessage — like the one above — rather than this.

API Reference

Components

| Component | Description | |-----------|-------------| | MinimalStreamingChat | Minimal chat UI (messages + input) | | StandardStreamingChat | Standard chat with metadata and typing | | FullStreamingChat | Full-featured chat with attachments and reactions | | SimpleChatMessage | Single message display (simple) | | ChatMessage | Single message display (full features) | | PresenceBadge | Online status indicator | | PresenceAvatarStack | Stacked avatar display for online users | | PresenceList | List of users with presence status | | TypingIndicator | Animated typing dots | | TypingUsersList | List of currently typing users | | CursorMarker | Remote cursor position display | | CursorOverlay | Overlay layer for all remote cursors |

Functions

| Function | Description | |----------|-------------| | streamingChatReducer | Main chat reducer | | createInitialStreamingChatState() | Create initial state with defaults | | createMockStreamingChat() | Fakes a streamed reply (streamMessage, generateId, getTimestamp) | | collaborativeReducer | Reducer for collaborative features | | createInitialCollaborativeState() | Initial state for the above | | getActiveUsers(users, currentUserId) | Everyone present but you, minus the offline | | getTypingUsers(users, currentUserId, target?) | Everyone typing but you | | getCursorPositions(users, currentUserId) | Every caret but yours, for CursorOverlay | | formatTypingIndicator(users) | "Ada is typing", "3 people are typing" — no ellipsis; TypingIndicator draws animated dots beside it | | generateRandomUserColor(userId) | Stable colour from an id |

Markdown helpers — renderMarkdown, extractVideosFromMarkdown, extractImagesFromMarkdown — are on the @composable-svelte/chat/streaming-chat/markdown subpath rather than the root barrel. Video extraction returns [] until @composable-svelte/media has loaded, since that peer is optional and imported dynamically.

All three selectors take the current user's id and exclude them: nobody is shown their own presence dot, told that they are typing, or given their own caret.

Dependencies

  • Runtime: marked (markdown parsing), isomorphic-dompurify (sanitising it)
  • Peer: @composable-svelte/core, svelte
  • Optional: @composable-svelte/code, @composable-svelte/media, prismjs, pdfjs-dist