Streamyy is a package-based calling infrastructure for 1-to-1 audio and video calls.
This repository is the source for the publishable packages.
The intended usage is simple:
- frontend developers install the frontend package
- backend developers install the backend package
- both sides communicate through the same signaling events and call lifecycle
Important:
- Streamyy handles signaling, call state, presence, and socket orchestration
- Streamyy does not process audio or video media on the server
- media still flows peer-to-peer through WebRTC
- persistence is adapter-based, so storage is not tied to MongoDB
Shared internal/backend package for:
- call session types
- call statuses
- repositories
- persistence adapters
- service lifecycle logic
Optional MongoDB/Mongoose adapter package for:
- Mongoose models
- Mongoose repositories
createMongoosePersistenceAdapter(...)
Optional Prisma adapter package for:
- Prisma-backed repositories
createPrismaPersistenceAdapter(...)
Optional PostgreSQL adapter package for:
- SQL-backed repositories
createPostgresPersistenceAdapter(...)
Optional Redis adapter package for:
- lightweight ephemeral state
- fast presence and connection tracking
createRedisPersistenceAdapter(...)
Optional Supabase adapter package for:
- Supabase table-backed repositories
createSupabasePersistenceAdapter(...)
Optional DynamoDB adapter package for:
- DynamoDB-backed repositories
createDynamoDbPersistenceAdapter(...)
Right now Streamyy supports:
- in-memory storage out of the box
- MongoDB through
@streamyy/mongoose - Prisma through
@streamyy/prisma - PostgreSQL through
@streamyy/postgres - Redis through
@streamyy/redis - Supabase through
@streamyy/supabase - DynamoDB through
@streamyy/dynamodb - custom adapters through the repository interfaces in
@streamyy/core
That means you can support:
- Prisma
- PostgreSQL
- MySQL
- Redis
- Supabase
- DynamoDB
- your own custom persistence layer
Official adapter packages in this workspace:
@streamyy/mongoose@streamyy/prisma@streamyy/postgres@streamyy/redis@streamyy/supabase@streamyy/dynamodb
Each adapter package now includes its own README and, where relevant, ready-to-copy schema examples.
Backend package developers install.
Use it when you want:
- Socket.IO signaling transport
- runtime bootstrap
- Express integration
- Fastify integration
- Nest-style module integration
- 60-second ringing timeout by default
- persistence-agnostic backend setup
Frontend package developers install.
Use it when you want:
- signaling client
- React hooks
- default install-ready UI
- ringtone support
- reconnect-aware connection state
- WebRTC helpers
CLI package developers can use to scaffold starter apps.
Use it when you want:
npx streamyy initto generate backend and frontend startersnpx streamyy init --backendfor backend onlynpx streamyy init --frontendfor frontend onlynpx streamyy init --frontend --customfor a hook-based custom frontend starter
Install:
npm install @streamyy/serverWhat they get:
- runtime bootstrap
- signaling handlers
- call session management
- presence tracking
- HTTP helper routes
Install:
npm install @streamyy/clientWhat they get:
StreamyyClientStreamyyProvideruseStreamyy()StreamyyCallWidgetVideoStage- ringtone configuration
- WebRTC helper utilities
This is the main backend entry point.
import { createServer } from "node:http";
import express from "express";
import { createStreammyServer, registerExpressStreammyRoutes } from "@streamyy/server";
const app = express();
app.use(express.json());
const httpServer = createServer(app);
const streammy = createStreammyServer({
httpServer,
ringingTimeoutMs: 60_000,
rateLimit: {
connectionAttempts: { max: 20, windowMs: 60_000 },
callInitiation: { max: 8, windowMs: 60_000 },
},
socket: {
cors: {
origin: "*",
},
},
auth: async (token, handshake) => {
if (!token) {
throw new Error("Missing auth token");
}
return {
userId: "user_123",
deviceId: "web_browser",
metadata: {
authSource: "jwt",
handshake,
},
};
},
});
streammy.bind();
registerExpressStreammyRoutes(app, {
service: streammy.service,
basePath: "/streammy",
});
httpServer.listen(4000);What this does:
- creates the call service
- creates the Socket.IO server internally
- binds Socket.IO events internally
- handles authentication
- can rate-limit connection attempts and call initiation
- enables ringing timeout
- exposes optional HTTP routes
- uses in-memory storage by default unless you pass a persistence adapter
npx streamyy initOr generate a single starter:
npx streamyy init --backend
npx streamyy init --frontend
npx streamyy init --frontend --customThe CLI creates streamyy-backend and/or streamyy-frontend directories in your current folder.
If you want call history, presence persistence, and socket connection persistence across restarts, pass a Mongoose adapter.
import mongoose from "mongoose";
import { createServer } from "node:http";
import express from "express";
import { createMongoosePersistenceAdapter } from "@streamyy/mongoose";
import { createStreammyServer } from "@streamyy/server";
await mongoose.connect(process.env.MONGODB_URI!);
const app = express();
const httpServer = createServer(app);
const streammy = createStreammyServer({
httpServer,
persistence: createMongoosePersistenceAdapter(mongoose),
});This means:
@streamyy/serveris storage-agnostic- Mongoose is optional
- you can later add other adapters like Prisma, PostgreSQL, Redis, or your own custom repositories
Install for this option:
npm install @streamyy/server @streamyy/mongoose mongooseIf your app uses another database, implement the repository interfaces from @streamyy/core and pass them into the server runtime.
import {
defineStreammyPersistenceAdapter,
type CallSessionRepository,
type SocketConnectionRepository,
type UserPresenceRepository,
} from "@streamyy/core";
import { createStreammyServer } from "@streamyy/server";
const sessions: CallSessionRepository = {
async create(session) {
return session;
},
async findByCallId(callId) {
return null;
},
async update(callId, update) {
return null;
},
};
const presence: UserPresenceRepository = {
async upsert(record) {
return record;
},
async findByUserId(userId) {
return null;
},
};
const connections: SocketConnectionRepository = {
async upsert(record) {
return record;
},
async deleteByConnectionId(connectionId) {
return null;
},
async findByConnectionId(connectionId) {
return null;
},
async findByUserId(userId) {
return [];
},
async countByUserId(userId) {
return 0;
},
};
const persistence = defineStreammyPersistenceAdapter({
sessions,
presence,
connections,
});
const streammy = createStreammyServer({
httpServer,
persistence,
});What this gives you:
- Streamyy server logic stays the same
- only the storage adapter changes
- backend teams can keep using their existing database stack
Create:
PrismaCallSessionRepositoryPrismaUserPresenceRepositoryPrismaSocketConnectionRepository
Then pass them as:
const persistence = defineStreammyPersistenceAdapter({
sessions: new PrismaCallSessionRepository(prisma),
presence: new PrismaUserPresenceRepository(prisma),
connections: new PrismaSocketConnectionRepository(prisma),
});For lightweight ephemeral calling state, you can also implement repositories backed by Redis.
That can be useful when:
- you care more about speed than long-term history
- you want fast presence and socket tracking
- you want short-lived call session state
Install:
npm install @streamyy/server @streamyy/prismaUsage:
import { createPrismaPersistenceAdapter } from "@streamyy/prisma";
const persistence = createPrismaPersistenceAdapter({
callSession: prisma.callSession,
userPresence: prisma.userPresence,
socketConnection: prisma.socketConnection,
});
const streammy = createStreammyServer({
httpServer,
persistence,
});Install:
npm install @streamyy/server @streamyy/postgres pgUsage:
import { Pool } from "pg";
import { createPostgresPersistenceAdapter } from "@streamyy/postgres";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const persistence = createPostgresPersistenceAdapter({
client: pool,
});Install:
npm install @streamyy/server @streamyy/redis redisUsage:
import { createClient } from "redis";
import { createRedisPersistenceAdapter } from "@streamyy/redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const persistence = createRedisPersistenceAdapter({
client: redis,
});Install:
npm install @streamyy/server @streamyy/supabase @supabase/supabase-jsUsage:
import { createClient } from "@supabase/supabase-js";
import { createSupabasePersistenceAdapter } from "@streamyy/supabase";
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!);
const persistence = createSupabasePersistenceAdapter({
callSession: supabase.from("streammy_call_sessions"),
userPresence: supabase.from("streammy_user_presence"),
socketConnection: supabase.from("streammy_socket_connections"),
});Install:
npm install @streamyy/server @streamyy/dynamodb @aws-sdk/lib-dynamodbUsage:
import { createDynamoDbPersistenceAdapter } from "@streamyy/dynamodb";
const persistence = createDynamoDbPersistenceAdapter({
client: dynamoDocumentClient,
});If your backend uses Express:
import express from "express";
import { registerExpressStreammyRoutes } from "@streamyy/server";
const app = express();
app.use(express.json());
registerExpressStreammyRoutes(app, {
service: streammy.service,
basePath: "/streammy",
});Routes:
GET /streammy/healthPOST /streammy/callsPOST /streammy/calls/:callId/end
POST /streammy/calls
Content-Type: application/json
{
"callerId": "user_123",
"receiverId": "user_456",
"callType": "video",
"metadata": {
"conversationId": "conv_001"
}
}POST /streammy/calls/call_123/end
Content-Type: application/json
{
"userId": "user_123",
"deviceId": "web_browser"
}If your backend uses Fastify:
import Fastify from "fastify";
import { registerFastifyStreammyRoutes } from "@streamyy/server";
const app = Fastify();
registerFastifyStreammyRoutes(app, {
service: streammy.service,
basePath: "/streammy",
});If you want Nest-style module registration:
import { StreammyModule } from "@streamyy/server";
const streammyModule = StreammyModule.forRoot({
global: true,
service: streammy.service,
notifier: streammy.notifier,
});The backend package handles:
- user connection registration
- multi-device user rooms
- incoming call notification
- accept, decline, cancel, and end events
- SDP and ICE relay
- presence updates
- missed call timeout after 60 seconds
- internal Socket.IO setup, so backend users do not need to install or create Socket.IO manually
- in-memory storage by default
- custom persistence via adapter injection
This is the easiest frontend integration path.
import { StreamyyCallWidget, StreamyyProvider } from "@streamyy/client";
export function CallingPage() {
return (
<StreamyyProvider
options={{
url: "http://localhost:4000",
token: "jwt-token",
userId: "user_123",
deviceId: "web_browser",
autoConnect: true,
lowBandwidthMode: true,
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelayMs: 1000,
reconnectionDelayMaxMs: 5000,
}}
>
<StreamyyCallWidget
defaultReceiverId="user_456"
defaultCallType="video"
/>
</StreamyyProvider>
);
}What the default UI gives you:
- start call form
- WhatsApp-style default in-call layout
- real local and remote media rendering
- incoming call accept/decline panel
- working mute and camera toggles
- end-call action
- reconnect status
- built-in ringtone behavior
- custom incoming-call and active-call render overrides
The frontend package supports different incoming and outgoing ringing sources.
You can provide:
- a file URL
- a generated tone pattern
<StreamyyCallWidget
ringtones={{
incoming: { kind: "url", src: "/sounds/incoming.mp3" },
outgoing: { kind: "url", src: "/sounds/outgoing.mp3" },
}}
/><StreamyyCallWidget
ringtones={{
incoming: {
kind: "pattern",
pattern: {
steps: [
{ frequency: 880, durationMs: 220, gain: 0.06 },
{ frequency: 660, durationMs: 220, gain: 0.06 },
],
pauseMs: 900,
},
},
outgoing: {
kind: "pattern",
pattern: {
steps: [{ frequency: 520, durationMs: 850, gain: 0.05 }],
pauseMs: 1100,
},
},
}}
/>If you want Streamyy to keep handling call state, ringing, and WebRTC, but you want your own screens, pass render functions into the widget.
<StreamyyCallWidget
renderIncomingCall={({ call, accept, decline }) => (
<MyIncomingCallSheet
callerId={call.callerId}
type={call.callType}
onAccept={accept}
onDecline={decline}
/>
)}
renderCallInterface={({ activeCall, media, toggleMute, toggleVideo, end }) => (
<MyCallScreen
call={activeCall}
localStream={media.localStream}
remoteStream={media.remoteStream}
muted={media.muted}
videoEnabled={media.videoEnabled}
onToggleMute={toggleMute}
onToggleVideo={toggleVideo}
onEnd={end}
/>
)}
/>If the frontend team does not want the default UI, they can use the client and build their own interface.
import { createStreamyyClient } from "@streamyy/client";
const client = createStreamyyClient({
url: "http://localhost:4000",
token: "jwt-token",
userId: "user_123",
deviceId: "web_browser",
autoConnect: true,
lowBandwidthMode: true,
reconnection: true,
});
client.on("incomingCall", (call) => {
console.log("Incoming call", call);
});
client.on("callAccepted", (payload) => {
console.log("Accepted", payload);
});
client.on("callEnded", (payload) => {
console.log("Ended", payload.status, payload.reason);
});
client.on("error", (payload) => {
console.error(payload.code, payload.message);
});
client.initiateCall("user_456", "audio", {
conversationId: "conv_001",
});If the frontend team wants a custom UI but still wants package-managed state:
import { StreamyyProvider, useStreamyy } from "@streamyy/client";
function CustomCallingUI() {
const {
connected,
reconnecting,
activeCall,
callStatus,
media,
startAudioCall,
startVideoCall,
acceptCall,
declineCall,
endCall,
toggleMute,
toggleVideo,
} = useStreamyy();
return (
<div>
<p>Connected: {String(connected)}</p>
<p>Reconnecting: {String(reconnecting)}</p>
<p>Status: {callStatus}</p>
<button onClick={() => void startAudioCall("user_456")}>
Start audio call
</button>
<button onClick={() => void startVideoCall("user_456")}>
Start video call
</button>
{activeCall?.direction === "incoming" ? (
<div>
<button onClick={() => void acceptCall(activeCall.callId)}>Accept</button>
<button onClick={() => void declineCall(activeCall.callId)}>Decline</button>
</div>
) : null}
{activeCall ? (
<>
<button onClick={() => toggleMute()}>{media.muted ? "Unmute" : "Mute"}</button>
<button onClick={() => toggleVideo()}>{media.videoEnabled ? "Stop video" : "Start video"}</button>
<button onClick={() => void endCall(activeCall.callId)}>End</button>
</>
) : null}
</div>
);
}The frontend package also exports helper utilities.
import { getUserMedia, toggleStreamTracks } from "@streamyy/client";
const localStream = await getUserMedia({
audio: true,
video: true,
});
toggleStreamTracks(localStream, "audio", false);
toggleStreamTracks(localStream, "video", true);import { StreamyyPeerSession } from "@streamyy/client";
const peer = new StreamyyPeerSession({
client,
callId: "call_123",
remoteUserId: "user_456",
});
peer.attachLocalStream(localStream);
const offer = await peer.createOffer();
client.sendOffer("call_123", "user_456", offer);If the frontend team wants the common calling layout where:
- one video is shown in the main area
- the other video is shown in a smaller corner preview
- tapping the smaller preview swaps them
they can use VideoStage.
import { VideoStage } from "@streamyy/client";
<VideoStage
localStream={localStream}
remoteStream={remoteStream}
localLabel="You"
remoteLabel="Ada"
defaultMainView="remote"
/>Behavior:
- remote video is large by default
- local video appears in the smaller corner tile
- clicking the smaller tile swaps the focus
- clicking again swaps back
- local video is not mirrored unless you opt in
VideoTile is not mirrored by default.
That means when a user moves left, the video also moves left on screen.
If a frontend team wants selfie-style preview behavior, they can opt in:
import { VideoTile } from "@streamyy/client";
<VideoTile
stream={localStream}
label="Local preview"
mirrored={true}
/>The frontend package currently supports:
- outgoing and incoming call states
- reconnect-aware status
- low-bandwidth client mode
- different incoming and outgoing ringtones
- custom ringtone sources
- default call UI
- custom UI through hooks and client access
Socket events used by the packages:
call:initiatecall:incomingcall:acceptcall:declinecall:cancelcall:endcall:offercall:answercall:ice-candidatepresence:update
Statuses used by Streamyy:
initiatedringingaccepteddeclinedmissedongoingendedcancelledfailed
- Caller initiates the call.
- Backend creates a call session.
- Receiver gets
call:incoming. - Receiver accepts or declines.
- Offer, answer, and ICE candidates are exchanged.
- Call becomes active.
- If nobody answers within 60 seconds, the call becomes
missed. - When either side ends the call, the backend stores
endedAt,duration, andendedBy.
These are for working on the package source in this repository.
Install workspace dependencies:
npm installBuild all packages:
npm run buildBuild only backend package:
npm run build:serverBuild only Mongoose adapter package:
npm run build:mongooseBuild the other adapter packages:
npm run build:prisma
npm run build:postgres
npm run build:redis
npm run build:supabase
npm run build:dynamodbBuild only frontend package:
npm run build:frontendBuild only core package:
npm run build:core