From b02792fd395395699e6ac7537763f7c469038b3a Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Sat, 9 Dec 2023 20:44:59 +0100 Subject: [PATCH 1/8] feat: align to Rust CDK rev: https://github.com/omnia-network/ic-websocket-cdk-rs/tree/55ad12130d0c4f8b2e94971a0e4cd4c4fef8653f --- src/Constants.mo | 10 +- src/State.mo | 118 ++++++++++++++---- src/Timers.mo | 19 +-- src/Types.mo | 108 +++++++++------- src/lib.mo | 41 ++++-- tests/ic-websocket-cdk-rs | 2 +- tests/test_canister/src/test_canister/main.mo | 15 ++- 7 files changed, 221 insertions(+), 92 deletions(-) diff --git a/src/Constants.mo b/src/Constants.mo index e242e72..eb11b9d 100644 --- a/src/Constants.mo +++ b/src/Constants.mo @@ -5,8 +5,14 @@ module { public let DEFAULT_MAX_NUMBER_OF_RETURNED_MESSAGES : Nat = 50; /// The default interval at which to send acknowledgements to the client. public let DEFAULT_SEND_ACK_INTERVAL_MS : Nat64 = 300_000; // 5 minutes - /// The default timeout to wait for the client to send a keep alive after receiving an acknowledgement. - public let DEFAULT_CLIENT_KEEP_ALIVE_TIMEOUT_MS : Nat64 = 60_000; // 1 minute + /// The maximum network latency allowed between the client and the canister. + public let MAX_ALLOWED_NETWORK_LATENCY_MS : Nat64 = 30_000; // 30 seconds + public class Computed() { + /// The default timeout to wait for the client to send a keep alive after receiving an acknowledgement. + public let CLIENT_KEEP_ALIVE_TIMEOUT_MS : Nat64 = 2 * MAX_ALLOWED_NETWORK_LATENCY_MS; + /// Same as [CLIENT_KEEP_ALIVE_TIMEOUT_MS], but in nanoseconds. + public let CLIENT_KEEP_ALIVE_TIMEOUT_NS : Nat64 = CLIENT_KEEP_ALIVE_TIMEOUT_MS * 1_000_000; + }; /// The initial nonce for outgoing messages. public let INITIAL_OUTGOING_MESSAGE_NONCE : Nat64 = 0; diff --git a/src/State.mo b/src/State.mo index a815c66..4916b68 100644 --- a/src/State.mo +++ b/src/State.mo @@ -21,7 +21,8 @@ import Utils "Utils"; module { type CanisterOutputMessage = Types.CanisterOutputMessage; type CanisterWsGetMessagesResult = Types.CanisterWsGetMessagesResult; - type CanisterWsSendResult = Types.CanisterWsSendResult; + type CanisterCloseResult = Types.CanisterCloseResult; + type CanisterSendResult = Types.CanisterSendResult; type ClientKey = Types.ClientKey; type ClientPrincipal = Types.ClientPrincipal; type GatewayPrincipal = Types.GatewayPrincipal; @@ -68,7 +69,7 @@ module { public func reset_internal_state(handlers : WsHandlers) : async () { // for each client, call the on_close handler before clearing the map for (client_key in REGISTERED_CLIENTS.keys()) { - await remove_client(client_key, handlers); + await remove_client(client_key, ?handlers, null); }; // make sure all the maps are cleared @@ -96,18 +97,38 @@ module { }; }; - /// Decrements the clients connected count for the given gateway. - /// If there are no more clients connected, the gateway is removed from the list of registered gateways. - func decrement_gateway_clients_count(gateway_principal : GatewayPrincipal) { - switch (REGISTERED_GATEWAYS.get(gateway_principal)) { - case (?registered_gateway) { + /// Decrements the clients connected count for the given gateway, if it exists. + /// + /// If `remove_if_empty` is true, the gateway is removed from the list of registered gateways + /// if it has no clients connected. + func decrement_gateway_clients_count(gateway_principal : GatewayPrincipal, remove_if_empty : Bool) { + let messages_keys_to_delete = Option.map( + REGISTERED_GATEWAYS.get(gateway_principal), + func(registered_gateway : RegisteredGateway) : ?List.List { let clients_count = registered_gateway.decrement_clients_count(); - if (clients_count == 0) { - REGISTERED_GATEWAYS.delete(gateway_principal); + if (remove_if_empty and clients_count == 0) { + let g = REGISTERED_GATEWAYS.remove(gateway_principal); + + return switch (g) { + case (?registered_gateway) { + ?List.map(registered_gateway.messages_queue, func(m : CanisterOutputMessage) : Text { m.key }); + }; + case (null) { + Prelude.unreachable(); + }; + }; }; + + null; + }, + ); + + switch (Option.flatten(messages_keys_to_delete)) { + case (?keys) { + delete_keys_from_cert_tree(keys); }; case (null) { - Prelude.unreachable(); // gateway must be registered at this point + // Do nothing }; }; }; @@ -253,9 +274,25 @@ module { }; /// Removes a client from the internal state - /// and call the on_close callback, + /// and call the on_close callback (if handlers are provided), /// if the client was registered in the state. - public func remove_client(client_key : ClientKey, handlers : WsHandlers) : async () { + /// + /// If a `close_reason` is provided, it also sends a close message to the client, + /// so that the client can close the WS connection with the gateway. + /// + /// If a `close_reason` is **not** provided, it also removes the gateway from the state + /// if it has no clients connected anymore. + public func remove_client(client_key : ClientKey, handlers : ?WsHandlers, close_reason : ?Types.CloseMessageReason) : async () { + switch (close_reason) { + case (?close_reason) { + // ignore the error + ignore send_service_message_to_client(client_key, #CloseMessage({ reason = close_reason })); + }; + case (null) { + // Do nothing + }; + }; + CLIENTS_WAITING_FOR_KEEP_ALIVE := TrieSet.delete(CLIENTS_WAITING_FOR_KEEP_ALIVE, client_key, Types.hashClientKey(client_key), Types.areClientKeysEqual); CURRENT_CLIENT_KEY_MAP.delete(client_key.client_principal); OUTGOING_MESSAGE_TO_CLIENT_NUM_MAP.delete(client_key); @@ -263,11 +300,21 @@ module { switch (REGISTERED_CLIENTS.remove(client_key)) { case (?registered_client) { - decrement_gateway_clients_count(registered_client.gateway_principal); - - await handlers.call_on_close({ - client_principal = client_key.client_principal; - }); + decrement_gateway_clients_count( + registered_client.gateway_principal, + Option.isNull(close_reason), + ); + + switch (handlers) { + case (?handlers) { + await handlers.call_on_close({ + client_principal = client_key.client_principal; + }); + }; + case (null) { + // Do nothing + }; + }; }; case (null) { // Do nothing @@ -406,7 +453,7 @@ module { switch (get_registered_gateway(gateway_principal)) { case (#Ok(registered_gateway)) { // messages in the queue are inserted with contiguous and increasing nonces - // (from beginning to end of the queue) as ws_send is called sequentially, the nonce + // (from beginning to end of the queue) as `send` is called sequentially, the nonce // is incremented by one in each call, and the message is pushed at the end of the queue registered_gateway.add_message_to_queue(message, message_timestamp); #Ok; @@ -426,13 +473,23 @@ module { case (#Err(err)) { return #Err(err) }; }; - for (key in Iter.fromList(deleted_messages_keys)) { - CERT_TREE.delete([Text.encodeUtf8(key)]); - }; + delete_keys_from_cert_tree(deleted_messages_keys); #Ok; }; + func delete_keys_from_cert_tree(keys : List.List) { + let root_hash = do { + for (key in Iter.fromList(keys)) { + CERT_TREE.delete([Text.encodeUtf8(key)]); + }; + labeledHash(Constants.LABEL_WEBSOCKET, CERT_TREE.treeHash()); + }; + + // certify data with the new root hash + CertifiedData.set(root_hash); + }; + func get_cert_for_range(keys : Iter.Iter) : (Blob, Blob) { let witness = CERT_TREE.reveals(keys); let tree : CertTree.Witness = #labeled(Constants.LABEL_WEBSOCKET, witness); @@ -485,7 +542,7 @@ module { }; /// Internal function used to put the messages in the outgoing messages queue and certify them. - public func _ws_send(client_key : ClientKey, msg_bytes : Blob, is_service_message : Bool) : CanisterWsSendResult { + public func _ws_send(client_key : ClientKey, msg_bytes : Blob, is_service_message : Bool) : CanisterSendResult { // get the registered client if it exists let registered_client = switch (get_registered_client(client_key)) { case (#Err(err)) { @@ -579,7 +636,7 @@ module { ); }; - public func _ws_send_to_client_principal(client_principal : ClientPrincipal, msg_bytes : Blob) : CanisterWsSendResult { + public func _ws_send_to_client_principal(client_principal : ClientPrincipal, msg_bytes : Blob) : CanisterSendResult { let client_key = switch (get_client_key_from_principal(client_principal)) { case (#Err(err)) { return #Err(err); @@ -590,5 +647,20 @@ module { }; _ws_send(client_key, msg_bytes, false); }; + + public func _close_for_client_principal(client_principal : ClientPrincipal, handlers : ?WsHandlers) : async CanisterCloseResult { + let client_key = switch (get_client_key_from_principal(client_principal)) { + case (#Err(err)) { + return #Err(err); + }; + case (#Ok(client_key)) { + client_key; + }; + }; + + await remove_client(client_key, handlers, ? #ClosedByApplication); + + #Ok; + }; }; }; diff --git a/src/Timers.mo b/src/Timers.mo index 49cbef2..d45339b 100644 --- a/src/Timers.mo +++ b/src/Timers.mo @@ -3,6 +3,7 @@ import Nat64 "mo:base/Nat64"; import Array "mo:base/Array"; import TrieSet "mo:base/TrieSet"; +import Constants "Constants"; import Types "Types"; import State "State"; import Utils "Utils"; @@ -49,13 +50,13 @@ module { /// /// The interval callback is [send_ack_to_clients_timer_callback]. After the callback is executed, /// a timer is scheduled to check if the registered clients have sent a keep alive message. - public func schedule_send_ack_to_clients(ws_state : State.IcWebSocketState, ack_interval_ms : Nat64, keep_alive_timeout_ms : Nat64, handlers : Types.WsHandlers) { + public func schedule_send_ack_to_clients(ws_state : State.IcWebSocketState, ack_interval_ms : Nat64, handlers : Types.WsHandlers) { let timer_id = Timer.recurringTimer( #nanoseconds(Nat64.toNat(ack_interval_ms * 1_000_000)), func() : async () { send_ack_to_clients_timer_callback(ws_state, ack_interval_ms); - schedule_check_keep_alive(ws_state, keep_alive_timeout_ms, handlers); + schedule_check_keep_alive(ws_state, handlers); }, ); @@ -66,11 +67,11 @@ module { /// after receiving an acknowledgement message. /// /// The timer callback is [check_keep_alive_timer_callback]. - func schedule_check_keep_alive(ws_state : State.IcWebSocketState, keep_alive_timeout_ms : Nat64, handlers : Types.WsHandlers) { + func schedule_check_keep_alive(ws_state : State.IcWebSocketState, handlers : Types.WsHandlers) { let timer_id = Timer.setTimer( - #nanoseconds(Nat64.toNat(keep_alive_timeout_ms * 1_000_000)), + #nanoseconds(Nat64.toNat(Constants.Computed().CLIENT_KEEP_ALIVE_TIMEOUT_NS)), func() : async () { - await check_keep_alive_timer_callback(ws_state, keep_alive_timeout_ms, handlers); + await check_keep_alive_timer_callback(ws_state, handlers); }, ); @@ -113,17 +114,17 @@ module { /// Checks if the clients for which we are waiting for keep alive have sent a keep alive message. /// If a client has not sent a keep alive message, it is removed from the connected clients. - func check_keep_alive_timer_callback(ws_state : State.IcWebSocketState, keep_alive_timeout_ms : Nat64, handlers : Types.WsHandlers) : async () { + func check_keep_alive_timer_callback(ws_state : State.IcWebSocketState, handlers : Types.WsHandlers) : async () { for (client_key in Array.vals(TrieSet.toArray(ws_state.CLIENTS_WAITING_FOR_KEEP_ALIVE))) { // get the last keep alive timestamp for the client and check if it has exceeded the timeout switch (ws_state.REGISTERED_CLIENTS.get(client_key)) { case (?client_metadata) { let last_keep_alive = client_metadata.get_last_keep_alive_timestamp(); - if (Utils.get_current_time() - last_keep_alive > (keep_alive_timeout_ms * 1_000_000)) { - await ws_state.remove_client(client_key, handlers); + if (Utils.get_current_time() - last_keep_alive > Constants.Computed().CLIENT_KEEP_ALIVE_TIMEOUT_NS) { + await ws_state.remove_client(client_key, ?handlers, ? #KeepAliveTimeout); - Utils.custom_print("[check-keep-alive-timer-cb]: Client " # Types.clientKeyToText(client_key) # " has not sent a keep alive message in the last " # debug_show (keep_alive_timeout_ms) # " ms and has been removed"); + Utils.custom_print("[check-keep-alive-timer-cb]: Client " # Types.clientKeyToText(client_key) # " has not sent a keep alive message in the last " # debug_show (Constants.Computed().CLIENT_KEEP_ALIVE_TIMEOUT_MS) # " ms and has been removed"); }; }; case (null) { diff --git a/src/Types.mo b/src/Types.mo index cb1d576..dbbec14 100644 --- a/src/Types.mo +++ b/src/Types.mo @@ -45,8 +45,12 @@ module { public type CanisterWsMessageResult = Result<(), Text>; /// The result of [ws_get_messages]. public type CanisterWsGetMessagesResult = Result; - /// The result of [ws_send]. + /// The result of [send]. + public type CanisterSendResult = Result<(), Text>; + /// @deprecated Use [`CanisterSendResult`] instead. public type CanisterWsSendResult = Result<(), Text>; + /// The result of [close]. + public type CanisterCloseResult = Result<(), Text>; /// The arguments for [ws_open]. public type CanisterWsOpenArguments = { @@ -256,7 +260,9 @@ module { /// Decrements the connected clients count by 1, returning the new value. public func decrement_clients_count() : Nat64 { - connected_clients_count -= 1; + if (connected_clients_count > 0) { + connected_clients_count -= 1; + }; connected_clients_count; }; @@ -350,10 +356,31 @@ module { last_incoming_sequence_num : Nat64; }; + public type CloseMessageReason = { + /// When the canister receives a wrong sequence number from the client. + #WrongSequenceNumber; + /// When the canister receives an invalid service message from the client. + #InvalidServiceMessage; + /// When the canister doesn't receive the keep alive message from the client in time. + #KeepAliveTimeout; + /// When the developer calls the `close` function. + #ClosedByApplication; + }; + + public type CanisterCloseMessageContent = { + reason : CloseMessageReason; + }; + + /// A service message sent by the CDK to the client or vice versa. public type WebsocketServiceMessageContent = { + /// Message sent by the **canister** when a client opens a connection. #OpenMessage : CanisterOpenMessageContent; + /// Message sent _periodically_ by the **canister** to the client to acknowledge the messages received. #AckMessage : CanisterAckMessageContent; + /// Message sent by the **client** in response to an acknowledgement message from the canister. #KeepAliveMessage : ClientKeepAliveMessageContent; + /// Message sent by the **canister** when it wants to close the connection. + #CloseMessage : CanisterCloseMessageContent; }; public func encode_websocket_service_message_content(content : WebsocketServiceMessageContent) : Blob { to_candid (content); @@ -379,7 +406,7 @@ module { /// Use [`from_candid`] to deserialize the message. /// /// # Example - /// This example is the deserialize equivalent of the [`ws_send`]'s serialize one. + /// This example is the deserialize equivalent of the [`send`]'s serialize one. /// ```motoko /// import IcWebSocketCdk "mo:ic-websocket-cdk"; /// @@ -421,11 +448,19 @@ module { public type OnCloseCallbackArgs = { client_principal : ClientPrincipal; }; - /// Handler initialized by the canister and triggered by the CDK once the WS Gateway closes the - /// IC WebSocket connection. + /// Handler initialized by the canister + /// and triggered by the CDK once the WS Gateway closes the IC WebSocket connection + /// for that client. + /// + /// Make sure you **don't** call the [close](crate::close) function in this callback. public type OnCloseCallback = (OnCloseCallbackArgs) -> async (); /// Handlers initialized by the canister and triggered by the CDK. + /// + /// **Note**: if the callbacks that you define here trap for some reason, + /// the CDK will disconnect the client with principal `args.client_principal`. + /// However, the client **won't** be notified + /// until at least the next time it will try to send a message to the canister. public class WsHandlers( init_on_open : ?OnOpenCallback, init_on_message : ?OnMessageCallback, @@ -438,11 +473,9 @@ module { public func call_on_open(args : OnOpenCallbackArgs) : async () { switch (on_open) { case (?callback) { - try { - await callback(args); - } catch (err) { - Utils.custom_print("Error calling on_open handler: " # Error.message(err)); - }; + // we don't have to recover from errors here, + // we just let the canister trap + await callback(args); }; case (null) { // Do nothing. @@ -453,11 +486,8 @@ module { public func call_on_message(args : OnMessageCallbackArgs) : async () { switch (on_message) { case (?callback) { - try { - await callback(args); - } catch (err) { - Utils.custom_print("Error calling on_message handler: " # Error.message(err)); - }; + // see call_on_open + await callback(args); }; case (null) { // Do nothing. @@ -468,11 +498,8 @@ module { public func call_on_close(args : OnCloseCallbackArgs) : async () { switch (on_close) { case (?callback) { - try { - await callback(args); - } catch (err) { - Utils.custom_print("Error calling on_close handler: " # Error.message(err)); - }; + // see call_on_open + await callback(args); }; case (null) { // Do nothing. @@ -485,15 +512,14 @@ module { /// /// Arguments: /// - /// - `init_max_number_of_returned_messages`: Maximum number of returned messages. Defaults to `10` if null. - /// - `init_send_ack_interval_ms`: Send ack interval in milliseconds. Defaults to `60_000` (60 seconds) if null. - /// - `init_keep_alive_timeout_ms`: Keep alive timeout in milliseconds. Defaults to `10_000` (10 seconds) if null. + /// - `init_max_number_of_returned_messages`: Maximum number of returned messages. Defaults to `50` if null. + /// - `init_send_ack_interval_ms`: Send ack interval in milliseconds. Defaults to `300_000` (5 minutes) if null. public class WsInitParams( init_max_number_of_returned_messages : ?Nat, init_send_ack_interval_ms : ?Nat64, - init_keep_alive_timeout_ms : ?Nat64, ) = self { /// The maximum number of messages to be returned in a polling iteration. + /// /// Defaults to `50`. public var max_number_of_returned_messages : Nat = switch (init_max_number_of_returned_messages) { case (?value) { value }; @@ -502,31 +528,22 @@ module { /// The interval at which to send an acknowledgement message to the client, /// so that the client knows that all the messages it sent have been received by the canister (in milliseconds). /// - /// Must be greater than `keep_alive_timeout_ms`. + /// Must be greater than [`CLIENT_KEEP_ALIVE_TIMEOUT_MS`] (1 minute). /// /// Defaults to `300_000` (5 minutes). public var send_ack_interval_ms : Nat64 = switch (init_send_ack_interval_ms) { case (?value) { value }; case (null) { Constants.DEFAULT_SEND_ACK_INTERVAL_MS }; }; - /// The delay to wait for the client to send a keep alive after receiving an acknowledgement (in milliseconds). - /// - /// Must be lower than `send_ack_interval_ms`. - /// - /// Defaults to `60_000` (1 minute). - public var keep_alive_timeout_ms : Nat64 = switch (init_keep_alive_timeout_ms) { - case (?value) { value }; - case (null) { Constants.DEFAULT_CLIENT_KEEP_ALIVE_TIMEOUT_MS }; - }; /// Checks the validity of the timer parameters. - /// `send_ack_interval_ms` must be greater than `keep_alive_timeout_ms`. + /// `send_ack_interval_ms` must be greater than [`CLIENT_KEEP_ALIVE_TIMEOUT_MS`]. /// /// # Traps - /// If `send_ack_interval_ms` < `keep_alive_timeout_ms`. + /// If `send_ack_interval_ms` <= [`CLIENT_KEEP_ALIVE_TIMEOUT_MS`]. public func check_validity() { - if (keep_alive_timeout_ms > send_ack_interval_ms) { - Utils.custom_trap("send_ack_interval_ms must be greater than keep_alive_timeout_ms"); + if (send_ack_interval_ms <= Constants.Computed().CLIENT_KEEP_ALIVE_TIMEOUT_MS) { + Utils.custom_trap("send_ack_interval_ms must be greater than CLIENT_KEEP_ALIVE_TIMEOUT_MS"); }; }; @@ -537,17 +554,18 @@ module { self; }; + /// Sets the interval (in milliseconds) at which to send an acknowledgement message + /// to the connected clients. + /// + /// Must be greater than [`CLIENT_KEEP_ALIVE_TIMEOUT_MS`] (1 minute). + /// + /// # Traps + /// If `send_ack_interval_ms` <= [`CLIENT_KEEP_ALIVE_TIMEOUT_MS`]. See [WsInitParams.check_validity]. public func with_send_ack_interval_ms( ms : Nat64 ) : WsInitParams { send_ack_interval_ms := ms; - self; - }; - - public func with_keep_alive_timeout_ms( - ms : Nat64 - ) : WsInitParams { - keep_alive_timeout_ms := ms; + check_validity(); self; }; }; diff --git a/src/lib.mo b/src/lib.mo index 7fd4a81..0c8597f 100644 --- a/src/lib.mo +++ b/src/lib.mo @@ -35,6 +35,8 @@ import Errors "Errors"; module { //// TYPES //// // re-export types + public type CanisterCloseResult = Types.CanisterCloseResult; + public type CanisterSendResult = Types.CanisterSendResult; public type CanisterWsCloseArguments = Types.CanisterWsCloseArguments; public type CanisterWsCloseResult = Types.CanisterWsCloseResult; public type CanisterWsGetMessagesArguments = Types.CanisterWsGetMessagesArguments; @@ -43,6 +45,7 @@ module { public type CanisterWsMessageResult = Types.CanisterWsMessageResult; public type CanisterWsOpenArguments = Types.CanisterWsOpenArguments; public type CanisterWsOpenResult = Types.CanisterWsOpenResult; + /// @deprecated Use [`CanisterSendResult`] instead. public type CanisterWsSendResult = Types.CanisterWsSendResult; public type ClientPrincipal = Types.ClientPrincipal; public type OnCloseCallbackArgs = Types.OnCloseCallbackArgs; @@ -73,7 +76,7 @@ module { Timers.cancel_timers(WS_STATE); // schedule a timer that will send an acknowledgement message to clients - Timers.schedule_send_ack_to_clients(WS_STATE, params.send_ack_interval_ms, params.keep_alive_timeout_ms, handlers); + Timers.schedule_send_ack_to_clients(WS_STATE, params.send_ack_interval_ms, handlers); }; /// Handles the WS connection open event sent by the client and relayed by the Gateway. @@ -105,7 +108,7 @@ module { // Do nothing }; case (#Ok(old_client_key)) { - await WS_STATE.remove_client(old_client_key, handlers); + await WS_STATE.remove_client(old_client_key, ?handlers, null); }; }; @@ -134,6 +137,9 @@ module { }; /// Handles the WS connection close event received from the WS Gateway. + /// + /// If you want to close the connection with the client in your logic, + /// use the [close] function instead. public func ws_close(caller : Principal, args : CanisterWsCloseArguments) : async CanisterWsCloseResult { let gateway_principal = caller; @@ -167,7 +173,7 @@ module { }; }; - await WS_STATE.remove_client(args.client_key, handlers); + await WS_STATE.remove_client(args.client_key, ?handlers, null); #Ok; }; @@ -233,7 +239,7 @@ module { // check if the incoming message has the expected sequence number if (sequence_num != expected_sequence_num) { - await WS_STATE.remove_client(client_key, handlers); + await WS_STATE.remove_client(client_key, ?handlers, ? #WrongSequenceNumber); return #Err(Errors.to_string(#IncomingSequenceNumberWrong({ expected_sequence_num; actual_sequence_num = sequence_num }))); }; // increase the expected sequence number by 1 @@ -267,11 +273,18 @@ module { WS_STATE.get_cert_messages(caller, args.nonce, params.max_number_of_returned_messages); }; - /// Sends a message to the client. See [IcWebSocketCdk.ws_send] function for reference. + /// Sends a message to the client. See [IcWebSocketCdk.send] function for reference. public func send(client_principal : ClientPrincipal, msg_bytes : Blob) : async CanisterWsSendResult { WS_STATE._ws_send_to_client_principal(client_principal, msg_bytes); }; + /// Closes the connection with the client. + /// + /// This function **must not** be called in the `on_close` callback. + public func close(client_principal : ClientPrincipal) : async CanisterCloseResult { + await WS_STATE._close_for_client_principal(client_principal, ?handlers); + }; + /// Resets the internal state of the IC WebSocket CDK. /// /// **Note:** You should only call this function in tests. @@ -307,10 +320,24 @@ module { /// some_field: "Hello, World!"; /// }; /// - /// IcWebSocketCdk.ws_send(ws_state, client_principal, to_candid(msg)); + /// IcWebSocketCdk.send(ws_state, client_principal, to_candid(msg)); /// } /// ``` - public func ws_send(ws_state : IcWebSocketState, client_principal : ClientPrincipal, msg_bytes : Blob) : async CanisterWsSendResult { + public func send(ws_state : IcWebSocketState, client_principal : ClientPrincipal, msg_bytes : Blob) : async CanisterSendResult { ws_state._ws_send_to_client_principal(client_principal, msg_bytes); }; + + /// @deprecated Use [`send`] instead. + public func ws_send(ws_state : IcWebSocketState, client_principal : ClientPrincipal, msg_bytes : Blob) : async CanisterWsSendResult { + await send(ws_state, client_principal, msg_bytes); + }; + + /// Closes the connection with the client. + /// It can be used in a similar way as the [`IcWebSocketCdk.send`] function. + /// + /// This function **must not** be called in the `on_close` callback + /// and **doesn't** trigger the `on_close` callback. + public func close(ws_state : IcWebSocketState, client_principal : ClientPrincipal) : async CanisterCloseResult { + await ws_state._close_for_client_principal(client_principal, null); + }; }; diff --git a/tests/ic-websocket-cdk-rs b/tests/ic-websocket-cdk-rs index 4ff0311..55ad121 160000 --- a/tests/ic-websocket-cdk-rs +++ b/tests/ic-websocket-cdk-rs @@ -1 +1 @@ -Subproject commit 4ff03111d5efcc3f02fe61981d40d9787d6254df +Subproject commit 55ad12130d0c4f8b2e94971a0e4cd4c4fef8653f diff --git a/tests/test_canister/src/test_canister/main.mo b/tests/test_canister/src/test_canister/main.mo index 669d4d7..680cbff 100644 --- a/tests/test_canister/src/test_canister/main.mo +++ b/tests/test_canister/src/test_canister/main.mo @@ -8,7 +8,6 @@ import IcWebSocketCdkState "mo:ic-websocket-cdk/State"; actor class TestCanister( init_max_number_of_returned_messages : Nat64, init_send_ack_interval_ms : Nat64, - init_keep_alive_timeout_ms : Nat64, ) { type AppMessage = { @@ -18,7 +17,6 @@ actor class TestCanister( let params = IcWebSocketCdkTypes.WsInitParams( ?Nat64.toNat(init_max_number_of_returned_messages), ?init_send_ack_interval_ms, - ?init_keep_alive_timeout_ms, ); var ws_state = IcWebSocketCdkState.IcWebSocketState(params); @@ -65,11 +63,11 @@ actor class TestCanister( //// Debug/tests methods // send a message to the client, usually called by the canister itself - public shared func ws_send(client_principal : IcWebSocketCdk.ClientPrincipal, messages : [Blob]) : async IcWebSocketCdk.CanisterWsSendResult { - var res : IcWebSocketCdk.CanisterWsSendResult = #Ok; + public shared func send(client_principal : IcWebSocketCdk.ClientPrincipal, messages : [Blob]) : async IcWebSocketCdk.CanisterSendResult { + var res : IcWebSocketCdk.CanisterSendResult = #Ok; label f for (msg_bytes in Array.vals(messages)) { - switch (await IcWebSocketCdk.ws_send(ws_state, client_principal, msg_bytes)) { + switch (await IcWebSocketCdk.send(ws_state, client_principal, msg_bytes)) { case (#Ok(value)) { // Do nothing }; @@ -82,4 +80,11 @@ actor class TestCanister( return res; }; + + // close the connection with a client, usually called by the canister itself + public shared func close(client_principal : IcWebSocketCdk.ClientPrincipal) : async IcWebSocketCdk.CanisterCloseResult { + await ws.close(client_principal); + // or (but doesn't call the on_close callback): + // await IcWebSocketCdk.close(ws_state, client_principal); + }; }; From b1a716e14204bd1f84811d614b3b24354a868f00 Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Sat, 9 Dec 2023 20:46:28 +0100 Subject: [PATCH 2/8] chore: update dfx to v0.15.2 --- .github/workflows/release.yml | 2 +- .github/workflows/tests.yml | 2 +- tests/test_canister/dfx.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e66d96..344dabd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: - uses: aviate-labs/setup-dfx@v0.2.6 with: - dfx-version: 0.15.1 + dfx-version: 0.15.2 env: DFX_IDENTITY_PEM: ${{ secrets.DFX_IDENTITY_PEM }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e9203f..0e6c87a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,7 +25,7 @@ jobs: - uses: aviate-labs/setup-dfx@v0.2.6 with: - dfx-version: 0.15.1 + dfx-version: 0.15.2 # rust toolchain is needed for integration tests - uses: actions-rs/toolchain@v1 diff --git a/tests/test_canister/dfx.json b/tests/test_canister/dfx.json index 953a1a1..ca338d3 100644 --- a/tests/test_canister/dfx.json +++ b/tests/test_canister/dfx.json @@ -13,5 +13,5 @@ }, "output_env_file": ".env", "version": 1, - "dfx": "0.15.1" + "dfx": "0.15.2" } \ No newline at end of file From 26899d8bf2b2c046f5344e8dadd6fe138ad31d6f Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Fri, 15 Dec 2023 09:48:19 +0100 Subject: [PATCH 3/8] perf: rename const https://github.com/omnia-network/ic-websocket-cdk-rs/commit/67b1bdd9e26b748897f8ba1b4a4abe84a28476ea --- src/Constants.mo | 6 +++--- tests/ic-websocket-cdk-rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Constants.mo b/src/Constants.mo index eb11b9d..f985481 100644 --- a/src/Constants.mo +++ b/src/Constants.mo @@ -5,11 +5,11 @@ module { public let DEFAULT_MAX_NUMBER_OF_RETURNED_MESSAGES : Nat = 50; /// The default interval at which to send acknowledgements to the client. public let DEFAULT_SEND_ACK_INTERVAL_MS : Nat64 = 300_000; // 5 minutes - /// The maximum network latency allowed between the client and the canister. - public let MAX_ALLOWED_NETWORK_LATENCY_MS : Nat64 = 30_000; // 30 seconds + /// The maximum communication latency allowed between the client and the canister. + public let COMMUNICATION_LATENCY_BOUND_MS : Nat64 = 30_000; // 30 seconds public class Computed() { /// The default timeout to wait for the client to send a keep alive after receiving an acknowledgement. - public let CLIENT_KEEP_ALIVE_TIMEOUT_MS : Nat64 = 2 * MAX_ALLOWED_NETWORK_LATENCY_MS; + public let CLIENT_KEEP_ALIVE_TIMEOUT_MS : Nat64 = 2 * COMMUNICATION_LATENCY_BOUND_MS; /// Same as [CLIENT_KEEP_ALIVE_TIMEOUT_MS], but in nanoseconds. public let CLIENT_KEEP_ALIVE_TIMEOUT_NS : Nat64 = CLIENT_KEEP_ALIVE_TIMEOUT_MS * 1_000_000; }; diff --git a/tests/ic-websocket-cdk-rs b/tests/ic-websocket-cdk-rs index 55ad121..67b1bdd 160000 --- a/tests/ic-websocket-cdk-rs +++ b/tests/ic-websocket-cdk-rs @@ -1 +1 @@ -Subproject commit 55ad12130d0c4f8b2e94971a0e4cd4c4fef8653f +Subproject commit 67b1bdd9e26b748897f8ba1b4a4abe84a28476ea From 30a74a7ec232e4ef9a087eedd22d90c813041403 Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Fri, 15 Dec 2023 12:52:51 +0100 Subject: [PATCH 4/8] chore: update PocketIC to v2 --- .github/workflows/tests.yml | 1 + scripts/download-pocket-ic.sh | 19 +------------------ scripts/test.sh | 1 + tests/ic-websocket-cdk-rs | 2 +- tests/test_canister/src/test_canister/main.mo | 15 ++++++++++++++- 5 files changed, 18 insertions(+), 20 deletions(-) mode change 100755 => 120000 scripts/download-pocket-ic.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0e6c87a..f6ca55e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,7 @@ jobs: - name: Run integration tests run: | + export POCKET_IC_MUTE_SERVER=1 export POCKET_IC_BIN="$(pwd)/bin/pocket-ic" export TEST_CANISTER_WASM_PATH="$(pwd)/bin/test_canister.wasm" cd tests/ic-websocket-cdk-rs diff --git a/scripts/download-pocket-ic.sh b/scripts/download-pocket-ic.sh deleted file mode 100755 index 56687ec..0000000 --- a/scripts/download-pocket-ic.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -cd bin/ - -POCKET_IC_BIN=pocket-ic -if [ -f "$POCKET_IC_BIN" ]; then - echo -e "$POCKET_IC_BIN exists. Path: $(pwd)/$POCKET_IC_BIN\n" -else - echo "$POCKET_IC_BIN does not exist." - echo "Downloading Pocket IC binary..." - curl -sLO https://download.dfinity.systems/ic/307d5847c1d2fe1f5e19181c7d0fcec23f4658b3/openssl-static-binaries/x86_64-linux/pocket-ic.gz - - echo "Extracting Pocket IC binary..." - gzip -d $POCKET_IC_BIN.gz - chmod +x $POCKET_IC_BIN - - echo -e "Pocket IC binary downloaded and extracted successfully! Path: $(pwd)/$POCKET_IC_BIN\n" -fi diff --git a/scripts/download-pocket-ic.sh b/scripts/download-pocket-ic.sh new file mode 120000 index 0000000..48931ad --- /dev/null +++ b/scripts/download-pocket-ic.sh @@ -0,0 +1 @@ +../tests/ic-websocket-cdk-rs/scripts/download-pocket-ic.sh \ No newline at end of file diff --git a/scripts/test.sh b/scripts/test.sh index 9ffe164..99de06c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -6,6 +6,7 @@ set -e ./scripts/build-test-canister.sh +export POCKET_IC_MUTE_SERVER=1 export POCKET_IC_BIN="$(pwd)/bin/pocket-ic" export TEST_CANISTER_WASM_PATH="$(pwd)/bin/test_canister.wasm" diff --git a/tests/ic-websocket-cdk-rs b/tests/ic-websocket-cdk-rs index 67b1bdd..af5aa9a 160000 --- a/tests/ic-websocket-cdk-rs +++ b/tests/ic-websocket-cdk-rs @@ -1 +1 @@ -Subproject commit 67b1bdd9e26b748897f8ba1b4a4abe84a28476ea +Subproject commit af5aa9aedf7f2b1ec85263434329e108248c8d19 diff --git a/tests/test_canister/src/test_canister/main.mo b/tests/test_canister/src/test_canister/main.mo index 680cbff..7866f4a 100644 --- a/tests/test_canister/src/test_canister/main.mo +++ b/tests/test_canister/src/test_canister/main.mo @@ -14,7 +14,7 @@ actor class TestCanister( text : Text; }; - let params = IcWebSocketCdkTypes.WsInitParams( + var params = IcWebSocketCdkTypes.WsInitParams( ?Nat64.toNat(init_max_number_of_returned_messages), ?init_send_ack_interval_ms, ); @@ -87,4 +87,17 @@ actor class TestCanister( // or (but doesn't call the on_close callback): // await IcWebSocketCdk.close(ws_state, client_principal); }; + + // wipes the internal state + public shared func wipe(max_number_of_returned_messages : Nat64, send_ack_interval_ms : Nat64) : async () { + // calling ws.wipe() is not necessary here + // because the ws_state and ws are reinitialized + + params := IcWebSocketCdkTypes.WsInitParams( + ?Nat64.toNat(max_number_of_returned_messages), + ?send_ack_interval_ms, + ); + ws_state := IcWebSocketCdkState.IcWebSocketState(params); + ws := IcWebSocketCdk.IcWebSocket(ws_state, params, handlers); + }; }; From b5421acffa8e458d6ed6cdf8a5fbb84e334d7b8b Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Fri, 15 Dec 2023 13:13:19 +0100 Subject: [PATCH 5/8] fix: map option instead of switch unreachable --- src/State.mo | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/State.mo b/src/State.mo index 4916b68..5fb793a 100644 --- a/src/State.mo +++ b/src/State.mo @@ -107,16 +107,12 @@ module { func(registered_gateway : RegisteredGateway) : ?List.List { let clients_count = registered_gateway.decrement_clients_count(); if (remove_if_empty and clients_count == 0) { - let g = REGISTERED_GATEWAYS.remove(gateway_principal); - - return switch (g) { - case (?registered_gateway) { - ?List.map(registered_gateway.messages_queue, func(m : CanisterOutputMessage) : Text { m.key }); - }; - case (null) { - Prelude.unreachable(); - }; - }; + return Option.map( + REGISTERED_GATEWAYS.remove(gateway_principal), + func(g : RegisteredGateway) : List.List { + List.map(g.messages_queue, func(m : CanisterOutputMessage) : Text { m.key }); + }, + ); }; null; From aa441ac5c0127dde7a0c46a769b1a71c7faa0b2d Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Fri, 15 Dec 2023 22:19:30 +0100 Subject: [PATCH 6/8] fix: align to Rust CDK https://github.com/omnia-network/ic-websocket-cdk-rs/pull/5/commits/27ef65e5e6fc662b661c596955f2fac380d9b9f4 --- src/State.mo | 81 ++++++++++++++++++++++++++------------- src/Timers.mo | 4 ++ src/Types.mo | 10 +++-- tests/ic-websocket-cdk-rs | 2 +- 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/src/State.mo b/src/State.mo index 5fb793a..a9ed65b 100644 --- a/src/State.mo +++ b/src/State.mo @@ -10,6 +10,7 @@ import Nat64 "mo:base/Nat64"; import Text "mo:base/Text"; import Blob "mo:base/Blob"; import CertifiedData "mo:base/CertifiedData"; +import Buffer "mo:base/Buffer"; import CertTree "mo:ic-certification/CertTree"; import Sha256 "mo:sha2/Sha256"; @@ -59,6 +60,8 @@ module { var CERT_TREE = CertTree.Ops(CERT_TREE_STORE); /// Keeps track of the principals of the WS Gateways that poll the canister. var REGISTERED_GATEWAYS = HashMap.HashMap(0, Principal.equal, Principal.hash); + /// Keeps track of the gateways that must be removed from the list of registered gateways in the next ack interval + var GATEWAYS_TO_REMOVE = HashMap.HashMap(0, Principal.equal, Principal.hash); /// The acknowledgement active timer. public var ACK_TIMER : ?Timer.TimerId = null; /// The keep alive active timer. @@ -80,11 +83,14 @@ module { CERT_TREE_STORE := CertTree.newStore(); CERT_TREE := CertTree.Ops(CERT_TREE_STORE); REGISTERED_GATEWAYS := HashMap.HashMap(0, Principal.equal, Principal.hash); + GATEWAYS_TO_REMOVE := HashMap.HashMap(0, Principal.equal, Principal.hash); }; /// Increments the clients connected count for the given gateway. /// If the gateway is not registered, a new entry is created with a clients connected count of 1. func increment_gateway_clients_count(gateway_principal : GatewayPrincipal) { + ignore GATEWAYS_TO_REMOVE.remove(gateway_principal); + switch (REGISTERED_GATEWAYS.get(gateway_principal)) { case (?registered_gateway) { registered_gateway.increment_clients_count(); @@ -99,32 +105,59 @@ module { /// Decrements the clients connected count for the given gateway, if it exists. /// - /// If `remove_if_empty` is true, the gateway is removed from the list of registered gateways - /// if it has no clients connected. - func decrement_gateway_clients_count(gateway_principal : GatewayPrincipal, remove_if_empty : Bool) { - let messages_keys_to_delete = Option.map( - REGISTERED_GATEWAYS.get(gateway_principal), - func(registered_gateway : RegisteredGateway) : ?List.List { + /// If the gateway has no more clients connected, it is added to the [GATEWAYS_TO_REMOVE] map, + /// in order to remove it in the next keep alive check. + func decrement_gateway_clients_count(gateway_principal : GatewayPrincipal) { + switch (REGISTERED_GATEWAYS.get(gateway_principal)) { + case (?registered_gateway) { let clients_count = registered_gateway.decrement_clients_count(); - if (remove_if_empty and clients_count == 0) { - return Option.map( - REGISTERED_GATEWAYS.remove(gateway_principal), - func(g : RegisteredGateway) : List.List { - List.map(g.messages_queue, func(m : CanisterOutputMessage) : Text { m.key }); - }, - ); + + if (clients_count == 0) { + GATEWAYS_TO_REMOVE.put(gateway_principal, Utils.get_current_time()); }; + }; + case (null) { + // do nothing + }; + }; + }; - null; + /// Removes the gateways that were added to the [GATEWAYS_TO_REMOVE] map + /// more than the ack interval ms time ago from the list of registered gateways + public func remove_empty_expired_gateways() { + let ack_interval_ms = init_params.send_ack_interval_ms; + let time = Utils.get_current_time(); + + let gateway_principals_to_remove : Buffer.Buffer = Buffer.Buffer(GATEWAYS_TO_REMOVE.size()); + GATEWAYS_TO_REMOVE := HashMap.mapFilter( + GATEWAYS_TO_REMOVE, + Principal.equal, + Principal.hash, + func(gp : GatewayPrincipal, added_at : Types.TimestampNs) : ?Types.TimestampNs { + if (time - added_at > (ack_interval_ms * 1_000_000)) { + gateway_principals_to_remove.add(gp); + null; + } else { + ?added_at; + }; }, ); - switch (Option.flatten(messages_keys_to_delete)) { - case (?keys) { - delete_keys_from_cert_tree(keys); - }; - case (null) { - // Do nothing + for (gateway_principal in gateway_principals_to_remove.vals()) { + switch ( + Option.map( + REGISTERED_GATEWAYS.remove(gateway_principal), + func(g : RegisteredGateway) : List.List { + List.map(g.messages_queue, func(m : CanisterOutputMessage) : Text { m.key }); + }, + ) + ) { + case (?messages_keys_to_delete) { + delete_keys_from_cert_tree(messages_keys_to_delete); + }; + case (null) { + // do nothing + }; }; }; }; @@ -275,9 +308,6 @@ module { /// /// If a `close_reason` is provided, it also sends a close message to the client, /// so that the client can close the WS connection with the gateway. - /// - /// If a `close_reason` is **not** provided, it also removes the gateway from the state - /// if it has no clients connected anymore. public func remove_client(client_key : ClientKey, handlers : ?WsHandlers, close_reason : ?Types.CloseMessageReason) : async () { switch (close_reason) { case (?close_reason) { @@ -296,10 +326,7 @@ module { switch (REGISTERED_CLIENTS.remove(client_key)) { case (?registered_client) { - decrement_gateway_clients_count( - registered_client.gateway_principal, - Option.isNull(close_reason), - ); + decrement_gateway_clients_count(registered_client.gateway_principal); switch (handlers) { case (?handlers) { diff --git a/src/Timers.mo b/src/Timers.mo index d45339b..99d9d7f 100644 --- a/src/Timers.mo +++ b/src/Timers.mo @@ -114,7 +114,11 @@ module { /// Checks if the clients for which we are waiting for keep alive have sent a keep alive message. /// If a client has not sent a keep alive message, it is removed from the connected clients. + /// + /// Before checking the clients, it removes all the empty expired gateways from the list of registered gateways. func check_keep_alive_timer_callback(ws_state : State.IcWebSocketState, handlers : Types.WsHandlers) : async () { + ws_state.remove_empty_expired_gateways(); + for (client_key in Array.vals(TrieSet.toArray(ws_state.CLIENTS_WAITING_FOR_KEEP_ALIVE))) { // get the last keep alive timestamp for the client and check if it has exceeded the timeout switch (ws_state.REGISTERED_CLIENTS.get(client_key)) { diff --git a/src/Types.mo b/src/Types.mo index dbbec14..4c256c9 100644 --- a/src/Types.mo +++ b/src/Types.mo @@ -229,8 +229,10 @@ module { is_end_of_queue : Bool; }; + public type TimestampNs = Nat64; + type MessageToDelete = { - timestamp : Nat64; + timestamp : TimestampNs; }; public type GatewayPrincipal = Principal; @@ -267,7 +269,7 @@ module { }; /// Adds the message to the queue and its metadata to the `messages_to_delete` queue. - public func add_message_to_queue(message : CanisterOutputMessage, message_timestamp : Nat64) { + public func add_message_to_queue(message : CanisterOutputMessage, message_timestamp : TimestampNs) { messages_queue := List.append( messages_queue, List.fromArray([message]), @@ -330,11 +332,11 @@ module { /// The metadata about a registered client. public class RegisteredClient(gw_principal : GatewayPrincipal) { - public var last_keep_alive_timestamp : Nat64 = Utils.get_current_time(); + public var last_keep_alive_timestamp : TimestampNs = Utils.get_current_time(); public let gateway_principal : GatewayPrincipal = gw_principal; /// Gets the last keep alive timestamp. - public func get_last_keep_alive_timestamp() : Nat64 { + public func get_last_keep_alive_timestamp() : TimestampNs { last_keep_alive_timestamp; }; diff --git a/tests/ic-websocket-cdk-rs b/tests/ic-websocket-cdk-rs index af5aa9a..27ef65e 160000 --- a/tests/ic-websocket-cdk-rs +++ b/tests/ic-websocket-cdk-rs @@ -1 +1 @@ -Subproject commit af5aa9aedf7f2b1ec85263434329e108248c8d19 +Subproject commit 27ef65e5e6fc662b661c596955f2fac380d9b9f4 From f7d315a5690c4db996ed81a7056669d405491afb Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Fri, 15 Dec 2023 22:47:05 +0100 Subject: [PATCH 7/8] chore: update Rust CDK submodule --- tests/ic-websocket-cdk-rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ic-websocket-cdk-rs b/tests/ic-websocket-cdk-rs index 27ef65e..e897bcc 160000 --- a/tests/ic-websocket-cdk-rs +++ b/tests/ic-websocket-cdk-rs @@ -1 +1 @@ -Subproject commit 27ef65e5e6fc662b661c596955f2fac380d9b9f4 +Subproject commit e897bccaa0bf75feaf287fbddb3c5b02a92ac760 From 658de2c3c5d22651a4b0837916fefcf477ffe85d Mon Sep 17 00:00:00 2001 From: Luca8991 Date: Fri, 15 Dec 2023 22:48:23 +0100 Subject: [PATCH 8/8] chore: bump to version v0.3.2 --- mops.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mops.toml b/mops.toml index 544313a..9c0676d 100644 --- a/mops.toml +++ b/mops.toml @@ -1,6 +1,6 @@ [package] name = "ic-websocket-cdk" -version = "0.3.1" +version = "0.3.2" description = "IC WebSocket Motoko CDK" repository = "https://github.com/omnia-network/ic-websocket-cdk-mo" keywords = ["ic", "websocket", "motoko", "cdk"]