yoctopus
v1.2.1
Published
A plugin-based WebSocket server framework for Node.js. Yoctopus provides a modular architecture where functionality is organized into plugins that define event listeners, endpoints, and connection-scoped settings.
Keywords
Readme
Yoctopus
A plugin-based WebSocket server framework for Node.js. Yoctopus provides a modular architecture where functionality is organized into plugins that define event listeners, endpoints, and connection-scoped settings.
Table of Contents
Features
- Plugin Architecture: Modular design where each plugin can define endpoints, settings, and event listeners
- Connection-Scoped State: Each connection has its own data store with typed, validated settings
- Request Validation: Built-in validation for request formats (string, number, boolean, array, object, date, email, hostname, custom)
- Endpoint Requirements: Conditionally allow/deny endpoint access based on connection state
- Lifecycle Events: Hook into connection open, close, request received, response prepared/sent
- Cross-Plugin Communication: Plugins can read each other's settings and listen for changes
- Real-time Notifications: Push messages to clients via the
notifyfunction - Multicast Support: Send messages to filtered subsets of connections
- SSL/TLS Support: Built-in HTTPS/WSS support
Installation
npm install yoctopusQuick Start
const Yoctopus = require('yoctopus')
const server = new Yoctopus({ port: 3000 })
// Add a simple plugin
server.plugin({
name: 'greeter',
endpoints: {
hello: {
read: {
requestFormat: {
name: 'string'
},
handler: ({ request }) => ({
message: `Hello, ${request.data.name}!`
})
}
}
}
})
server.start()A client can now connect and send:
{
"meta": { "id": "1", "endpoint": "greeter:hello", "method": "read" },
"data": { "name": "Alice" }
}And receive:
{
"meta": { "id": "1", "endpoint": "greeter:hello", "method": "read" },
"data": { "message": "Hello, Alice!" }
}Core Concepts
Server Initialization
const server = new Yoctopus({
port, // Port to host the server on
keyPath, // Path to SSL key (optional, enables HTTPS)
certPath, // Path to SSL certificate (optional)
caPath, // Path to SSL CA (optional)
allowedOrigins, // Array of allowed origins, e.g. ['https://example.com'] or ['*']
pingInterval, // Milliseconds between ping checks (default: 250)
maxLostPings, // Max unanswered pings before disconnect (default: 3)
staticEndpointArguments // Object passed to all handlers
})
// Add plugins
server.plugin(pluginConfig, pluginOptions)
// Start the server
await server.start()Plugins
Plugins are the building blocks of Yoctopus. Each plugin can define:
- name: Unique identifier (required)
- settings: Connection-scoped variables with type validation
- endpoints: Request handlers
- listeners: Event handlers for lifecycle and setting changes
- passToListeners: Functions/data to pass to other plugins' handlers
{
name: 'my-plugin',
settings: {
'userId': 'string',
'isAdmin': 'bool',
'loginCount': 'number'
},
listeners: {
server: {
onConnectionOpened: ({ connectionSettings, socket, notify, connectionId }) => {},
onConnectionClosed: ({ connectionSettings, notify, connectionId }) => {},
onRequestReceived: ({ request, connectionSettings, notify, data }) => {},
onResponsePrepared: ({ request, connectionSettings, notify, data }) => {},
onResponseSent: ({ request, connectionSettings, notify, data }) => {},
onConnectionChanged: ({ connectionSettings, notify }) => {}
},
plugins: {
'other-plugin': {
'settingName': ({ value, setting, connectionId }) => {
// Called when another plugin's setting changes
}
}
}
},
endpoints: {
'endpoint-name': {
'method': {
requirements: { 'plugin.setting': expectedValue },
requestFormat: { 'key': 'type' },
handler: async ({ request, connectionSettings, notify, log, connectionId }) => {
return { result: 'data' }
}
}
}
},
passToListeners: {
someFunction: () => {},
someData: { key: 'value' }
}
}Endpoints
Endpoints are organized by name and method. The method typically represents the type of operation:
endpoints: {
user: {
create: {
requestFormat: {
name: 'string',
email: 'email',
age: 'number'
},
handler: async ({ request, connectionSettings }) => {
const { name, email, age } = request.data
// Create user...
return { id: 'user-123', name, email }
}
},
read: {
requestFormat: {
id: 'string'
},
handler: async ({ request }) => {
// Fetch user...
return { id: request.data.id, name: 'Alice' }
}
},
update: {
requirements: { 'auth.authenticated': true },
handler: async ({ request, connectionSettings }) => {
// Update user...
}
},
delete: {
requirements: { 'auth.isAdmin': true },
handler: async ({ request }) => {
// Delete user...
}
}
}
}Endpoint Aliases
By default, endpoints are accessed with plugin-name:endpoint format. You can create aliases for cleaner access:
server.plugin({
name: 'fb-auth',
endpoints: { user: { read: { handler: () => {} } } }
}, {
endpointAliases: {
'user': 'account' // Access as 'account' instead of 'fb-auth:user'
}
})Connection Settings
Each connection has a data store where plugins can store typed, validated settings.
Declaring Settings
{
name: 'auth',
settings: {
'authenticated': 'bool',
'userId': 'string',
'roles': 'array',
'metadata': 'object',
'loginCount': 'number'
}
}Reading Settings
Use connectionSettings.get(pluginName, settingKey) to read any plugin's settings:
handler: async ({ connectionSettings }) => {
const userId = connectionSettings.get('auth', 'userId')
const isAdmin = connectionSettings.get('auth', 'roles')?.includes('admin')
const port = connectionSettings.get('yoctopus', 'port')
return { userId, isAdmin, port }
}Writing Settings
Method 1: Setter (Legacy)
handler: async ({ connectionSettings }) => {
connectionSettings.set('authenticated', true)
connectionSettings.set('userId', 'user-123')
return { success: true }
}Method 2: Return Value (Recommended)
handler: async () => ({
settings: {
authenticated: true,
userId: 'user-123'
},
response: {
success: true,
message: 'Logged in'
}
})The return value method is cleaner and makes settings changes explicit. You can return:
- Both settings and response:
{ settings: {...}, response: {...} } - Only settings:
{ settings: {...} }(response defaults to{}) - Only response:
{ response: {...} }(no settings changed) - Direct value (legacy):
return someValue(treated as response)
Listening to Setting Changes
Plugins can react when another plugin's settings change:
{
name: 'logger',
listeners: {
plugins: {
'auth': {
'authenticated': ({ value, setting, connectionId }) => {
console.log(`Connection ${connectionId} auth changed to ${value}`)
}
}
}
}
}Lifecycle Events
| Event | When Called | Use Case |
|-------|-------------|----------|
| onConnectionOpened | New WebSocket connection established | Initialize connection state, authenticate |
| onRequestReceived | Request received, before processing | Logging, rate limiting, request transformation |
| onResponsePrepared | Response ready, before sending | Response transformation, logging |
| onResponseSent | Response sent to client | Analytics, cleanup |
| onConnectionClosed | Connection closed | Cleanup, logging |
| onConnectionChanged | Any setting changed on connection | Cross-plugin coordination |
listeners: {
server: {
onConnectionOpened: async ({ socket, notify, connectionSettings, connectionId }) => {
console.log(`New connection: ${connectionId}`)
// Access request headers via socket.upgradeReq.headers
},
onRequestReceived: async ({ request, data }) => {
console.log(`Request to ${request.meta.endpoint}`)
// Return modified data to transform the request
return { ...data, timestamp: Date.now() }
},
onResponsePrepared: async ({ data }) => {
// Return modified data to transform the response
return { ...data, serverTime: Date.now() }
},
onConnectionClosed: async ({ connectionId }) => {
console.log(`Connection closed: ${connectionId}`)
}
}
}Request Format Validation
Define expected request data types. Requests that don't match are rejected automatically.
requestFormat: {
name: 'string', // Must be a string
age: 'number', // Must be a number
isActive: 'bool', // Must be a boolean
tags: 'array', // Must be an array
metadata: 'object', // Must be an object
email: 'email', // Must be valid email format
website: 'hostname', // Must be valid hostname
birthdate: 'date', // Must be ISO8601 date string
status: ['active', 'inactive', 'pending'], // Union type - must be one of these
validator: (value) => value > 0 // Custom validator function
}For nested validation:
requestFormat: {
user: {
name: 'string',
address: {
street: 'string',
city: 'string',
zip: 'string'
}
}
}Requirements
Restrict endpoint access based on connection state:
endpoints: {
admin: {
dashboard: {
requirements: {
'auth.authenticated': true,
'auth.isAdmin': true
},
handler: () => ({ dashboardData: '...' })
}
},
profile: {
read: {
requirements: {
'auth.authenticated': true
},
handler: ({ connectionSettings }) => {
return { userId: connectionSettings.get('auth', 'userId') }
}
}
}
}Requirements can also be async functions:
requirements: {
'custom': async ({ connectionSettings, log }) => {
const userId = connectionSettings.get('auth', 'userId')
return await checkUserPermissions(userId)
}
}Bundled Plugins
yoctopus (Core Plugin)
Automatically installed. Provides basic connection metadata.
Settings:
label(string): Human-readable connection labelisSecure(bool): Whether connection is over HTTPS/WSSport(number): Server portremoteAddress(string): Client IP address
Endpoints:
yoctopus:label.update- Set a label for the connection
// Client can label their connection
{
"meta": { "id": "1", "endpoint": "yoctopus:label", "method": "update" },
"data": "Mobile App Connection"
}multicast
Send messages to filtered subsets of connections based on their store data.
Usage:
// In any endpoint handler
handler: async ({ multicast }) => {
// Send to all connections where auth.isAdmin is true
multicast(
{ auth: { isAdmin: true } },
{ type: 'admin-alert', data: { message: 'System update' } }
)
// Send to connections with specific userId
multicast(
{ auth: { userId: 'user-123' } },
{ type: 'personal-notification', data: { ... } }
)
return { sent: true }
}api
Exposes the server's endpoint API to clients. Useful for auto-generating documentation or building dynamic clients.
Endpoints:
api:api.read- Get all available endpoints and their request formatsapi:api.subscribe- Subscribe to API changesapi:api.unsubscribe- Unsubscribe from API changes
// Client request
{ "meta": { "id": "1", "endpoint": "api:api", "method": "read" }, "data": {} }
// Response
{
"meta": { ... },
"data": {
"greeter:hello": {
"read": { "requestFormat": { "name": "string" } }
},
"user": {
"create": { "requestFormat": { "name": "string", "email": "email" } },
"read": { "requestFormat": { "id": "string" } }
}
}
}sequelize-change-tracker
Requires: npm install sequelize sequelize-change-tracker sqlite3 (or your DB driver)
Real-time database change notifications. Automatically notifies connected clients when Sequelize models are created, updated, or deleted.
Setup:
const Yoctopus = require('yoctopus')
const { Sequelize, DataTypes } = require('sequelize')
const changeTrackerPlugin = require('yoctopus/plugins/sequelize-change-tracker')
// Setup Sequelize
const sequelize = new Sequelize('sqlite::memory:')
const User = sequelize.define('User', {
name: DataTypes.STRING,
email: DataTypes.STRING,
password: DataTypes.STRING // Sensitive - won't be exposed
})
const Post = sequelize.define('Post', {
title: DataTypes.STRING,
content: DataTypes.TEXT,
authorId: DataTypes.INTEGER
})
await sequelize.sync()
// Setup Yoctopus
const server = new Yoctopus({ port: 3000 })
const { tracker, plugin } = changeTrackerPlugin({
models: [User, Post],
server: server,
autoSubscribeTo: ['Post'], // Auto-subscribe all connections to Post changes
publicFieldsPerModel: {
User: ['id', 'name'], // Only expose id and name (not email, password)
Post: ['id', 'title', 'content', 'authorId']
}
})
server.plugin(plugin)
await server.start()How it works:
- Auto-subscription: When a client connects, they're automatically subscribed to models listed in
autoSubscribeTo - Field filtering: Only fields in
publicFieldsPerModelare sent to clients (protects sensitive data) - Cleanup: Subscriptions are automatically removed when connections close
Client notifications:
When database changes occur, subscribed clients receive:
{
"meta": { "method": "notify", "type": "data-changed" },
"data": {
"operation": "create",
"model": "Post",
"fields": {
"id": 1,
"title": "Hello World",
"content": "My first post",
"authorId": 42
}
}
}Operations: create, update, delete
Manual subscriptions:
You can allow clients to subscribe to specific models via endpoints:
server.plugin({
name: 'subscriptions',
endpoints: {
subscribe: {
create: {
requestFormat: { model: 'string' },
handler: ({ request, changeTracker, connectionId }) => {
changeTracker.addSubscription({
modelName: request.data.model,
subscriptionId: connectionId
})
return { subscribed: request.data.model }
}
}
},
unsubscribe: {
delete: {
requestFormat: { model: 'string' },
handler: ({ request, changeTracker, connectionId }) => {
changeTracker.removeSubscription({
modelName: request.data.model,
subscriptionId: connectionId
})
return { unsubscribed: request.data.model }
}
}
}
}
})Instance-specific subscriptions:
Subscribe to changes on specific records:
// Subscribe to a specific post's changes
changeTracker.addSubscription({
modelName: 'Post',
subscriptionId: connectionId,
instanceId: 42 // Only notified when Post with id=42 changes
})Client Communication
Message Format
All messages use JSON with this structure:
Request:
{
"meta": {
"id": "unique-request-id",
"endpoint": "plugin:endpoint",
"method": "read|create|update|delete|..."
},
"data": { ... }
}Response:
{
"meta": {
"id": "unique-request-id",
"endpoint": "plugin:endpoint",
"method": "read",
"status": "ok"
},
"data": { ... }
}Error Response:
{
"meta": {
"id": "unique-request-id",
"endpoint": "plugin:endpoint",
"method": "read",
"status": "error"
},
"error": "Error message or array of errors"
}Server Notification (push):
{
"meta": {
"method": "notify",
"type": "notification-type"
},
"data": { ... }
}Sending Notifications
From any handler:
handler: async ({ notify }) => {
// Send a notification to this connection
notify({ type: 'alert', data: { message: 'Hello!' } })
return { result: 'ok' }
}Advanced Topics
Binary Message Handling
Handle raw binary WebSocket messages:
const server = new Yoctopus({
port: 3000,
binaryHandler: async ({ buffer, connectionSettings, connectionId, notify }) => {
// Process binary data
const processed = processBuffer(buffer)
notify({ type: 'binary-processed', data: { size: buffer.length } })
},
binaryHandlerRequirements: {
'auth.authenticated': true // Only authenticated users can send binary
}
})Static Endpoint Arguments
Pass shared resources to all handlers:
const db = new Database()
const cache = new Redis()
const server = new Yoctopus({
port: 3000,
staticEndpointArguments: {
db,
cache,
config: { maxItems: 100 }
}
})
// In handlers:
handler: async ({ db, cache, config }) => {
const items = await db.query('SELECT * FROM items LIMIT ?', [config.maxItems])
return { items }
}Dynamic Origin Control
const server = new Yoctopus({
port: 3000,
allowedOrigins: ['https://myapp.com']
})
// Later, update allowed origins
server.allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com']Subscribing to Logs
const unsubscribe = server.subscribeToLogs((logEntry) => {
console.log(`[${logEntry.level}] ${logEntry.message}`)
// Send to external logging service
})
// Later
unsubscribe()Connection Register
Access all active connections:
// Get all connection IDs
const connectionIds = Object.keys(server.connectionRegister)
// Access a specific connection
const connection = server.connectionRegister[connectionId]
connection.notify({ type: 'direct-message', data: {...} })
connection.close()
// Export connection state
const state = connection.exportStore()Error Handling
const server = new Yoctopus({
port: 3000,
onError: (error) => {
console.error('WebSocket server error:', error)
// Handle error, potentially restart server
}
}) *#(##((((###/
(/*(***,*/(/,****##
,(*((((,*,,,,,,,,,,/(#/
(/**((//*////..,,,//*/((%
*(//*,,,,,/////..,,,,**////
%#/////*,,.//*,,.,,//(*((/#
%(((///,,,...,/....///((((%
#(##/,,,,//.......,,//*((#%
%%%%(//,/,,....//..,,*(%%#%
&&@%(*,,,,./...,,,.,/#&&%
%#*%%%/,/*,//**,*%%#,(%
//(/( ///#@@@(#/,,**(/@@@(*/* (/(//
*/(///* #@%.&@@@%*,*,,#@&(@@@@# ,(////*
/*/*/ #%%%/%/, /@@@@@@(%/,,,,#/@@&&@@/ ,/#/#%%# *//*/
/*(% .. %##/#(% (%#%%****,*,,*###%( %(%*%%% ...##*/
.(/#* (#**(%. /(/,(/,,,./,/(* #(/*/# (#//.
,*(#/ #(//(#/((*(,,,./...,*,,/*,#%#(###(# #/(**
(#//*(// ###%(,***(///....//....,,,*#### **(/**((
(////(/*((((/(**(.,///,,.....////*,**/(((/,*(*/***(
(//*//**/((**/*,,,,.,.....*////..*(((//(/***/
((,%#%((*,,,*.,////......,,,,.*/(%#%,/(
(/#/# &//**/,*,,,*///....*/,,,,,,,,/& (*#/(
(/(/ /##*(#((**(/,*,,.,,,/////(((/*(/, *///
#/%//&%(#((* ((/***/**/*****(* *(*/(#&*/%(#
(%/(//*///, ,%/*/((* ,#(//(%. ,((/////*%/
#//(///*/ /#(((# (/*(#* (/***/*/#
((#**/((## #(**/**#(#
(##/&/(/#, .(/#/@/(#(
#((%/&%### #(#&&*%((#
/((*(%#(#% ((%(*##(
*((%/%%&. .&%&/%((*.
,(#(/( (//#(,
