DDAPPS is an event-driven / messages Deno framework. It's adapted to build living applications (opposed to the conventional request/response service) like a distributed database (see the example).
- Docker Hub - Server
- Docker Hub - CLI
- Binaries are store on CircleCI in the
DenoBuildServer
&DenoBuildCLI
jobs ("artifacts" panel)
I've used the async nature of JavaScript in order to build distributed systems (see the examples, there are a distributed key-value store like etcd and a blockchain). Deno has great conventional web frameworks based on request/response. I've developed ddapps as an async, distributed, messages oriented framework allowing to build server applications that run and behave without any connected user. This framework is designed with heavy typing in order to help the developer, and provides base components required to build distributed systems.
Those components are:
- Client (SDK) to build your own integration
- CLI to interact with the system & bootstrap your project
- Networking to allow cluster communication
- Monitoring to observe the system
- API for clients' requests handling an routing
- Logging to known what's happening
- Peer as a core component
Typing is a first class citizen in ddapps. Pretty much everything is "strongly" typed, and the framework relies on it in order to offer a powerful but friendly user experience.
This document offers a high level view of the framework, its philosophy and how to use it. There is a technical documentation in the sources, along with concrete examples on how ddapps can be used for complex applications.
Ddapps works out of the box, even if it currently doesn't expose any meaningful feature by itself. This section uses the compiled binary, but you can find the associated Docker image. Here is the CLI Docker image.
Start the server by executing ddapps
. By default it uses the port 8080
.
# --console-messages activates console logging
# --debug will display all messages (only clients' requests if ommited)
$ ddapps --console-messages --debug
Interact with the server using the CLI. By default it targets localhost:8080
$ ddappsctl ping
You should get a response like
{
type: "ClientResponse",
source: "localhost",
destination: "Client",
payload: { token: "px2vydgrs7", type: "Pong", payload: 3, timestamp: 1647610732124 }
}
You can see the server logs
🔄 12 DDAPPS Messenger InitialMessage null
🔄 3 Net Peer DiscoveryResult {"success":false,"result":"Discovery is not activated","source":"discovery_disabled"}
🔄 2 Peer Logger LogMessage {"message":"Node is ready after discovery result"}
🔄 4571 Net Logger ClientConnectionOpen {"_conn":{},"_sock":{"readyState":1,"protocol":null},"_latency":0}
🔵 0 127.0.0.1-7 Api ClientRequest {"token":"aghl4yd21p","type":"Ping","payload":null,"timestamp":1647611045333}
🟢 3 Api 127.0.0.1-7 ClientResponse {"token":"aghl4yd21p","type":"Pong","payload":3,"timestamp":1647611045336}
🔄 0 Net Api ClientConnectionClose "127.0.0.1-7"
🔄 0 Net Logger ClientConnectionClose "127.0.0.1-7"
You can compile the server & CLI using Deno tasks
compile-ddapps
&compile-cli
.
We will use ddapps to get a remote machine's hostname using the ddapps CLI.
Create a file hostname.ddapps.ts
import { DDAPPS, Api, EMType, Message, EOpType, state as base } from "https://deno.land/x/[email protected]/mod.ts";
// Extend from the Api component to receive the client message
class HostnameApi extends Api {
// Override the EMType.ClientRequest handler
protected override [EMType.ClientRequest](message: Message<EMType.ClientRequest>) {
// Call the parent method for core mechanics
super.ClientRequest(message)
// Send a response of type Any with the machine hostname as a payload
this.response(message.payload.token, EOpType.Any, Deno.hostname());
}
}
// Bootstrap you DDAPPS application
new DDAPPS()
.use(HostnameApi) // Use your custom API instead of the base one
.run(base); // Start the application listening on the network
You don't have to compile it for now, just start the application with Deno CLI.
# --unstable is needed to call Deno.hostname()
$ deno run -A --unstable hostname.ddapps.ts
In another terminal, you can use the ddapps CLI to send a message Any
to the server.
$ deno run -A --unstable cli/ddapps.ts any
The compiled CLI is available on CircleCI so you don't have to clone the repo to use it. In this case just use
ddappsctl any
. You can find a link to the binaries at the top of this document.
You should get a response of which the payload is you machine's hostname
{
type: "ClientResponse",
source: "localhost",
destination: "Client",
payload: {
token: "hkth7g1fca",
type: "Any",
payload: "MacBook-Pro-de-Sofiane.local",
timestamp: 1647612904435
}
}
This basic example showed you how to bootstrap a ddapps application, extend a base component in order to add some logic, and use the CLI in order to interact with your application. So far we've only used pre-existing structures for types, enums and components. The rest of this document will show you how to create a more advanced ddapps application in order to interact with multiple components, and execute ddapps on multiple machines in order to leverage its distributed and message oriented nature.
The
ddappsctl
CLI exposes aninit
command that will bootstrap a workspace for you, but you should first understand the next sections before going for it.
This section will often use examples from the example
directory containing a key-value store (KV store) and a blockchain.
Some structures' names in ddapps are prefixed depending on their nature (not all are however)
- [I] Interfaces (e.g
IMessage
) - [E] Enumerations (e.g
EComponent
)
In the example, the KV store structures are also prefexied with KV
(e.g KVPeer
), and blockchains' ones are prefixed with C
or Chain
(e.g ChainPeer
).
The terms Node & Peer are equivalent and refer to the same concept
Messages are the building block allowing entities to exchange information (between components, but also remote peers or clients). In ddapps, messages have a type
, a payload
(which is typed depending on the type
of the message) a source
and a destination
, much like a TCP packet. The response you got in the Getting Started is a typical message of type ClientResponse
and a payload containing the information you requested.
interface IMessage<T> {
type: T;
source: string;
destination: EComponent | string;
payload: MPayload[T]; // MessagePayload
}
This IMessage interface is simplified for this section, refer to the typing section for the full description
At its core, ddapps relies on (async) events using Deno.CustomEvent
, disptachEvent
, addEventListener
& removeEventListener
. CustomEvent
is used for its capability to embed a payload. This payload is always a message matching the interface IMessage
. User is not supposed to deal with CustomEvent
since it's only a core transport object. Instead the framework exposes messages transparently.
You can find the documentation about core messages here.
A component is a singleton object that handles (non exclusively) messages. You can create as many as you need, in order to respect the separation of concerns. However there is a minimal set of components that are created out of the box by the DDAPPS factory (see the associated section below).
Net
for network connectivityPeer
as the main logical componentLogger
to make messages readableMonitor
to handle cluster operations & observabilityApi
to accept clients' requests & route them
In a ddapps, there can only be at least and only ONE instance of each of those base components or inherited versions. If you need to tune their behavior, extend the class and use yours. If you need to add unrelated components, inherit from the Messenger
class (see the associated section below).
You can extend any and all of those components for your own needs. They don't necessarily offer specific features but handle some important aspects of ddapps that you may want to fine tune. For example, if you want to extend the logic of adding a new node to the cluster, you may extend Peer
or Net
, but extending Api
won't allow you (out of the box) to handle the related events.
All components inherit directly or indirectly from the Messenger
base class. This base class subscribes the component to the correct messages, calls the correct handler, and exposes the Messenger#send
method in order to allow components to send typed messages.
Concretely, a messenger calls
addEventListener(Component)
in order to handle a message when they are the actual destination.
Components receive a message when they are the destination
of the message (i.e the class name is exactly the same as the destination
value of the message). They handle the message only if they have an associated handler (i.e a class method with a name matching the message type
).
// Peer is a core component provided by ddapps, used here as an example
class Peer extends Messenger {
// Message handlers are class methods accepting a typed message
protected [EMType.Ping](message: IMessage<EMType.Ping>) {
console.log("Received a ping");
}
}
The typing of messages helps you on the payload manipulation. The TypeScript signature is reliable and can tell you what is the exact nature of the payload (properties and their types).
You should always name handlers based on a message types enumeration (here it's
EMType
, the base message types enum).
To leverage the heavy typing used by ddapps, a .send()
method is accessible by all components (inherited from the Messenger
base class). The first argument must be a message typed derived from from EMType
. The second argument is the payload
and will only compile if its signature matches the message type. The third argument is the destination
of the message.
class Peer extends Messenger {
// Message handlers are class methods accepting a typed message
protected [EMType.Ping](message: IMessage<EMType.Ping>) {
this.sendLog("Messages of type Any are for demo purpose and should be avoided", message);
}
}
Here again, ddapps typing will help you build a safe payload by making you specify all its properties with their correct nature.
Messages can be sent to peers and clients by specifying an IP as the destination. Ddapps doesn't make much difference between a remote component and a local one. This allows you always use the same approach whether you exchange messages localy or remotely.
In the Getting Start section, the
HostnameApi
callsthis.response
. It's a wrapper around thethis.send
method that allows to type messages exchanged with clients.
All components have access a shared object named state
. It stores the application data such as configuration, networking resources, and any business logic you need. You can access it simply via this.state
.
class Peer extends Messenger {
// The state is passed to any messenger, no need to override the constructor
constructor(protected state: IState) {
this.state.ready = true;
}
}
Operations are like messages but for clients. A client sends a message ClientRequest
, but the end-user API is composed of an operation type, and an operation payload.
export interface IClientRequest<
ReqPayload extends IRequestPayload = IRequestPayload,
ReqType extends keyof ReqPayload = keyof ReqPayload,
> {
token: string;
type: ReqType;
payload: ReqPayload[ReqType];
timestamp: number;
}
ClientRequest
is a conventional message, but it also has a token
used to match requests & responses. The type
and payload
properties are typed based on EOpType
. It's the same mechanic than message typing but it prevents clients from sending system-oriented messages. It also allows the API
to segregate and handle clients' requests from system-oriented messages.
Typing is an important part of ddapps, and you will hardly extend it without taking the time to declare your own types. That may seem cumbersome but it's the mechanic that helps the developer to build an application without losing the track of what's manipulated. Apart from limiting the runtime errors, I've spent hours just trying to remember and understand what was the content of a payload. Typing is used to facilitate the development of a ddapps, and makes it extansible for you to build on top of it.
Heavy typing allows the .send()
methods to alert you when the payload does not match the passed type. You can also use the M
type in your handlers' declaration in order to understand the content of a message based on its type. Also, configure your IDE to use TypeScript correctly (Deno linter), in order to get advanced auto completion and documentation.
...
protected override [EMType.PeerConnectionAccepted](
message: M<EMType.PeerConnectionAccepted>,
) {
console.log(message.knownPeers); // OK
console.log(message.randomProperty); // KO this will not compile
}
...
There are 3 concepts that are typed
- The messages exchanged between the peers
- The operations exchanged between the client and the server (operation)
- The shared state
For more information on how to extend types when building with ddapps, see the usage section.
Messages are typed in the associated module. EMType
enumeration lists the messages types. IMPayload
uses EMType
to match a message to a typed payload. IMPayload
is generic and uses operations types in order to type the ClientRequest
, ClientNotification
& ClientResponse
messages;
export interface IMPayload<
ReqPayload extends IRequestPayload = IRequestPayload,
ResPayload extends IResponsePayload = IResponsePayload
> {
[EMType.LogMessage]: { message: string; };
}
Operations are typed in the associated module. EOpType
enumeration lists the operations types. IRequestPayload
& IResponsePayload
use EOpType
to match an operation to a typed payload. Note that ClientNotification
messages have a IResponsePayload
payload.
export interface IRequestPayload {
[EOpType.Crash]: null,
[EOpType.Any]: unknown;
[EOpType.Ping]: null;
[EOpType.Pong]: number;
[EOpType.MonOp]: IMonOp;
[EOpType.MonWatch]: IMonWatch;
}
The shared state is typed in the interface module. It's generic in order to be extended.
export interface IState<
ReqPayload extends IRequestPayload = IRequestPayload,
ResPayload extends IResponsePayload = IResponsePayload,
MPayload extends IMPayload<ReqPayload, ResPayload> = IMPayload<
ReqPayload,
ResPayload
>> {
ready: boolean;
net: {
requests: { [key: string]: string };
ready: boolean;
peers: DRemotePeerSet<ReqPayload, ResPayload, MPayload>,
clients: DClientSet<ReqPayload, ResPayload>
};
log: {
console: boolean;
exclude: (keyof MPayload)[];
last: number;
};
mon: {
requests: string[];
stats: { [key: string]: number };
watchers: { [key: string]: number };
loggers: string[];
};
}
This section does not offer a complete technical reference but instead describes some peripheral utilities at your disposal when building with ddapps.
DDAPPS class allows you to bootstrap your application without worrying of various subtlties. It's typed so it can handle your custom components, themselves handling custom messages.
The factory handles
- Instanciating and registering all necessary components, only once, in the correct order
- Performing dependency injection of the correct state in all components
- Starting the web server and accepting requests
- Sending an InitialMessage for components initialisation
You will mainly use DDAPPS.use()
in order to specify what components you want to instanciate in your application.
In the getting start, the last three lines perform this action. DDAPPS relies on method chaining to let you register multiple components (see the example). You must call DDAPPS.run()
at the end since it actually creates objects and starts the web server (so it's blocking).
// Bootstrap you DDAPPS application
new DDAPPS()
.use(HostnameApi) // Use your custom API instead of the base one
.run(base); // Start the application listening on the network
In the example you can find a typed example of DDAPPS
new DDAPPS<
IKVRequestPayload,
IKVResponsePayload,
IKVMPayload,
IKVState
>().use(KVLogger)
.use(KVApi)
.use(KVMonitor)
.use(Store)
.use(KVPeer)
.run(kvstate);
When running the application, you must provide a shared state. The Getting Started uses the default one, but you can also extend it to add your own properties. Pass it to the .run()
method and the state will be injected in all components you registered.
As mentioned in the "Concepts::Components" section, there can only be at least and only ONE of each base component or its derivative. You can create and use any number of custom components that inherit from Messenger
but it will still be a singleton. If you use a component that inherits one of the base components (Net
, Api
, Peer
, Monitor
, Logger
), the factory will use the one provided and not create the basic one. You should not loose their capabilities since you inherit their behavior.
In order to interact with your cluster, you will need to open a WebSocket on the correct endpoint, and send correctly formated messages (a JSON strings). Programmatically, it's handled by the client class. Like other extandable structure of ddapps, it's typed to you can add your own types of messages.
To add a new feature, inherit from the Client
class and add a public method (from the example)
export class KVClient extends Client<
IKVRequestPayload,
IKVResponsePayload,
IKVMPayload
> {
public kvget(key: string) {
return this.send(EKVOpType.KVGet, key);
}
}
The base client exposes the .send<T extends keyof ReqPayload>(type: T, payload: ReqPayload[T])
method. It will handles the build of the ClientRequest
message, handling the token
and matching the ClientResponse
correctly. For you to add a new operation, just expose the method and use .send()
with the correct operation type and payload.
Client.send()
returns a Promise<IMessage<EMType.ClientResponse, ReqPayload, ResPayload, MPayload>
. That means that you can call the operations' methods, and use .then(response => {})
to handle the server response for your own need. For example, the CLI will format and print it in stdout
. More information about the CLI in the next section.
If you want to extend the client behavior when receiving a message from the server, you can also override the message handlers ClientResponse
, ClientNotification
& InvalidClientRequestType
. This can be used to fine tune the requests life cycle at a lower level, but is not mandatory to handle your operations' results.
export class KVClient extends Client<
IKVRequestPayload,
IKVResponsePayload,
IKVMPayload
> {
protected override [EMType.ClientNotification](
message: KVM<EMType.ClientNotification>,
) {
super[EMType.ClientNotification](message);
if (message.payload.type === EKVOpType.KVWatch) {
const payload = message.payload.payload as unknown as ILog;
if (Object.keys(this._watchers).includes(payload.next.key)) {
this._watchers[payload.next.key](message);
}
}
}
}
The
Client
does not inherit from theMessenger
base class. But it works the same way, using message handlers & a typed.send()
method.
The CLI relies on the Client
(see the client section) and uses the Cliffy Command Deno package to parse arguments and generate documentation. It's extandable and you can find an implementation in the example.
Out of the box, you can ping a node, get & set monitoring (state) values, sent arbitrary data with any
, and crash
a node. In the cli
directory of the example you can see how to add custom commands. The CLI init command allows you to create a ddapps boilerplate in order to avoid the initial creation of files and structures.
Usage: ddappsctl
Version: 1.4.5
Description:
Interact with ddapps
Options:
-h, --help - Show this help.
-V, --version - Show the version number for this program.
Commands:
ping - Ping the cluster
mon - Monitor the cluster
any - Whatever you want
crash - Kill the node
init - Initialize repo for a ddapps application
To add a new command to the CLI, refer to the Cliffy documentation and create a Command
object. Make sure to not await
the command since it will make it blocking and execute in the current shell.
const put = new Command()
.description("Add a key-value pair")
.version("0.1.0")
.option("-a, --address <addr:string>", "HTTP endpoint", {
default: "localhost",
})
.option("-p, --port <port:number>", "HTTP port", { default: 8080 })
.option("-k, --key <key:string>", "Key part")
.option("-v, --value <value:string>", "Value part")
.action(async ({ address, port, key, value }: {
address: string;
port: number;
key: string;
value: string;
}) => {
// You may need to customize your own client to send specific messages
await new KVClient(address, port).co
.then((ops) => {
// First, the WebSocket connection is opened and you have access to a Client instance (ops)
return ops.kvput(key, value);
}).then(response => {
// When calling a client operation, the next promise resolves with the ClientResponse form the server
console.dir(response);
Deno.exit(0);
}).catch((err) => {
console.error(err);
Deno.exit(1);
});
});
Then you can import ddappsctl
and use method chaining to register your commands. When you want to actually expose your CLI and make it executable, use await
and call your cli.ts
script in your shell. Refer to the Cliffy doc for more information.
import { ddappsctl } from "ddapps/cli.ts";
// This allows your CLI to inherit the base ddappsctl commands
const customCli = ddappsctl.command("put", put);
// You can declare multiple commands in different files and start your CLI in a specific one (see example)
await customCli;
In order to test the async messages of your components, some helper are provided.
The function assertMessages
takes a list of messages that are expected to be sent by a component after it received a message. You may also want to extend a typed state and use the of()
function from the state.ts
module. You generally use it to type your custom typed state, and in your tests you directly import your custom state.
Deno.test("KVPeer::NewTerm::Accept", async () => {
const s: IKVState = {
...kvstate,
voteGrantedDuringTerm: true,
heartBeatInterval: 10,
term: 1,
};
// Instanciate the component you want to test
const component = new KVPeer(s);
// Type a message to send to the component
const message: KVM<EKVMType.NewTerm> = {
type: EKVMType.NewTerm,
destination: EKVComponent.KVPeer,
payload: {
term: 2,
},
source: "Source",
};
// Expect some messages fired by the component (not ordered due to the async nature of messages)
await assertMessages([
{
type: EKVMType.NewTermAccepted,
payload: {
term: 2,
},
source: EKVComponent.KVPeer,
destination: EComponent.Logger,
},
], message);
// You can also test the state after the message has been handled
assertEquals(s.term, 2);
assertEquals(s.voteGrantedDuringTerm, false);
// Don't forget to shutdown your component to avoid async leaks
component.shutdown();
});
This section describes step by step how to use and extend ddapps in order to build an application and leverage the various components. We will build a toy application but the key-value example can provide you a much elaborate use of the framework.
It's important to understand how to extend ddapps, but when you feel at ease, don't hesitate to use
ddappsctl init --name secret
in order to bootstrap the files and structures.
Our application is called secret, and will allow a user to store a secret on multiple machines. At the end we will connect 2 nodes, define a secret on one node and retrieve it on the other. As a convention, we will use the S
prefix on our various structures.
ddapps is currently using the
8080
port, so you will need Docker or VMs to start two nodes.
The steps involved are:
- Extend the state to add a
secret
property - Declare a
SetSecret
message to update the state - Implement the storage of the secret on the first node
- Implement the propagation of the secret on the second node
- Add the
SetSecret
&GetSecret
operations - Extend the
Api
to expose the feature - Extend the CLI to allow users to use the new feature
We start by creating all required typings to use them in the next steps. We type the messages, operations, and a custom state. Don't hesitate to declare your structure in various files. Imports are ommited for clarity
// Messages (ISecretMessagePayload)
export enum ESMType { SetSecret = "SetSecret" }
export interface ISMPayload extends IMPayload<ISRequestPayload, ISResponsePayload> {
[ESMType.SetSecret]: string // Payload will be a string, the secret
}
// Allows to type the incoming message in handlers
export type SM<T extends keyof ISMPayload> = M<T, ISRequestPayload, ISResponsePayload, ISMPayload>;
// Operations
export enum ESOpType { SetSecret = "SetSecret", GetSecret = "GetSecret" }
export interface ISRequestPayload extends IRequestPayload {
[ESOpType.SetSecret]: string
[ESOpType.GetSecret]: null
}
export interface ISResponsePayload extends IResponsePayload {
[ESOpType.GetSecret]: string
[ESOpType.SetSecret]: null
}
// State adds a secret property to store the secret
export interface ISState extends IState<ISRequestPayload, ISResponsePayload, ISMPayload> {
secret: string;
}
Then we can start to implement the secret storage on the first node.
- Create a new component that inherits
Peer
(it's the component that will receive messages sent by peers). Indicate your typings to thePeer
base class. - Add a handler for the
SetSecret
message. Use the handler typeSM
to type the incoming message. You can observe that the message payload is correctly typed as a string.
The handler will have to store the secret in the state, but also send it to the second node in order to propagate it. You can use the .send()
method on the state.peers
in order to broadcast a message.
Send the message only to the peers that are not the source of the message to avoid an infinite loop caused by the second node sending the secret to the first.
// Component from a typed messenger to adapt the state and send()
export class SecretManager extends Peer<ISRequestPayload, ISResponsePayload, ISMPayload, ISState> {
// Type your incoming message
protected [ESMType.SetSecret](message: SM<ESMType.SetSecret>): void {
const secret: string = message.payload; // Payload type OK
this.state.secret = secret; // We update the local shared state
this.peers // Filter the peers to avoid the second peer to send the message back
.filter((peer) => peer.hostname != message.source)
.send(ESMType.SetSecret, message.payload);
}
}
Next let's extend the Api
in order to accept the new operation and forward it to our component. As a component, don't forget to type it also. If the operation type is SetSecret
, we send the appropriate message to our custom component. Then we respond to the client. If it's GetSecret
, the Api
will directly return the value stored in the state to the client.
export class SApi extends Api<ISRequestPayload, ISResponsePayload, ISMPayload, ISState> {
protected override [EMType.ClientRequest](message: M<EMType.ClientRequest> | SM<EMType.ClientRequest>) {
super.ClientRequest(message as M<EMType.ClientRequest>); // Call the parent to keep base operations accessible
switch (message.payload.type) {
case ESOpType.SetSecret:
this.send(
ESMType.SetSecret,
message.payload.payload as ISMPayload[ESOpType.SetSecret],
SecretManager
).then(() => { // Called when all handlers have been executed successfully
this.response(
message.payload.token,
ESOpType.SetSecret,
null
)
})
break;
case ESOpType.GetSecret:
this.response( // A utility method that allows to formulate a ClientResponse with typed operation
message.payload.token,
ESOpType.GetSecret,
this.state.secret
)
break;
default:
break;
}
}
}
To finish the server side, we will create a state, and bootstrap the application using the factory.
const state: ISState = {
...of(), // Inherit base state properties
secret: ""
}
new DDAPPS<ISRequestPayload, ISResponsePayload, ISMPayload, ISState>()
.use(SApi)
.use(SecretManager)
.run(state);
Now that we've implemented the server side, we will extend the Client
to add our operations, and add a CLI command to interact with our application. Since the server side is blocking, create a new file for your client side.
First, use the Client.send()
method to package and send a ClientRequest
using the typings.
class SClient extends Client<ISRequestPayload, ISResponsePayload, ISMPayload> {
public sset(secret: string) {
return this.send(ESOpType.SetSecret, secret);
}
public sget() {
return this.send(ESOpType.GetSecret, null);
}
}
Second, declare your CLI commands that use your custom client, calling the associated methods. In this example the response is simply printed on stdout
before exiting Deno.
Due to a limitation on Cliffy, the address and port arguments must be declared on each command.
const sset = new Command()
.description("Set the secret")
.option("-a, --address <addr:string>", "HTTP endpoint", {
default: "localhost",
})
.option("-p, --port <port:number>", "HTTP port", { default: 8080 })
.option("-v, --value <value:string>", "Secret value")
.action(async ({ address, port, value }: {
address: string;
port: number;
value: string
}) => {
await new SClient(address, port).co
.then((ops) => {
return ops.sset(value);
}).then((response) => {
console.dir(response, { depth: 10 });
Deno.exit(0);
}).catch((err) => {
console.error(err);
Deno.exit(1);
});
})
const sget = new Command()
.description("Retrieve the secret")
.option("-a, --address <addr:string>", "HTTP endpoint", {
default: "localhost",
})
.option("-p, --port <port:number>", "HTTP port", { default: 8080 })
.action(async ({ address, port }: {
address: string;
port: number;
}) => {
await new SClient(address, port).co
.then((ops) => {
return ops.sget();
}).then((response) => {
console.dir(response, { depth: 10 });
Deno.exit(0);
}).catch((err) => {
console.error(err);
Deno.exit(1);
});
})
Finally, you can extend the ddappsctl
to add your custom commands. Then use await
to make the CLI executable at the end of the script.
const sctl = ddappsctl
.description("Manage the secret")
.command("sset", sset)
.command("sget", sget)
await sctl.parse(Deno.args);
You should not have your declarations and your bootstrap in the same file. That's because the CLI file needs to import your declarations, and will start the server if present in the module.
You can start the server with deno run -A --unstable server.secret.ddapps.ts
. If you add --console-messages
, the server will log in the console (only the client messages). You can add --debug
to display all internal messages.
┌────────────┬──────────────┐
│ (idx) │ Values │
├────────────┼──────────────┤
│ deno │ "1.20.1" │
│ v8 │ "10.0.139.6" │
│ typescript │ "4.6.2" │
│ ddapps │ "1.4.5" │
└────────────┴──────────────┘
[INFO] DDAPPS::Component::Use::log::Logger
[INFO] DDAPPS::Component::Use::api::SApi
[INFO] DDAPPS::Component::Use::mon::Monitor
[INFO] Logger::AlreadyRegistered::Monitor
[INFO] DDAPPS::Component::Use::peer::SecretManager
[INFO] DDAPPS::Component::Use::net::Net
[INFO] Logger::AlreadyRegistered::Net
[INFO] DDAPPS::Server::Start::tcp://0.0.0.0:8080
We can observe that DDAPPS
correctly used SApi
& SecretManager
components as the api & peer. Then define a secret with your custom CLI deno run -A --unstable cli.secret.ddapps.ts sset -v "my-secret"
{
type: "ClientResponse",
source: "localhost",
destination: "Client",
payload: { token: "flszhonhzy4", type: "SetSecret", payload: null, timestamp: 1647795375248 }
}
You can retrieve your secret with deno run -A --unstable cli.secret.ddapps.ts sget
{
type: "ClientResponse",
source: "localhost",
destination: "Client",
payload: {
token: "u97o6kwg7n",
type: "GetSecret",
payload: "my-secret",
timestamp: 1647795171896
}
}
Try starting a second instance of your application. For the two nodes to connect, you need to define two environment variables (on each node).
DDAPPS_NODE_IP
is the IP of the current nodeDDAPPS_CLUSTER_HOSTNAME
is the IP or DNS of the node to connect to
Then, start the second node with the flag --discovery
to activate the connection to the first node (based on the environement variables). Use the same CLI to set a secret on the first node (using the --address
argument since it shouldn't be localhost anymore), and try to retrieve the secret from the second node. Congratulations, you've implemented a distributed application that can dispatch a value on multiple machines. Note that we've made the effort to use types as much as possible, and we used many aspect of ddapps framework. But as a framework, you can also use only a subset of it, for example you may not need to implement your own client & CLI if you only want to extend the monitoring behavior and use the base tooling.
For more information on internals and base structures, you can go in the technical documentation.
I try to use as few as possible dependencies. For now ddapps relies on
- Cliffy to develop the ddappsctl CLI
- Eta template engine for the CLI to init a project
- Standard Library for various utilities such as HTTP server or logging colors