1.2.0 • Published 5 months ago

@magicbutton.cloud/messaging v1.2.0

Weekly downloads
-
License
-
Repository
-
Last release
5 months ago

Magic Button Messaging

A type-safe, domain-driven design framework for distributed systems communication. Magic Button Messaging provides a robust foundation for building scalable, maintainable, and secure communication between distributed system components.

Magic Button Messaging

Table of Contents

Features

  • Contract-First Design: Define your communication contracts with Zod schemas for complete type safety
  • Dependency Injection: Inject contracts, transports, and providers for flexible architecture
  • Pluggable Transport Layer: Use built-in transports or create your own (HTTP, WebSockets, MQTT, etc.)
  • Role-Based Access Control: Strongly-typed role-based permissions for secure communication
  • Authentication: Pluggable authentication providers for flexible identity management
  • Authorization: Fine-grained access control with role inheritance and permissions
  • Type Safety: Full TypeScript support with inferred types from your Zod schemas
  • Client/Server Architecture: Dedicated client and server classes for easy implementation
  • Event-Driven Communication: Support for both request/response and event-based communication patterns
  • Error Handling: Standardized error registry with severity levels and retry capabilities

Installation

# Using npm
npm install @magicbutton.cloud/messaging

# Using yarn
yarn add @magicbutton.cloud/messaging

# Using pnpm
pnpm add @magicbutton.cloud/messaging

Quick Start

Define Your Contract

import * as z from "zod"
import { createContract, createEventMap, createRequestSchemaMap } from "@magicbutton.cloud/messaging"

// Define event schemas
const events = createEventMap({
  userCreated: z.object({
    id: z.string(),
    email: z.string().email(),
    createdAt: z.number(),
  }),
  userUpdated: z.object({
    id: z.string(),
    email: z.string().email().optional(),
    name: z.string().optional(),
    updatedAt: z.number(),
  }),
})

// Define request/response schemas
const requests = createRequestSchemaMap({
  getUserById: {
    requestSchema: z.object({
      id: z.string(),
    }),
    responseSchema: z.object({
      id: z.string(),
      email: z.string().email(),
      name: z.string().nullable(),
      createdAt: z.number(),
    }),
  },
  createUser: {
    requestSchema: z.object({
      email: z.string().email(),
      name: z.string().optional(),
    }),
    responseSchema: z.object({
      id: z.string(),
      email: z.string().email(),
      name: z.string().nullable(),
      createdAt: z.number(),
    }),
  },
})

// Define error codes
const errors = {
  USER_NOT_FOUND: { code: "USER_NOT_FOUND", message: "User not found", status: 404 },
  INVALID_EMAIL: { code: "INVALID_EMAIL", message: "Invalid email format", status: 400 },
}

// Create the contract
const userServiceContract = createContract({
  events,
  requests,
  errors,
})

export type UserServiceContract = typeof userServiceContract

Server Implementation

import { Server, InMemoryTransport } from "@magicbutton.cloud/messaging"
import { userServiceContract } from "./contract"

// Create a server with the in-memory transport
const transport = new InMemoryTransport()
const server = new Server(transport)

// Start the server
await server.start("memory://user-service")

// Handle the getUserById request
server.handleRequest("getUserById", async (payload, context, clientId) => {
  const { id } = payload
  
  // Simulate database lookup
  const user = users.find(u => u.id === id)
  
  if (!user) {
    throw new Error("USER_NOT_FOUND")
  }
  
  return user
})

// Handle the createUser request
server.handleRequest("createUser", async (payload, context, clientId) => {
  const { email, name } = payload
  
  // Create a new user
  const user = {
    id: crypto.randomUUID(),
    email,
    name: name || null,
    createdAt: Date.now(),
  }
  
  // Save the user
  users.push(user)
  
  // Emit userCreated event
  await server.broadcast("userCreated", user)
  
  return user
})

console.log("User service running on memory://user-service")

Client Implementation

import { Client, InMemoryTransport } from "@magicbutton.cloud/messaging"
import { userServiceContract } from "./contract"

// Create a client with the in-memory transport
const transport = new InMemoryTransport()
const client = new Client(transport, {
  clientId: "admin-client",
  clientType: "admin",
})

// Connect to the server
await client.connect("memory://user-service")

// Subscribe to events
client.on("userCreated", (payload) => {
  console.log("New user created:", payload)
})

// Send a request to create a user
const newUser = await client.request("createUser", {
  email: "john@example.com",
  name: "John Doe",
})
console.log("Created user:", newUser)

// Send a request to get a user
try {
  const user = await client.request("getUserById", { id: newUser.id })
  console.log("Retrieved user:", user)
} catch (error) {
  console.error("Error retrieving user:", error)
}

Core Concepts

Contracts

Contracts define the shape of your communication. They consist of:

  • Events: One-way messages published by services
  • Requests: Request/response pairs for service-to-service communication
  • Errors: Standardized error codes and messages
import * as z from "zod"
import { createContract, createEventMap, createRequestSchemaMap, createErrorMap } from "@magicbutton.cloud/messaging"

// Define events
const events = createEventMap({
  orderCreated: z.object({
    orderId: z.string(),
    customerId: z.string(),
    amount: z.number(),
    timestamp: z.number(),
  }),
})

// Define requests
const requests = createRequestSchemaMap({
  getOrderDetails: {
    requestSchema: z.object({
      orderId: z.string(),
    }),
    responseSchema: z.object({
      orderId: z.string(),
      customerId: z.string(),
      items: z.array(z.object({
        productId: z.string(),
        quantity: z.number(),
        price: z.number(),
      })),
      total: z.number(),
      status: z.enum(["pending", "processing", "shipped", "delivered"]),
      createdAt: z.number(),
    }),
  }),
})

// Define errors
const errors = createErrorMap({
  ORDER_NOT_FOUND: { code: "ORDER_NOT_FOUND", message: "Order not found", status: 404 },
  INVALID_ORDER_ID: { code: "INVALID_ORDER_ID", message: "Invalid order ID format", status: 400 },
})

// Create the contract
const orderServiceContract = createContract({
  events,
  requests,
  errors,
})

export type OrderServiceContract = typeof orderServiceContract

Transport Adapters

Transport adapters abstract the underlying communication protocol. Magic Button Messaging comes with an InMemoryTransport for testing, but you can implement your own adapters for HTTP, WebSockets, MQTT, etc.

import { TransportAdapter, MessageContext, AuthResult } from "@magicbutton.cloud/messaging"

// Example of a custom WebSocket transport adapter
export class WebSocketTransport implements TransportAdapter {
  private socket: WebSocket | null = null
  private eventHandlers = new Map()
  private requestHandlers = new Map()
  private connectionString = ""
  
  async connect(connectionString: string): Promise<void> {
    this.connectionString = connectionString
    this.socket = new WebSocket(connectionString)
    
    return new Promise((resolve, reject) => {
      this.socket!.onopen = () => resolve()
      this.socket!.onerror = (error) => reject(error)
      
      this.socket!.onmessage = (event) => {
        const message = JSON.parse(event.data)
        
        if (message.type === "event") {
          const handlers = this.eventHandlers.get(message.event)
          if (handlers) {
            handlers.forEach(handler => handler(message.payload, message.context))
          }
        } else if (message.type === "response") {
          // Handle responses to requests
          // ...
        }
      }
    })
  }
  
  // Implement other methods...
}

Client and Server

The Client and Server classes provide high-level abstractions for communication:

// Server example
const server = new Server(transport, {
  serverId: "order-service",
  version: "1.0.0",
})

await server.start("ws://localhost:8080")

server.handleRequest("getOrderDetails", async (payload, context, clientId) => {
  const { orderId } = payload
  return orderRepository.findById(orderId)
})

// Client example
const client = new Client(transport, {
  clientId: "web-client",
  autoReconnect: true,
})

await client.connect("ws://localhost:8080")

const orderDetails = await client.request("getOrderDetails", { orderId: "order-123" })

Access Control

Magic Button Messaging includes a role-based access control system:

import { createSystem, createRole, createAccessControl, createActor } from "@magicbutton.cloud/messaging"

// Define a system with resources, actions, and roles
const orderSystem = createSystem({
  name: "order-system",
  resources: ["order", "payment", "shipment"],
  actions: ["create", "read", "update", "delete"],
  roles: [
    createRole({
      name: "admin",
      permissions: ["order:*", "payment:*", "shipment:*"],
    }),
    createRole({
      name: "customer",
      permissions: ["order:read", "order:create"],
    }),
    createRole({
      name: "shipping-agent",
      permissions: ["order:read", "shipment:update"],
    }),
  ],
})

// Create an access control instance
const accessControl = createAccessControl(orderSystem)

// Create an actor
const user = createActor({
  id: "user-123",
  type: "user",
  roles: ["customer"],
})

// Check permissions
if (accessControl.hasPermission(user, "order:create")) {
  // User can create orders
}

Message Context

Message context allows you to pass metadata with your messages:

import { createMessageContext } from "@magicbutton.cloud/messaging"

// Create a message context
const context = createMessageContext({
  source: "web-client",
  target: "order-service",
  auth: {
    token: "jwt-token",
    actor: {
      id: "user-123",
      type: "user",
      roles: ["customer"],
    },
  },
  metadata: {
    requestId: "req-123",
    sessionId: "session-456",
  },
  traceId: "trace-789",
})

// Use the context in a request
const orderDetails = await client.request("getOrderDetails", { orderId: "order-123" }, context)

Documentation

API Reference

The full API reference is included in the package:

# View the documentation after installing
open node_modules/@magicbutton.cloud/messaging/docs/api/index.html

You can also view it online at https://code.magicbutton.cloud

Core Functions

  • createContract(options): Create a contract with events, requests, and errors
  • createEventMap(schemas): Create an event schema map
  • createRequestSchemaMap(schemas): Create a request schema map
  • createErrorMap(errors): Create an error map
  • createMessageContext(context): Create a message context
  • createTransportAdapter(transport): Create a transport adapter

Access Control

  • createSystem(system): Create a system definition
  • createRole(role): Create a role definition
  • createActor(actor): Create an actor
  • createAccessControl(system): Create an access control instance

Classes

  • Client: Client for sending requests and subscribing to events
  • Server: Server for handling requests and publishing events
  • InMemoryTransport: In-memory transport adapter for testing

Examples

Basic Usage

import * as z from "zod"
import { 
  createContract, 
  createEventMap, 
  createRequestSchemaMap, 
  InMemoryTransport, 
  Client, 
  Server 
} from "@magicbutton.cloud/messaging"

// Define contract
const contract = createContract({
  events: createEventMap({
    greeting: z.object({ message: z.string() }),
  }),
  requests: createRequestSchemaMap({
    sayHello: {
      requestSchema: z.object({ name: z.string() }),
      responseSchema: z.object({ greeting: z.string() }),
    },
  }),
})

// Set up server
const serverTransport = new InMemoryTransport()
const server = new Server(serverTransport)
await server.start("memory://hello-service")

server.handleRequest("sayHello", async (payload) => {
  return { greeting: `Hello, ${payload.name}!` }
})

// Set up client
const clientTransport = new InMemoryTransport()
const client = new Client(clientTransport)
await client.connect("memory://hello-service")

// Send request
const response = await client.request("sayHello", { name: "World" })
console.log(response.greeting) // "Hello, World!"

Custom Transport

import { TransportAdapter, MessageContext } from "@magicbutton.cloud/messaging"

class HttpTransport implements TransportAdapter {
  private baseUrl: string = ""
  private connected: boolean = false
  private eventHandlers = new Map()
  private requestHandlers = new Map()
  private eventSource: EventSource | null = null
  
  async connect(connectionString: string): Promise<void> {
    this.baseUrl = connectionString
    this.connected = true
    
    // Set up SSE for events
    this.eventSource = new EventSource(`${this.baseUrl}/events`)
    this.eventSource.onmessage = (event) => {
      const { type, payload, context } = JSON.parse(event.data)
      const handlers = this.eventHandlers.get(type)
      if (handlers) {
        handlers.forEach(handler => handler(payload, context))
      }
    }
  }
  
  async disconnect(): Promise<void> {
    if (this.eventSource) {
      this.eventSource.close()
    }
    this.connected = false
  }
  
  getConnectionString(): string {
    return this.baseUrl
  }
  
  isConnected(): boolean {
    return this.connected
  }
  
  async emit(event: string, payload: any, context?: MessageContext): Promise<void> {
    await fetch(`${this.baseUrl}/events`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ type: event, payload, context }),
    })
  }
  
  on(event: string, handler: (payload: any, context: MessageContext) => void): void {
    if (!this.eventHandlers.has(event)) {
      this.eventHandlers.set(event, new Set())
    }
    this.eventHandlers.get(event).add(handler)
  }
  
  off(event: string, handler: (payload: any, context: MessageContext) => void): void {
    const handlers = this.eventHandlers.get(event)
    if (handlers) {
      handlers.delete(handler)
    }
  }
  
  async request(requestType: string, payload: any, context?: MessageContext): Promise<any> {
    const response = await fetch(`${this.baseUrl}/requests/${requestType}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ payload, context }),
    })
    
    return response.json()
  }
  
  handleRequest(requestType: string, handler: (payload: any, context: MessageContext) => Promise<any>): void {
    this.requestHandlers.set(requestType, handler)
  }
  
  async login(credentials: any): Promise<any> {
    const response = await fetch(`${this.baseUrl}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credentials),
    })
    
    return response.json()
  }
  
  async logout(): Promise<void> {
    await fetch(`${this.baseUrl}/auth/logout`, { method: 'POST' })
  }
}

Access Control Example

import { 
  createSystem, 
  createRole, 
  createAccessControl, 
  createActor 
} from "@magicbutton.cloud/messaging"

// Define a system
const documentSystem = createSystem({
  name: "document-system",
  resources: ["document", "folder", "comment"],
  actions: ["create", "read", "update", "delete", "share"],
  roles: [
    createRole({
      name: "admin",
      permissions: ["document:*", "folder:*", "comment:*"],
    }),
    createRole({
      name: "editor",
      permissions: ["document:read", "document:update", "document:create", "comment:*"],
      extends: ["viewer"],
    }),
    createRole({
      name: "viewer",
      permissions: ["document:read", "comment:read"],
    }),
  ],
})

// Create access control
const accessControl = createAccessControl(documentSystem)

// Create actors
const adminUser = createActor({
  id: "user-1",
  type: "user",
  roles: ["admin"],
})

const editorUser = createActor({
  id: "user-2",
  type: "user",
  roles: ["editor"],
})

const viewerUser = createActor({
  id: "user-3",
  type: "user",
  roles: ["viewer"],
})

// Check permissions
console.log(accessControl.hasPermission(adminUser, "document:delete")) // true
console.log(accessControl.hasPermission(editorUser, "document:delete")) // false
console.log(accessControl.hasPermission(editorUser, "document:update")) // true
console.log(accessControl.hasPermission(viewerUser, "document:read")) // true
console.log(accessControl.hasPermission(viewerUser, "document:update")) // false

// Get all permissions
console.log(accessControl.getPermissions(editorUser))
// ["document:read", "document:update", "document:create", "comment:read", "comment:create", "comment:update", "comment:delete"]

Error Handling

import * as z from "zod"
import { 
  createContract, 
  createRequestSchemaMap, 
  createErrorMap, 
  InMemoryTransport, 
  Client, 
  Server 
} from "@magicbutton.cloud/messaging"

// Define contract with errors
const contract = createContract({
  events: {},
  requests: createRequestSchemaMap({
    getUserById: {
      requestSchema: z.object({ id: z.string() }),
      responseSchema: z.object({ 
        id: z.string(),
        name: z.string(),
        email: z.string().email(),
      }),
    },
  }),
  errors: createErrorMap({
    USER_NOT_FOUND: { code: "USER_NOT_FOUND", message: "User not found", status: 404 },
    INVALID_USER_ID: { code: "INVALID_USER_ID", message: "Invalid user ID format", status: 400 },
  }),
})

// Set up server
const serverTransport = new InMemoryTransport()
const server = new Server(serverTransport)
await server.start("memory://user-service")

// Mock user database
const users = [
  { id: "user-1", name: "John Doe", email: "john@example.com" },
]

server.handleRequest("getUserById", async (payload) => {
  const { id } = payload
  
  // Validate ID format
  if (!id.startsWith("user-")) {
    throw new Error("INVALID_USER_ID")
  }
  
  // Find user
  const user = users.find(u => u.id === id)
  if (!user) {
    throw new Error("USER_NOT_FOUND")
  }
  
  return user
})

// Set up client
const clientTransport = new InMemoryTransport()
const client = new Client(clientTransport)
await client.connect("memory://user-service")

// Successful request
try {
  const user = await client.request("getUserById", { id: "user-1" })
  console.log("User found:", user)
} catch (error) {
  console.error("Error:", error)
}

// Error handling - User not found
try {
  const user = await client.request("getUserById", { id: "user-999" })
  console.log("User found:", user)
} catch (error) {
  console.error("Error:", error.message) // "USER_NOT_FOUND"
}

// Error handling - Invalid ID
try {
  const user = await client.request("getUserById", { id: "invalid-id" })
  console.log("User found:", user)
} catch (error) {
  console.error("Error:", error.message) // "INVALID_USER_ID"
}

React Integration

import React, { useState, useEffect } from 'react'
import { Client, InMemoryTransport, createMessageContext } from '@magicbutton.cloud/messaging'
import { userServiceContract } from './contract'

// Create a client
const transport = new InMemoryTransport()
const client = new Client(transport, {
  clientId: "web-client",
  autoReconnect: true,
})

// React hook for using the messaging client
function useMessagingClient() {
  const [isConnected, setIsConnected] = useState(false)
  const [error, setError] = useState<Error | null>(null)
  
  useEffect(() => {
    // Connect to the server
    client.connect("memory://user-service")
      .then(() => setIsConnected(true))
      .catch(err => setError(err))
    
    // Listen for status changes
    const unsubscribe = client.onStatusChange((status) => {
      setIsConnected(status === 'connected')
    })
    
    // Clean up
    return () => {
      unsubscribe()
      client.disconnect()
    }
  }, [])
  
  return { client, isConnected, error }
}

// Example component using the hook
function UserProfile({ userId }) {
  const { client, isConnected, error } = useMessagingClient()
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [userError, setUserError] = useState(null)
  
  useEffect(() => {
    if (!isConnected) return
    
    setLoading(true)
    
    // Create context with auth info
    const context = createMessageContext({
      auth: {
        token: localStorage.getItem('token'),
      },
    })
    
    // Fetch user data
    client.request('getUserById', { id: userId }, context)
      .then(userData => {
        setUser(userData)
        setLoading(false)
      })
      .catch(err => {
        setUserError(err.message)
        setLoading(false)
      })
  }, [userId, isConnected])
  
  if (!isConnected) return <div>Connecting to server...</div>
  if (error) return <div>Connection error: {error.message}</div>
  if (loading) return <div>Loading user data...</div>
  if (userError) return <div>Error loading user: {userError}</div>
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
      <button onClick={() => {
        client.request('updateUser', {
          id: userId,
          name: user.name + ' (Updated)',
        })
        .then(updatedUser => setUser(updatedUser))
      }}>
        Update Name
      </button>
    </div>
  )
}

Contributing

We welcome contributions to Magic Button Messaging! Please see our contributing guidelines for more information.

License

Magic Button Messaging is licensed under the MIT License. See the LICENSE file for more information.

1.2.0

5 months ago

1.1.3

6 months ago

1.1.2

6 months ago

1.1.1

6 months ago

1.1.0

6 months ago

1.0.10

6 months ago

1.0.9

6 months ago

1.0.8

6 months ago

1.0.7

6 months ago

1.0.6

6 months ago

1.0.5

6 months ago

1.0.4

6 months ago

1.0.3

6 months ago

0.1.0

6 months ago

1.0.2

6 months ago

1.0.0

6 months ago