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

simple-support-chat

v0.4.1

Published

Embeddable chat widget SDK that routes customer messages to Slack threads. Open-source Crisp alternative.

Downloads

837

Readme

simple-support-chat

Embeddable chat widget SDK that routes customer messages to Slack threads. Open-source alternative to Crisp, Intercom, and Drift.

  • Zero cost, self-hosted
  • Slack-native: messages appear as threads in your workspace
  • Bidirectional: team replies in Slack threads show in the chat widget
  • Real-time delivery via SSE, with automatic polling fallback
  • Slack emoji shortcodes (:heart:) auto-converted to Unicode (❤️)
  • Works with any React app (Next.js, Remix, Vite, etc.)
  • Anonymous and authenticated user support
  • No external CSS or Tailwind dependency

Installation

pnpm add simple-support-chat

React 18+ is a peer dependency for the client widget. The server handler works without React.

Slack App Setup

Follow these steps to create a Slack App for your workspace. Each open-source user creates their own app -- there is no shared OAuth install flow.

Step 1: Create the App

  1. Go to api.slack.com/apps and click Create New App
  2. Choose From scratch
  3. Name it (e.g., "Support Chat") and select your workspace
  4. Click Create App

Step 2: Configure Bot Token Scopes

Navigate to OAuth & Permissions in the sidebar and scroll to Bot Token Scopes. Add these three scopes:

| Scope | Purpose | |-------|---------| | chat:write | Post messages to channels | | chat:write.customize | Customize the bot's display name and icon per message | | users:read | Read basic user profile information |

These are the minimum required scopes. You do not need any User Token Scopes.

Step 3: Install to Workspace

  1. Scroll to the top of OAuth & Permissions and click Install to Workspace
  2. Review the permissions and click Allow
  3. Copy the Bot User OAuth Token -- it starts with xoxb-

Step 4: Get Your Channel ID

You need the channel ID (not the channel name) for configuration:

  1. Open Slack and navigate to the channel you want to use for support
  2. Click the channel name at the top to open channel details
  3. Scroll to the bottom of the details panel -- the Channel ID is displayed there (e.g., C0123ABCDEF)

Alternatively, right-click the channel name, click Copy link, and extract the ID from the URL.

Step 5: Invite the Bot

The bot must be a member of the channel to post messages. In Slack, type:

/invite @YourBotName

Step 6: Set Environment Variables

Add these to your .env file:

SLACK_BOT_TOKEN=xoxb-your-token-here
SLACK_CHANNEL_ID=C0123ABCDEF
SLACK_SIGNING_SECRET=your-signing-secret-here  # Required for receiving replies (see "Receiving Replies" section)

The signing secret is found under Basic Information > App Credentials in your Slack app settings. It is only needed if you want bidirectional messaging (team replies appearing in the widget).

Validate Your Token

Use the built-in validateSlackToken utility to verify your token works before going live:

import { validateSlackToken } from "simple-support-chat/server";

const result = await validateSlackToken(process.env.SLACK_BOT_TOKEN!);
if (result.ok) {
  console.log(`Connected to workspace: ${result.team} as ${result.user}`);
} else {
  console.error(`Token validation failed: ${result.error}`);
}

Troubleshooting

| Problem | Solution | |---------|----------| | not_in_channel error | Invite the bot to the channel with /invite @BotName | | invalid_auth error | Check that your token starts with xoxb- and has not been revoked | | channel_not_found error | Verify you are using the Channel ID (e.g., C0123ABCDEF), not the channel name | | Messages not appearing | Confirm the bot has chat:write scope and the channel ID is correct | | Bot name/icon not customizing | Ensure chat:write.customize scope is added |

Quick Start

1. Server: Create API Route

// app/api/support/route.ts (Next.js App Router)
import { createSupportHandler } from "simple-support-chat/server";

export const POST = createSupportHandler({
  slackBotToken: process.env.SLACK_BOT_TOKEN!,
  slackChannel: process.env.SLACK_CHANNEL_ID!,
});

2. Client: Add the Chat Widget

import { ChatBubble } from "simple-support-chat";

export default function App() {
  return (
    <>
      {/* Your app content */}
      <ChatBubble apiUrl="/api/support" />
    </>
  );
}

That's it! Messages from your users will appear as threaded conversations in your Slack channel.

Receiving Replies

By default, simple-support-chat is one-way: users send messages that appear in Slack. To make it bidirectional -- so team replies in Slack appear in the user's chat widget -- you need three additional pieces:

  1. A webhook route that receives events from Slack when someone replies in a thread
  2. A replies endpoint that the client polls for new replies
  3. The repliesUrl prop on the client widget

Prerequisites

Add the signing secret to your environment variables:

SLACK_SIGNING_SECRET=your-signing-secret-here

Step 1: Enable Event Subscriptions

  1. Go to your app at api.slack.com/apps
  2. Navigate to Event Subscriptions in the sidebar
  3. Toggle Enable Events to On
  4. Set the Request URL to https://your-domain.com/api/support/webhook
    • Slack will send a verification challenge -- your webhook handler responds automatically
  5. Under Subscribe to bot events, add message.channels
  6. Click Save Changes

A pre-built manifest is included at slack-app-manifest.json. Replace YOUR_DOMAIN with your actual domain and use it when creating or reconfiguring your Slack app.

Local development: Slack cannot reach localhost. Use a tunnel like ngrok (ngrok http 3000) and set the tunnel URL as the Request URL. You can skip webhook setup entirely for local dev -- one-way messaging works without it.

Step 2: Create a Shared Store

The webhook handler, replies handler, and message handler must share the same store instance so that thread mappings and replies are consistent.

// lib/support-store.ts (or wherever you keep shared singletons)
import { InMemoryStore } from "simple-support-chat/server";

export const store = new InMemoryStore();

InMemoryStore works for local development. For production, implement the SupportChatStore interface with your database (see Custom Storage below).

Step 3: Set Up Routes

Next.js App Router

// app/api/support/route.ts
import { createSupportHandler } from "simple-support-chat/server";
import { store } from "@/lib/support-store";

export const POST = createSupportHandler({
  slackBotToken: process.env.SLACK_BOT_TOKEN!,
  slackChannel: process.env.SLACK_CHANNEL_ID!,
  store,
});
// app/api/support/webhook/route.ts
import { createWebhookHandler } from "simple-support-chat/server";
import { store } from "@/lib/support-store";

export const POST = createWebhookHandler({
  store,
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
// app/api/support/replies/route.ts
import { createRepliesHandler } from "simple-support-chat/server";
import { store } from "@/lib/support-store";

export const GET = createRepliesHandler({ store });

Express

import express from "express";
import {
  createExpressHandler,
  createExpressWebhookHandler,
  createExpressRepliesHandler,
  InMemoryStore,
} from "simple-support-chat/server";

const app = express();
const store = new InMemoryStore();

// Message handler (uses JSON body parser)
app.post(
  "/api/support",
  express.json(),
  createExpressHandler({
    slackBotToken: process.env.SLACK_BOT_TOKEN!,
    slackChannel: process.env.SLACK_CHANNEL_ID!,
    store,
  }),
);

// Webhook handler (uses raw body parser for signature verification)
app.post(
  "/api/support/webhook",
  express.raw({ type: "application/json" }),
  createExpressWebhookHandler({
    store,
    signingSecret: process.env.SLACK_SIGNING_SECRET!,
  }),
);

// Replies handler (GET endpoint for client polling)
app.get(
  "/api/support/replies",
  createExpressRepliesHandler({ store }),
);

app.listen(3000);

Important: The webhook route must use express.raw() (not express.json()) so that the raw request body is available for Slack signature verification.

Step 4: Add repliesUrl to the Client

Pass the repliesUrl prop to enable reply polling:

<ChatBubble
  apiUrl="/api/support"
  repliesUrl="/api/support/replies"
/>

Or with the modal:

<SupportChatModal
  apiUrl="/api/support"
  repliesUrl="/api/support/replies"
  isOpen={isOpen}
  onClose={close}
/>

When repliesUrl is set, the widget polls every 4 seconds while the chat panel is open. Replies appear as left-aligned gray bubbles. Polling stops when the chat is closed.

Custom Storage (SupportChatStore)

For production, implement the SupportChatStore interface to persist thread mappings and replies to your database. Here is the interface:

interface SupportChatStore {
  saveThread(sessionId: string, threadTs: string, metadata?: Record<string, unknown>): Promise<void>;
  getThreadBySession(sessionId: string): Promise<ThreadRecord | null>;
  getThreadByTs(threadTs: string): Promise<ThreadRecord | null>;
  saveReply(sessionId: string, reply: Reply): Promise<void>;
  getReplies(sessionId: string, since?: string): Promise<Reply[]>;
}

Example: Supabase Implementation

First, create the tables:

-- Supabase migration
create table support_chat_threads (
  session_id text primary key,
  thread_ts text not null unique,
  metadata jsonb default '{}',
  created_at timestamptz default now()
);

create table support_chat_replies (
  id text primary key,
  session_id text not null references support_chat_threads(session_id),
  text text not null,
  sender text not null,
  timestamp timestamptz not null,
  thread_ts text not null
);

create index idx_replies_session_timestamp
  on support_chat_replies(session_id, timestamp);

Then implement the store:

import type { SupportChatStore, ThreadRecord, Reply } from "simple-support-chat/server";
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
);

export class SupabaseChatStore implements SupportChatStore {
  async saveThread(sessionId: string, threadTs: string, metadata?: Record<string, unknown>) {
    await supabase.from("support_chat_threads").upsert({
      session_id: sessionId,
      thread_ts: threadTs,
      metadata: metadata ?? {},
    });
  }

  async getThreadBySession(sessionId: string): Promise<ThreadRecord | null> {
    const { data } = await supabase
      .from("support_chat_threads")
      .select("*")
      .eq("session_id", sessionId)
      .single();
    if (!data) return null;
    return { sessionId: data.session_id, threadTs: data.thread_ts, metadata: data.metadata };
  }

  async getThreadByTs(threadTs: string): Promise<ThreadRecord | null> {
    const { data } = await supabase
      .from("support_chat_threads")
      .select("*")
      .eq("thread_ts", threadTs)
      .single();
    if (!data) return null;
    return { sessionId: data.session_id, threadTs: data.thread_ts, metadata: data.metadata };
  }

  async saveReply(sessionId: string, reply: Reply) {
    await supabase.from("support_chat_replies").insert({
      id: reply.id,
      session_id: sessionId,
      text: reply.text,
      sender: reply.sender,
      timestamp: reply.timestamp,
      thread_ts: reply.threadTs,
    });
  }

  async getReplies(sessionId: string, since?: string): Promise<Reply[]> {
    let query = supabase
      .from("support_chat_replies")
      .select("*")
      .eq("session_id", sessionId)
      .order("timestamp", { ascending: true });

    if (since) {
      query = query.gt("timestamp", since);
    }

    const { data } = await query;
    return (data ?? []).map((r) => ({
      id: r.id,
      text: r.text,
      sender: r.sender,
      timestamp: r.timestamp,
      threadTs: r.thread_ts,
    }));
  }
}

Then pass the store to all handlers:

import { SupabaseChatStore } from "@/lib/supabase-chat-store";

const store = new SupabaseChatStore();
// Use this store instance in createSupportHandler, createWebhookHandler, and createRepliesHandler

Real-time Replies (SSE)

By default, bidirectional chat uses polling (the client fetches /api/support/replies every 4 seconds). Starting in v0.4.0, you can upgrade to Server-Sent Events (SSE) for instant delivery with zero polling overhead.

SSE is optional -- existing polling setups continue to work with zero changes. Add SSE alongside your existing routes for real-time delivery, with automatic fallback to polling if the SSE connection fails.

How It Works

  1. The emitter is an in-process pub/sub bridge: the webhook handler publishes replies into it, and the SSE handler streams them out to connected clients.
  2. The client opens a single persistent EventSource connection. Replies arrive instantly as SSE events.
  3. If the SSE connection fails (network error, unsupported environment), the client automatically falls back to polling via repliesUrl.

Server Setup

Create a shared emitter and pass it to the webhook handler and SSE handler:

// lib/support-chat.ts
import { InMemoryStore, createReplyEmitter } from "simple-support-chat/server";

export const store = new InMemoryStore();
export const emitter = createReplyEmitter();

Next.js App Router

Next.js App Router supports SSE via streaming Response objects in route handlers. Add a new SSE route alongside your existing routes:

// app/api/support/route.ts
import { createSupportHandler } from "simple-support-chat/server";
import { store, emitter } from "@/lib/support-chat";

export const POST = createSupportHandler({
  slackBotToken: process.env.SLACK_BOT_TOKEN!,
  slackChannel: process.env.SLACK_CHANNEL_ID!,
  store,
  emitter,
});
// app/api/support/webhook/route.ts
import { createWebhookHandler } from "simple-support-chat/server";
import { store, emitter } from "@/lib/support-chat";

export const POST = createWebhookHandler({
  store,
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
  emitter,
});
// app/api/support/sse/route.ts
import { createSSEHandler } from "simple-support-chat/server";
import { emitter } from "@/lib/support-chat";

export const GET = createSSEHandler({ emitter });
// app/api/support/replies/route.ts (keep for polling fallback)
import { createRepliesHandler } from "simple-support-chat/server";
import { store } from "@/lib/support-chat";

export const GET = createRepliesHandler({ store });

Important: The emitter instance must be shared across the support handler, webhook handler, and SSE handler within the same server process. Module-level singletons (as shown above) work well for this.

Express

import express from "express";
import {
  createExpressHandler,
  createExpressWebhookHandler,
  createExpressRepliesHandler,
  createExpressSSEHandler,
  createReplyEmitter,
  InMemoryStore,
} from "simple-support-chat/server";

const app = express();
const store = new InMemoryStore();
const emitter = createReplyEmitter();

// Message handler
app.post(
  "/api/support",
  express.json(),
  createExpressHandler({
    slackBotToken: process.env.SLACK_BOT_TOKEN!,
    slackChannel: process.env.SLACK_CHANNEL_ID!,
    store,
    emitter,
  }),
);

// Webhook handler (pass emitter to broadcast replies)
app.post(
  "/api/support/webhook",
  express.raw({ type: "application/json" }),
  createExpressWebhookHandler({
    store,
    signingSecret: process.env.SLACK_SIGNING_SECRET!,
    emitter,
  }),
);

// SSE endpoint (real-time reply stream)
app.get("/api/support/sse", createExpressSSEHandler({ emitter }));

// Replies endpoint (polling fallback)
app.get("/api/support/replies", createExpressRepliesHandler({ store }));

app.listen(3000);

Client Setup

Pass the sseUrl prop alongside repliesUrl:

<ChatBubble
  apiUrl="/api/support"
  repliesUrl="/api/support/replies"
  sseUrl="/api/support/sse"
/>

Or with the modal:

<SupportChatModal
  apiUrl="/api/support"
  repliesUrl="/api/support/replies"
  sseUrl="/api/support/sse"
  isOpen={isOpen}
  onClose={close}
/>

When sseUrl is provided, the widget opens a single persistent SSE connection for real-time delivery. If the SSE connection fails, it automatically falls back to polling via repliesUrl. If only repliesUrl is provided (no sseUrl), polling works exactly as before.

Fallback Behavior

The transport layer handles failures gracefully:

| Scenario | Behavior | |----------|----------| | sseUrl + repliesUrl provided, SSE works | Real-time delivery via SSE. No polling. | | sseUrl + repliesUrl provided, SSE fails | Automatic fallback to polling via repliesUrl. | | Only repliesUrl provided (no sseUrl) | Polling every 4 seconds. Same as v0.3.x. | | Neither provided | One-way only (no replies). |

On brief SSE disconnects, EventSource auto-reconnects and the client performs a one-time catch-up fetch via repliesUrl to recover any missed messages.

Serverless Compatibility

SSE requires a long-lived server process to maintain persistent connections. It is not compatible with serverless platforms that terminate connections after a short timeout:

| Platform | SSE Support | Recommendation | |----------|-------------|----------------| | Node.js server (Express, Fastify, Hono) | Yes | Use SSE | | Docker / Railway / Render / Fly.io | Yes | Use SSE | | Next.js on Vercel (Node.js runtime) | Yes | Use SSE | | Vercel Edge Functions | No | Use polling (repliesUrl only) | | Cloudflare Workers | No | Use polling (repliesUrl only) | | AWS Lambda | No | Use polling (repliesUrl only) |

When deploying to a serverless environment, omit the sseUrl prop and rely on repliesUrl for polling. The client handles this gracefully -- no code changes needed.

Note: SSE operates within a single server process. If you run multiple server instances behind a load balancer, each instance has its own emitter. In this scenario, use a sticky session or session-affinity configuration so that a client's SSE connection and its webhook handler route to the same instance.

Configuration

ChatBubble Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | apiUrl | string | (required) | URL of your support API endpoint | | repliesUrl | string | undefined | URL of the replies endpoint. Enables bidirectional chat (polling). | | sseUrl | string | undefined | URL of the SSE endpoint. Enables real-time delivery. Falls back to repliesUrl polling on failure. | | position | 'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' | 'bottom-right' | Bubble position | | color | string | '#2563eb' | Primary color (bubble, header, sent messages) | | title | string | 'Support' | Chat panel header title | | placeholder | string | 'Type a message...' | Input placeholder text | | show | boolean | true | Show/hide the floating bubble | | user | ChatUser | undefined | Authenticated user info |

Server Options

| Option | Type | Description | |--------|------|-------------| | slackBotToken | string | Slack Bot OAuth Token (xoxb-...) | | slackChannel | string | Slack channel ID | | botName | string | Custom bot display name | | botIcon | string | Bot icon emoji (e.g., :speech_balloon:) | | onMessage | (data) => void | Callback on each message | | store | SupportChatStore | Pluggable storage backend (defaults to InMemoryStore) | | emitter | ReplyEmitter | Optional reply emitter for SSE broadcasting (see Real-time Replies) |

Identity Integration

Pass the logged-in user's identity to the widget so Slack threads show who they are:

import { ChatBubble } from "simple-support-chat";
import { useAuth } from "./your-auth"; // your auth provider

export function SupportWidget() {
  const { user } = useAuth();

  return (
    <ChatBubble
      apiUrl="/api/support"
      user={user ? { id: user.id, name: user.name, email: user.email } : undefined}
    />
  );
}

When a user is identified, Slack threads are titled: Support: John Doe ([email protected]) When anonymous: Support: Anonymous (session abc12345)

Modal Mode

Hide the floating bubble and trigger the chat from a custom "Contact Us" element using the useSupportChat hook and SupportChatModal component:

import { SupportChatModal, useSupportChat } from "simple-support-chat";

function App() {
  const { open, close, isOpen } = useSupportChat();

  return (
    <>
      <nav>
        <button onClick={open}>Contact Us</button>
      </nav>

      <SupportChatModal
        apiUrl="/api/support"
        isOpen={isOpen}
        onClose={close}
        title="Get Help"
        color="#10b981"
        user={currentUser}
      />
    </>
  );
}

The modal renders centered on desktop (max-width 500px) with a backdrop overlay, and full-screen on mobile. You can place the trigger button anywhere -- nav bar, footer, settings page, error boundaries, etc.

SupportChatModal Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | apiUrl | string | (required) | URL of your support API endpoint | | repliesUrl | string | undefined | URL of the replies endpoint. Enables bidirectional chat (polling). | | sseUrl | string | undefined | URL of the SSE endpoint. Enables real-time delivery. Falls back to repliesUrl polling on failure. | | isOpen | boolean | (required) | Whether the modal is open | | onClose | () => void | (required) | Callback to close the modal | | color | string | '#2563eb' | Primary color | | title | string | 'Contact Us' | Modal header title | | placeholder | string | 'Type a message...' | Input placeholder text | | user | ChatUser | undefined | Authenticated user info |

useSupportChat Hook

import { useSupportChat } from "simple-support-chat";

function ContactButton() {
  const { open } = useSupportChat();
  return <button onClick={open}>Contact Us</button>;
}

Express / Connect Handler

For Express-style frameworks (Express, Fastify with express compat, etc.), use createExpressHandler instead:

import express from "express";
import { createExpressHandler } from "simple-support-chat/server";

const app = express();
app.use(express.json());

app.post(
  "/api/support",
  createExpressHandler({
    slackBotToken: process.env.SLACK_BOT_TOKEN!,
    slackChannel: process.env.SLACK_CHANNEL_ID!,
    botName: "Support Bot",
    botIcon: ":speech_balloon:",
    onMessage: (data) => {
      console.log("New support message:", data.message);
    },
  }),
);

app.listen(3000);

The Express handler uses the same threading logic as the Web API handler. The request body must be parsed before the handler runs (use express.json() middleware).

API Reference

Server Exports (simple-support-chat/server)

| Export | Description | |--------|-------------| | createSupportHandler(options) | Returns a Web API (Request) => Promise<Response> handler for messages | | createExpressHandler(options) | Returns an Express (req, res) => Promise<void> handler for messages | | createWebhookHandler(options) | Returns a Web API handler for Slack event webhooks | | createExpressWebhookHandler(options) | Returns an Express handler for Slack event webhooks | | createRepliesHandler(options) | Returns a Web API handler for the replies polling endpoint | | createExpressRepliesHandler(options) | Returns an Express handler for the replies polling endpoint | | createSSEHandler(options) | Returns a Web API handler for SSE streaming (real-time replies) | | createExpressSSEHandler(options) | Returns an Express handler for SSE streaming (real-time replies) | | createReplyEmitter() | Creates an in-process event emitter for SSE broadcasting | | verifySlackSignature(secret, sig, ts, body) | Verifies a Slack request signature | | InMemoryStore | Default in-memory implementation of SupportChatStore | | validateSlackToken(token) | Validates a Slack token via auth.test | | emojify(text) | Converts Slack emoji shortcodes (:heart:) to Unicode (❤️) |

Client Exports (simple-support-chat)

| Export | Description | |--------|-------------| | ChatBubble | Floating chat bubble React component | | SupportChatModal | Modal-based chat React component | | useSupportChat() | Hook returning { open, close, toggle, isOpen } | | useChatEngine(options) | Low-level hook for chat message state | | useReplyTransport(options) | Low-level hook for SSE/polling reply transport | | collectAnonymousContext() | Collects browser context (page URL, user agent, etc.) | | getSessionId() | Gets or creates a persistent anonymous session ID |

Types

All TypeScript types are exported from both entry points:

// Client types
import type {
  ChatBubbleProps,
  ChatUser,
  SupportChatModalProps,
  ChatMessage,
  TransportMode,
  ReplyTransportOptions,
  ReplyTransportState,
} from "simple-support-chat";

// Server types
import type {
  SupportHandlerOptions,
  IncomingMessage,
  HandlerResponse,
  SupportChatStore,
  Reply,
  ThreadRecord,
  WebhookHandlerOptions,
  RepliesHandlerOptions,
  RepliesResponse,
  ReplyEmitter,
  SSEHandlerOptions,
} from "simple-support-chat/server";

Development

pnpm install
pnpm dev          # Watch mode
pnpm test         # Run tests
pnpm typecheck    # TypeScript check
pnpm lint         # ESLint
pnpm build        # Production build

License

MIT