Message Bridge (JS)
A CQRS Hook.
Extreme simplified Commands, Queries and Events for applications UI.
The Bridge enabled sending Commands, Queries and Event between a frontend and backend
through a websocket.
The pattern will remove the needs for controllers* and hook directly into Commands, Queries and Events in the backend.
*This doesn't mean you don't want controller or need them to expose you API for other sources
![Overview-diagram](https://github.com/alfnielsen/MessageBridgeJS/raw/HEAD/docs/Overview-diagram.jpg)
Examples
Demo (CodeSandbox):
const bridge = new MessageBridgeService("ws://localhost:8080")
await bridge.connect()
const command = { name: "Remember to", priority: "low" } as ICreateTodo
const id = await bridge.sendCommand({
module: "todo",
name: "CreateTodo",
payload: command,
})
console.log(`Todo created with id: ${id}`)
const todo = await bridge.sendQuery({
name: "GetTotoItem",
payload: { id: 25 },
})
console.log(`Todo with id:25 has title: ${todo.title}`)
const unsub = bridge.subscribeEvent({
name: "TotoItemUpdated",
onEvent(todo: ITodoItem) {
},
})
Tracked requests:
const { response, request, requestMessage } = await bridge.sendQueryTracked({
name: "GetTotoItem",
payload: { id: 25 },
})
console.log(
`${requestMessage.type} ${requestMessage.name}: Todo with id ${request.id} has title '${response.title}'`,
)
Advanced tracked requests:
const query = bridge.createQuery({
name: "GetTotoItem",
payload: { id: 25 },
})
const { response, request, requestMessage } = await query.sendTracked()
const response = await query.send()
const {
trackId,
requestMessage,
requestOptions,
send,
sendTracked,
cancel,
} = bridge.createQuery({
name: "GetTotoItem",
payload: { id: 25 },
})
Multiple parallel requests:
let promise1 = bridge.sendCommand({
name: "CreateTodo",
payload: command,
})
let promise2 = bridge.sendCommand({
name: "CreateTodoNode",
payload: commandNote,
})
const [todo, note] = await Promise.all([promise1, promise2])
Multiple parallel tracked requests:
let requestList = [
{ name: "GetTotoItem", payload: { id: 25 } },
{ name: "GetNote", payload: { search: "remember" } },
{...}
]
let promiseList = requestList.map(({name, payload}) =>
bridge.sendQueryTracked({ name, payload })
)
const responses = await Promise.all(promiseList)
responses.forEach(({ response, request, requestMessage }) => {
if(requestMessage.name === "GetTotoItem"){
console.log(`Todo: ${response.title}`),
}
if(requestMessage.name === "GetNote"){
console.log(`Note: ${response.title}`)
}
})
See the tests in "/tests" (github) for examples of all features.
Install
> npm i message-bridge-js
> yarn add message-bridge-js
Backend
The backend must handle bridge messages and respond a message with the correct tractId and type.
Type map:
- Command => CommandResponse
- Query => QueryResponse
- Event => No response
- <= Server Event can also be send from the backend to the frontend (use type Event)
An example of a backend implementation, can be found in the InMemoryClientSideServer (in "/services" folder)
Note it uses the helper methods in MessageBridgeHelper.ts to create the messages. (Primary createMessage)
At some point in the future there will most likely be create official backend implementations for NodeJs and .NET Core.
It's suggested to use some kind of CQRS pattern in the backend as well (Fx MediatR for asp.net core)
Events (Responsive behavior)
The Bridge enabled the frontend to listen to events send from processes in the backend.
This enables the frontend to be responsive to changes in the backend, instead of polling.
Technologies (dependencies)
The primary implementation uses @microsoft/signalr
The base bridge class uses uuid
Bridge versions
The primary and most tested version is the SignalR version: SignalRMessageBridgeService
But there is also a websocket version: WebSocketMessageBridgeService
And an ClientSideMessageBridgeService that uses the InMemoryClientSideServer to get started fast without a backend.
Internal - how it does it
The bridge sends BridgeMessage that contains a:
Name of the command, query or event
Payload that contains the actual Command/Query or payload for en Event for Error
TrackId that is used to catch responses
Type that are one of
- Command
- Query
- CommandResponse
- QueryResponse
- Event
- Error
The frontend will add a trackId which in the backend will be added in the responses.
The message is sent through a websocket and deserialize its payload in the backend.
The backend handles the Command/Query/Event and create a response message.
Then sent the response message back to the frontend including correct type and the trackId
The server can also send events to the frontend. (without any prior request)
![Overview-diagram](https://github.com/alfnielsen/MessageBridgeJS/raw/HEAD/docs/Command-flow.jpg)
Flow diagram
![Flow-diagram](https://github.com/alfnielsen/MessageBridgeJS/raw/HEAD/docs/CommandServiceDiagram.jpg)
Bridge commands:
Most used commands:
- sendCommand
- sendQuery
- sendEvent (It's called send because it will send it to the backend - not fire it! )
- subscribeEvent
- connect
For tracking (error handling and cancellation) use 'create' and then 'send' or 'sendTracked' later.
- createCommand
- createQuery
- createEvent
Tracked versions of requests (They resolve the promise with a full RequestResponse<TRequest,TResponse>)
It includes the request and response messages (So you can track which request data what used to get the response)
- sendCommandTracked
- sendQueryTracked
Helper commands (advanced use)
- createCommandMessage
- createQueryMessage
- createEventMessage
- createMessage
- createMessageFromDto
Protected commands (advanced use)
- onMessage
- handleIncomingMessage
- receiveEventMessage
- internalSendMessage
All features are fully tested
jest tests:
PASS tests/bridgeOptions.test.ts
PASS tests/fullFlow.test.ts
PASS tests/logger.test.ts
PASS tests/sendEvent.test.ts
PASS tests/intercept.test.ts
PASS tests/requestOptions.test.ts
PASS tests/sendQuery.test.ts
PASS tests/sendCommand.test.ts
PASS tests/handleErrors.test.ts
PASS tests/parallel.test.ts
Test Suites: 10 passed, 10 total
Tests: 53 passed, 53 total
Async vs Callback
You can use the bridge in two ways:
- with async/await (default and recommended)
- with callbacks.
bridge.sendCommand({
name: "CreateTodo",
payload: command,
onSuccess(id) {
console.log(`Todo created with id: ${id}`)
},
onError(error) {
console.log(`Error: ${error}`)
},
})
const id = await bridge.sendCommand({
name: "CreateTodo",
payload: command,
onError(error) {
},
})
Handle errors
There are a couple of ways to handle errors.
By default the bridge will follow normal promise flow for non-tracked requests (using try/catch)
try {
const response = await bridge.sendCommand({
name: "CreateTodo",
payload: command,
})
} catch (error) {
}
But this can be changed with the option: avoidThrowOnNonTrackedError
Tracked requests will NOT by default to throw errors, but instead include error in the RequestResponse
If an error is thrown or send from the backend (using the Error type with trackId),
the response will be undefined and the error will be set.
This behavior can be changed with the option: throwOnTrackedError
const { response, error, isError, errorMessage } = await bridge.sendCommandTracked({
name: "CreateTodo",
payload: command,
})
Cancellation
You can cancel a tracked request by using the cancel method. (The server can also send cancelled: true in teh response messsage)
const cmd = await bridge.createCommand({
name: "CreateTodo",
payload: command,
})
cmd.send().then((response) => {
})
const { cancelled } = await cmd.sendTracked()
Bridge options
bridge.setOptions({
timeout: 10_000,
avoidThrowOnNonTrackedError: true,
onError: (err, eventOrData) => {
},
logMessageReceived: true
logSendingMessage: true
})
export type BridgeOptions = {
onMessage?: (msg: Message) => void
onSend?: (msg: Message) => void
onError?: (err?: unknown , eventOrData?: unknown) => void
onSuccess?: (msg: RequestResponse) => void
onClose?: (err?: unknown , eventOrData?: unknown) => void
onConnect?: () => void
onCancel?: (msg: Message) => void
interceptSendMessage?: (msg: Message) => Message
interceptReceivedMessage?: (msg: Message) => Message
interceptCreatedMessageOptions?: (msg: CreatedMessage) => CreatedMessage
interceptCreatedEventMessageOptions?: (msg: CreatedEvent) => CreatedEvent
avoidThrowOnNonTrackedError?: boolean
throwOnTrackedError?: boolean
timeout?: number
resolveCancelledNonTrackedRequest?: boolean
sendCancelledRequest?: boolean
callOnErrorOnCancelledRequest?: boolean
callOnSuccessOnCancelledRequest?: boolean
allowResponseOnCancelledTrackedRequest?: boolean
timeoutFromBridgeOptionsMessage?: (ms: number) => string
timeoutFromRequestOptionsMessage?: (ms: number) => string
keepHistoryForReceivedMessages?: boolean
keepHistoryForSendingMessages?: boolean
logger?: (...data: any[]) => void
logParseIncomingMessageError?: boolean
logParseIncomingMessageErrorFormat?: (err: unknown) => any[]
logMessageReceived?: boolean
logMessageReceivedFormat?: (msg: Message) => any[]
logSendingMessage?: boolean
logSendingMessageFormat?: (msg: Message) => any[]
logMessageReceivedFilter?: undefined | string | RegExp
logSendingMessageFilter?: undefined | string | RegExp
}
Request options
You can set options for each request.
- onSuccess // not recommended for most use cases
- onError // not recommended for most use cases
- timeout // set timeout for this request (overrides bridge timeout*)
- module // info that the server can use
// advanced options:
- resolveCancelledForNonTracked?: boolean
- sendCancelled?: boolean
- callOnErrorOnCancelledRequest?: boolean
- callOnSuccessOnCancelledRequest?: boolean
- allowResponseOnCancelled?: boolean
*The bridge options has NO timeout as default
bridge.sendCommand({
name: "CreateTodo",
payload: command,
timeout: 10_000,
})
Getting started
You can use the included ClientSideMessageBridgeService and InMemoryClientSideServer
to get started quickly (and later change the bridge to the SignalR or Websocket version).
See the tests for full examples in the "/tests" folder (Github).
export enum RequestType {
GetTodoItemQuery = "GetTodoItemQuery",
UpdateTodoItemCommand = "UpdateTodoItemCommand",
TodoItemUpdated = "TodoItemUpdated",
}
export type Store = {
todos: TodoItem[]
}
export type TodoItem = {
id: number
title: string
}
export type UpdateTodoItemCommandResponse = {
done: boolean
}
export type UpdateTodoItemCommand = {
id: number
title: string
throwError?: boolean
sleep?: number
}
export type GetTodoItemQueryResponse = {
items: TodoItem[]
}
export type GetTodoItemQuery = {
search: string
throwError?: boolean
sleep?: number
}
import { InMemoryClientSideServer } from "message-bridge-js"
import { RequestType, Store } from "./TestInterfaces"
let server = new InMemoryClientSideServer<Store>()
server.store.todos = [
{ id: 1, title: "todo1" },
{ id: 2, title: "todo2" },
{ id: 3, title: "todo3" },
]
server.addCommand(RequestType.UpdateTodoItemCommand, ({ event, response }) => {
const todo = server.store.todos.find((t) => t.id === opt.requestMessage.payload.id)
if (todo) {
todo.title = opt.requestMessage.payload.title
}
setTimeout(() => {
event(RequestType.TodoItemUpdated, {
id: opt.requestMessage.payload.id,
title: opt.requestMessage.payload.title,
})
}, 10)
response({ done: true })
})
server.addQuery(RequestType.GetTodoItemQuery, ({ response }) => {
const items = server.store.todos.filter((t) =>
t.title.toLowerCase().includes(opt.requestMessage.payload.search.toLowerCase()),
)
response({ items })
})
export { server as testServer }
import { ClientSideMessageBridgeService } from "message-bridge-js"
import { RequestType } from "./TestInterfaces"
import { server } from "./TestServer"
const bridge = new ClientSideMessageBridgeService("ws://localhost:1234")
bridge.server = server
const response = await bridge.sendQuery<GetTodoItemQuery, GetTodoItemQueryResponse>({
name: RequestType.GetTodoItemQuery,
payload: {
search: "todo",
},
})
await bridge.sendCommand<UpdateTodoItemCommand, UpdateTodoItemCommandResponse>({
name: RequestType.UpdateTodoItemCommand,
payload: {
id: 1,
title: "todo1 changed",
},
})
React hook example
The bridge can be used with any frontend framework, but here is an example of how to use it with React hooks.
function useGetTodo(id: number): Promise<TodoItem | undefined> {
const [todo, setTodo] = useState<TodoItem | undefined>()
useEffect(() => {
const fetchTodoes = async () => {
const todos = await bridge.sendQuery<GetTodoItemQuery, GetTodoItemQueryResponse>({
name: RequestType.GetTodoItemQuery,
payload: { id },
})
setTodo(todos?.[0])
}
const unsub = bridge.subscribeEvent<TodoItemUpdatedEvent>({
name: RequestType.TodoItemUpdated,
onEvent: (todoEvent) => {
if (event.id === id) {
setTodo((todo) => ({
...todo,
title: todoEvent.title,
}))
}
},
})
return () => unsub()
}, [id])
return todo
}
function useUpdateTodo() {
const updateTodo = useCallback(async (id: number, title: string) => {
await bridge.sendCommand<UpdateTodoItemCommand, UpdateTodoItemCommandResponse>({
name: RequestType.UpdateTodoItemCommand,
payload: { id, title },
})
}, [])
return updateTodo
}