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

@aumiqx/medusa-plugin-messages

v0.1.0-alpha.0

Published

Self-hosted messaging for Medusa v2 marketplaces — customer↔vendor and vendor↔admin threads with Server-Sent Events real-time push, unread tracking, and zero external SaaS.

Readme

@aumiqx/medusa-plugin-messages

Self-hosted messaging for Medusa v2 marketplaces. Drop-in plugin that gives your store:

  • Customer ↔ vendor threads scoped to orders
  • Vendor ↔ admin support threads
  • Unread badge counts per participant
  • Server-Sent Events real-time push (with polling as a safety net)
  • Zero external SaaS dependency — messages live in your Postgres

Built to replace TalkJS, Intercom widgets, and other per-MAU-priced chat tools for marketplace scenarios.

Why

Most chat SaaS is priced per monthly active user. For a marketplace where every buyer is a potential chat user, that scales nastily. This plugin keeps everything in your own Postgres, on your own infra, under MIT license.

Requirements

  • Medusa v2 (@medusajs/medusa >= 2.11)
  • Node 20+
  • Postgres (comes with Medusa)

Install

npm install @aumiqx/medusa-plugin-messages
# or
yarn add @aumiqx/medusa-plugin-messages

Register in medusa-config.ts:

export default defineConfig({
  plugins: [
    { resolve: "@aumiqx/medusa-plugin-messages", options: {} },
  ],
})

Run migrations:

npx medusa db:migrate

What's in the box

Data model

conversation
  id, type (order_customer_vendor | vendor_admin),
  order_id?, seller_id?, customer_id?,
  subject?, last_message_at, metadata

conversation_participant
  id, actor_type (customer | seller | admin), actor_id,
  last_read_at, last_seen_at

message
  id, author_actor_type, author_actor_id, body

Each participant tracks their own last_read_at, which is how unread counts work — messages created after a participant's last_read_at, by someone other than them.

API routes

All three scopes expose the same shape. Auth is enforced via the existing Medusa actor guards (admin bearer/cookie, customer bearer, etc.).

| Scope | Method | Path | Purpose | |---|---|---|---| | Admin | GET | /admin/conversations | List (filters: type, order_id, seller_id, customer_id, limit, offset) | | Admin | POST | /admin/conversations | Open a vendor↔admin thread | | Admin | GET | /admin/conversations/:id | Thread + messages | | Admin | POST | /admin/conversations/:id/messages | Reply (auto-joins the admin as a participant) | | Admin | POST | /admin/conversations/:id/read | Mark read (auto-joins) | | Admin | GET | /admin/conversations/:id/stream | SSE push stream | | Store | GET | /store/conversations | Customer's threads | | Store | POST | /store/conversations | Open an order↔vendor thread (verifies order.customer_id matches caller) | | Store | GET | /store/conversations/:id | Thread + messages (participation check) | | Store | POST | /store/conversations/:id/messages | Send (participation check) | | Store | POST | /store/conversations/:id/read | Mark read | | Store | GET | /store/conversations/:id/stream | SSE push stream |

Workflows

Import and call from your own code:

import {
  createOrGetConversationWorkflow,
  sendMessageWorkflow,
  markReadWorkflow,
} from "@aumiqx/medusa-plugin-messages/workflows"

Events

import { MessageEvents } from "@aumiqx/medusa-plugin-messages"

MessageEvents.CONVERSATION_CREATED  // 'messages.conversation.created'
MessageEvents.MESSAGE_CREATED       // 'messages.message.created'
MessageEvents.CONVERSATION_READ     // 'messages.conversation.read'

All fired from workflows via emitEventStep, so they only fire after the workflow commits successfully. Wire your own subscribers (email, push, analytics, etc.) on these.

Extending

Vendor / seller routes

The plugin ships admin + store routes. If your stack is a marketplace with a dedicated seller auth scope (e.g. MercurJS), add a matching /vendor/conversations/* route set in your own project that resolves the current seller from req.auth_context.actor_id and forwards to the same workflows. Example stub:

// your-project/src/api/vendor/conversations/route.ts
import {
  createOrGetConversationWorkflow,
} from "@aumiqx/medusa-plugin-messages/workflows"
// ... resolve sellerId from auth, then call the workflow ...

Email fallback when participants are offline

Subscribe to messages.message.created, check each non-author participant's last_seen_at, and fire your notification provider for anyone who's been idle beyond your threshold:

import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { Modules } from "@medusajs/framework/utils"
import {
  MESSAGES_MODULE,
  MessagesModuleService,
  MessageEvents,
} from "@aumiqx/medusa-plugin-messages"

export default async function handler({ event, container }: SubscriberArgs<{
  message_id: string
  conversation_id: string
  author_actor_type: string
  author_actor_id: string
}>) {
  const service = container.resolve<MessagesModuleService>(MESSAGES_MODULE)
  const notifications = container.resolve(Modules.NOTIFICATION)

  const participants = await service.listConversationParticipants({
    conversation_id: event.data.conversation_id,
  })

  const OFFLINE_MS = 5 * 60 * 1000
  const offline = participants.filter(
    (p) =>
      !(p.actor_type === event.data.author_actor_type &&
        p.actor_id === event.data.author_actor_id) &&
      (!p.last_seen_at ||
        Date.now() - new Date(p.last_seen_at as any).getTime() > OFFLINE_MS)
  )

  // Resolve each participant's email from your own actor tables,
  // then call notifications.createNotifications(...) with your template.
}

export const config: SubscriberConfig = {
  event: MessageEvents.MESSAGE_CREATED,
  context: { subscriberId: "messages-offline-email" },
}

Storefront real-time from a Bearer-auth storefront

Browser EventSource can't send Authorization headers. If your storefront auth is Bearer-in-localStorage/cookie (not same-origin session), proxy the stream through a same-origin route handler that reads the token server-side and pipes the upstream response body. Next.js example:

// app/api/conversation-stream/[id]/route.ts
export const dynamic = "force-dynamic"
export const runtime = "nodejs"

export async function GET(req, { params }) {
  const { id } = await params
  const bearer = await getBearerFromCookie() // your helper
  const upstream = await fetch(
    `${process.env.MEDUSA_BACKEND_URL}/store/conversations/${id}/stream`,
    {
      headers: { Authorization: `Bearer ${bearer}`, Accept: "text/event-stream" },
      signal: req.signal,
    }
  )
  return new Response(upstream.body, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  })
}

Then point new EventSource('/api/conversation-stream/<id>') at this route.

Scaling beyond one process

The default messageBus is a single-process Node EventEmitter. It works on PM2 fork mode or a single docker container. If you scale horizontally, swap the bus for a Redis pub/sub adapter with the same publish / subscribe interface — the SSE route handler doesn't care about the implementation.

Frontend reference

Shape of a conversation (list endpoint response):

type Conversation = {
  id: string
  type: "order_customer_vendor" | "vendor_admin"
  order_id: string | null
  seller_id: string | null
  customer_id: string | null
  subject: string | null
  last_message_at: string | null
  created_at: string
  updated_at: string
  unread_count: number
  participants: Array<{
    id: string
    actor_type: "customer" | "seller" | "admin"
    actor_id: string
    last_read_at: string | null
  }>
}

SSE frames emit one event type you care about:

event: message.created
data: {"message_id":"msg_...","author_actor_type":"seller","author_actor_id":"..."}

On receiving that frame, refetch the thread (polling fallback does the same).

Status & limitations (v0.1.0-alpha.0)

  • Single-process messageBus — swap for Redis if horizontally scaling.
  • No attachments — bring your own file storage integration.
  • No typing indicators.
  • Hard cap of 500 messages per thread load — pagination pending.
  • SSE endpoints are participant-gated, not role-gated — any admin sees any thread they've been added to (admin routes auto-add).

License

MIT © Aumiqx Technologies