@ikaru5/heimdall-controller
v0.6.1
Published
Structure your REST requests by communicating with contracts.
Maintainers
Readme
Heimdall Controller
Structure your REST requests by communicating with Heimdall Contracts. Target is to encapsulate your asynchronous AJAX and WebSocket requests and responses for better maintainability.
- Installation
- General Usage - Sending Data
- General Usage - Receiving Data
- Example Backend Implementations
- CustomConnection/WebSockets/ActionCable setup example
- Contributing
- Contributors and Contact
- Copyright
Philosophy
React is a library, not a framework - frontends grow fetch calls in the wildest places, each with the same boilerplate. Heimdall exists to take that gray mass away and give the frontend ordering structures instead: controllers route inbound messages, operations hold the logic, contracts carry the data. It is an application bus for ONE backend/frontend pair that is developed, versioned and deployed together - not a general-purpose protocol between services, and deliberately not a dogmatic REST replacement. Order and simplicity are the highest good.
Dispatching is fire-and-forget by design: responses and server pushes arrive as messages. The correlation metadata (see below) exists so the application can manage its own in-flight commands - it does not change the semantics.
Installation
npm install @ikaru5/heimdall-controllerHeimdall Controller has no runtime dependencies. It is designed to work hand in hand with @ikaru5/heimdall-contract, but it is not coupled to it - see Contract interface.
Envelope, correlation and hooks
Heimdall is deliberately fire-and-forget: dispatch() sends a command, responses and
server pushes arrive as messages routed to your controllers. Since 0.6.0 the envelope
carries enough information for the application to track its own commands:
- Outgoing:
{version, messageId, receiver, payload}-dispatch()returns themessageId. - Inbound responses echo the id as
correlatesTo; server-initiated pushes carry none. heimdall.onDispatch(cb)/heimdall.onInbound(cb)observe every outgoing command and every inbound package (unsubscribe function returned). Typical use: a pending-commands registry that derives spinner state, instead of every operation toggling a global flag.- Transport failures arrive as a synthetic
Heimdall.transportFailedmessage withcorrelatesToset - network problems use the same mechanics as everything else.
Backends echo correlatesTo by copying the incoming messageId onto every dispatch
they answer with (one line in a base endpoint).
Contract interface
Heimdall Controller never imports a contract library. It duck-types against three methods:
| Method | Used when | Purpose |
| --- | --- | --- |
| toObject() | dispatching | extracts the JSON payload from a dispatched contract (plain objects pass through unchanged) |
| assign(payload) | receiving | fills a fresh instance of the contract class configured on an action |
| isValid(context?) | receiving | validates the instance after assignment (skipped with validate: false) |
Any class implementing these works: @ikaru5/heimdall-contract in any version, or your own.
This keeps the controller free of version coupling - your app decides which contract
library (and version) to use.
Create a heimdall.js file as an initializer with following content and import it in your application.js.
import heimdall, { HeimdallController } from "@ikaru5/heimdall-controller"
// import "./controllers/user-controller" later you will import your controllers here
import InitContract from "./contracts/application/init-contract" // create this contract if you need an initialization request
new HeimdallController() // prepared controller with two endpoints: HeimdallController.csrf and HeimdallController.error
const initApplicationData = () => {
const contract = new InitContract()
// if your contract has some fields like data from localDB, you can set them here
contract.dispatch()
}
heimdall.init({
path: "/api",
handleCSRF: true,
afterCSRF: initApplicationData
})Notice: InitContract is optional, but very helpful to load user data. So most applications will need it.
Configuration
You can configure heimdall controller with following options:
heimdall.init({
path: "/api", // HTTP path to your backend
port: 1234, // HTTP port to your backend, will try to detect automatically if not set
handleCSRF: true, // enable CSRF protection handling
afterCSRF: initApplicationData, // callback after initial CSRF handshake
host: "localhost", // Host address to your backend, will try to detect automatically if not set
protocol: "HTTP", // Protocol to your backend, everthing is HTTP until you define a custom connection
// for WebSockets like ActionCable
useCustomConnection: true, // enable custom connection handling
connectCustomConnection: createActionCableConnectionCallback,
disconnectAllCustomConnections: disconnectAllActionCableConnectionsCallback,
disconnectCustomConnection: disconnectActionCableConnectionCallback
})General Usage - Sending Data
Your backend will receive the following data:
{
"receiver": "User.Show",
"csrf": "TOKEN",
"payload": {
"id": 1
}
}There are simple implementation examples.
Dispatching a contract
import heimdall from "@ikaru5/heimdall-controller"
import UserBaseContract from "../user-contract.js" // your contract base class
const contract = new UserBaseContract() // create a contract
contract.id = this.id // set some data, you can also use assign() method
heimdall.dispatch(contract, {receiver: "User.Show"}) // dispatch the contractIf your contract has a default receiver or should be dispatched to only one receiver, you can define a method in the contract.
Example InitContract
import Contract from "../contract" // your contract base class
import heimdall from "@ikaru5/heimdall-controller"
// if you wonder: yes! this contract has no schema since it does send any data and acts as a request like GET
class InitContract extends Contract {
dispatch() {
heimdall.dispatch(this, {receiver: "Application.Init"})
}
}
export default InitContractLike this init contract you might have a lot of endpoints without any data to send.
You don't need to define this empty contracts, you can use the Contract class directly.
import Contract from "../contract" // your contract base class
import heimdall from "@ikaru5/heimdall-controller"
const contract = new Contract() // create a contract
heimdall.dispatch(contract, {receiver: "Application.Init"})Dispatch API
You can override some defaults on dispatching a contract. Send it to another host or path for example.
heimdall.dispatch(
contract,
{
receiver: "User.Show",
path: "another-api",
files: [UPLOADED-FILES],
port: 1234,
host: "localhost"
}
) General Usage - Receiving Data
Your Backend has to send the data in a format like this:
{
"receiver": "User.Show",
"csrf": "TOKEN",
"payload": {
"id": 1,
"name": "John Doe"
},
"priority": 1
}Higher priority number means that contracts are passed to frontend controller endpoints first.
There are simple implementation examples.
Creating a controller
import { ControllerBase } from "@ikaru5/heimdall-controller" // import the base class
// import some callback functions here
import { signupSuccessOperation } from "../../operations/user/signup"
import { showSuccessOperation } from "../../operations/user/show"
import { loginSuccessOperation } from "../../operations/user/login"
import { genericOperation } from "../../operations/user/generic"
import { logoutSuccessOperation } from "../../operations/user/logout"
import { failedOperation } from "../../operations/user/failed"
import UserBaseContract from "../contracts/user/base"
import GenericContract from "../contracts/generic-contract"
// create a controller class and define actions
class UserController extends ControllerBase {
static name = "UserController" // only required if you minifiy your code
static actions = [
{ name: "failed", contract: GenericContract, to: failedOperation, validate: false },
{ name: "showSuccess", contract: UserBaseContract, to: showSuccessOperation, onInvalid: genericOperation, context: "show" },
{ name: "deleteSuccess", controller: "SomeOtherControllerName", to: genericOperation },
{ name: "signupSuccess", to: signupSuccessOperation },
{ name: "loginSuccess", to: loginSuccessOperation },
"logoutSuccess"
]
// you can also define actions as methods
logoutSuccess({contract, receivedPackage}) {
logoutSuccessOperation(contract, receivedPackage) // for sure you dont need to call external functions here
}
}
export default new UserController() // export a new instance of the controllerIMPORTANT NOTICE: In this example an instance is exported, so by importing it in your initializer the controller is already active.
The callback functions and controller methods will be called with "contract" (if you defined one) and "receivedPackage" with raw data.
Properties of Actions:
@property {string} name - action name
@property {string} [controller] - overwrite name of controller
@property {Object} [contract] - assign received payload to an instance of this contract class
@property {function} [to] - callback where action should be passed to
@property {function} [onInvalid] - callback where action should be passed to if contract is invalid
@property {boolean} [validate] - validate contract, default: true
@property {boolean} [context] - validation context, will be passed to contract.isValid()Example Backend Implementations
CustomConnection/WebSockets/ActionCable setup example
At the moment a universal solution for WebSockets and other Custom Connections has to be found. For now you can use the following example to setup ActionCable.
First of all you have to create a consumer (generated by Rails).
import { createConsumer } from "@rails/actioncable"
export default createConsumer()Then you have to register the callbacks in your init.
import consumer from "../channels/consumer"
// ...
heimdall.init({
useCustomConnection: true,
connectCustomConnection: ({ params, onConnected, onDisconnected, onReceive }) => {
const mixin = params.mixin || {}
delete params.mixin
return consumer.subscriptions.create(params, {
connected() {
onConnected(params)
},
disconnected() {
onDisconnected(params)
},
received(data) {
onReceive({ data, params, protocol: "ActionCable" })
},
...mixin
})
},
disconnectAllCustomConnections: () => {
consumer.disconnect()
},
disconnectCustomConnection: ({ params, collection }) => {
collection.find(e => JSON.stringify(e.params) === JSON.stringify(params))?.connection?.unsubscribe()
}
})The idea of ActionCable is having multiple channels, so it doesn't work as a receiver. This is how a BoardChannel could look like in a collaborative whiteboard application:
class BoardChannel < ApplicationCable::Channel
def subscribed
stream_from "board_channel_#{params[:board_id]}"
end
def send_collaboration(data)
contract = Heimdall::Contract::Dispatch.new
contract.priority = 0
contract.payload = data
contract.receiver = "Board.collaborationUpdated"
ActionCable.server.broadcast "board_channel_#{params['board_id']}", contract.to_json
end
endContributing
- Fork it (https://github.com/ikaru5/heimdall-controller/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors and Contact
If you have ideas on how to develop heimdall more or what features it is missing, I would love to hear about it!
- @ikaru5 Kirill Kulikov - creator, maintainer
Copyright
Copyright (c) 2020 Kirill Kulikov [email protected]
heimdall-controller is released under the MIT License.
