diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..700707c
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
new file mode 100644
index 0000000..8d4b079
--- /dev/null
+++ b/.github/workflows/CI.yml
@@ -0,0 +1,75 @@
+name: CI
+on:
+ push:
+ branches:
+ - main
+ tags: ['*']
+ pull_request:
+ workflow_dispatch:
+concurrency:
+ # Skip intermediate builds: always.
+ # Cancel intermediate builds: only if it is a pull request build.
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }}
+jobs:
+ test:
+ name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 60
+ permissions: # needed to allow julia-actions/cache to proactively delete old caches that it has created
+ actions: write
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ version:
+ - '1.10'
+ - 'nightly'
+ os:
+ - ubuntu-latest
+ arch:
+ - x64
+ - x86
+ steps:
+ - uses: actions/checkout@v4
+ - uses: julia-actions/setup-julia@v1
+ with:
+ version: ${{ matrix.version }}
+ arch: ${{ matrix.arch }}
+ - uses: julia-actions/cache@v1
+ - uses: julia-actions/julia-buildpkg@v1
+ - uses: julia-actions/julia-runtest@v1
+ - uses: julia-actions/julia-processcoverage@v1
+ - uses: codecov/codecov-action@v3
+ with:
+ files: lcov.info
+ docs:
+ name: Documentation
+ runs-on: ubuntu-latest
+ permissions:
+ actions: write # needed to allow julia-actions/cache to proactively delete old caches that it has created
+ contents: write
+ statuses: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: julia-actions/setup-julia@v1
+ with:
+ version: '1'
+ - uses: julia-actions/cache@v1
+ - name: Configure doc environment
+ shell: julia --project=docs --color=yes {0}
+ run: |
+ using Pkg
+ Pkg.develop(PackageSpec(path=pwd()))
+ Pkg.instantiate()
+ - uses: julia-actions/julia-buildpkg@v1
+ - uses: julia-actions/julia-docdeploy@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Run doctests
+ shell: julia --project=docs --color=yes {0}
+ run: |
+ using Documenter: DocMeta, doctest
+ using Rembus
+ DocMeta.setdocmeta!(Rembus, :DocTestSetup, :(using Rembus); recursive=true)
+ doctest(Rembus)
diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml
new file mode 100644
index 0000000..cba9134
--- /dev/null
+++ b/.github/workflows/CompatHelper.yml
@@ -0,0 +1,16 @@
+name: CompatHelper
+on:
+ schedule:
+ - cron: 0 0 * * *
+ workflow_dispatch:
+jobs:
+ CompatHelper:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Pkg.add("CompatHelper")
+ run: julia -e 'using Pkg; Pkg.add("CompatHelper")'
+ - name: CompatHelper.main()
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ COMPATHELPER_PRIV: ${{ secrets.DOCUMENTER_KEY }}
+ run: julia -e 'using CompatHelper; CompatHelper.main()'
diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml
new file mode 100644
index 0000000..2bacdb8
--- /dev/null
+++ b/.github/workflows/TagBot.yml
@@ -0,0 +1,31 @@
+name: TagBot
+on:
+ issue_comment:
+ types:
+ - created
+ workflow_dispatch:
+ inputs:
+ lookback:
+ default: 3
+permissions:
+ actions: read
+ checks: read
+ contents: write
+ deployments: read
+ issues: read
+ discussions: read
+ packages: read
+ pages: read
+ pull-requests: read
+ repository-projects: read
+ security-events: read
+ statuses: read
+jobs:
+ TagBot:
+ if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: JuliaRegistries/TagBot@v1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ ssh: ${{ secrets.DOCUMENTER_KEY }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fb67b66
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+*.jl.*.cov
+*.jl.cov
+*.jl.mem
+*.sav
+/docs/Manifest.toml
+/docs/build/
+/build
+/keystore
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8d20770
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM julia:1.10.2
+
+WORKDIR /caronte
+
+COPY build .
+
+EXPOSE 8000
+EXPOSE 8001
+EXPOSE 8002
+
+ENV REMBUS_DB="/db"
+
+ENTRYPOINT ["bin/caronte"]
+
+
diff --git a/KEYSTORE.md b/KEYSTORE.md
new file mode 100644
index 0000000..6806be0
--- /dev/null
+++ b/KEYSTORE.md
@@ -0,0 +1,24 @@
+# Secure connections setup
+
+Encrypted WebSocket and TLS connections need keys and certificates adhering to the following rules:
+
+- The rembus broker private key is named `caronte.key`.
+- The rembus signed certificate is named `caronte.crt`.
+- The environment variable `REMBUS_KEYSTORE` define the directory where `caronte.key` and `caronte.crt` must be located.
+- The full path of the certificate or the bundle containing the CA that signed `caronte.crt` must be specified with the standard env variable `HTTP_CA_BUNDLE`.
+
+`$REMBUS_KEYSTORE/rembus-ca.crt` is the default value for `HTTP_CA_BUNDLE` if it is unset.
+
+## Secrets materials: quick bootstrapping
+
+The utility script `init keystore` may be tweaked or used as is to generate the secrets and a CA:
+
+```shell
+myhost:Rembus.jl> bin/init_keystore [-n server_dns] [-i ip_address] [-k keystore_dir]
+```
+
+### options
+
+- `-i` register an ip address that may be used to connect securely.
+- `-n` DNS server name (default to `caronte`).
+- `-k` generated directory name containing the secret materials (default to `$HOME/keystore`).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7974ebf
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,677 @@
+Copyright (C) 2024 Attilio Donà, Claudio Carraro
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/Manifest.toml b/Manifest.toml
new file mode 100644
index 0000000..0c47ba2
--- /dev/null
+++ b/Manifest.toml
@@ -0,0 +1,575 @@
+# This file is machine-generated - editing it directly is not advised
+
+julia_version = "1.10.2"
+manifest_format = "2.0"
+project_hash = "3d32120aa01f2d49d2640e9b4452922369f876c4"
+
+[[deps.ArgParse]]
+deps = ["Logging", "TextWrap"]
+git-tree-sha1 = "d4eccacaa3a632e8717556479d45502af44b4c17"
+uuid = "c7e460c6-2fb9-53a9-8c5b-16f535851c63"
+version = "1.1.5"
+
+[[deps.ArgTools]]
+uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f"
+version = "1.1.1"
+
+[[deps.Arrow]]
+deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "LoggingExtras", "Mmap", "PooledArrays", "SentinelArrays", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"]
+git-tree-sha1 = "29faa9835f77dee04b2833b2c9ee415223b3ebbd"
+uuid = "69666777-d1a9-59fb-9406-91d4454c9d45"
+version = "2.7.1"
+
+[[deps.ArrowTypes]]
+deps = ["Sockets", "UUIDs"]
+git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
+uuid = "31f734f8-188a-4ce0-8406-c8a06bd891cd"
+version = "2.3.0"
+
+[[deps.Artifacts]]
+uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
+
+[[deps.Base64]]
+uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
+
+[[deps.BitFlags]]
+git-tree-sha1 = "2dc09997850d68179b69dafb58ae806167a32b1b"
+uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35"
+version = "0.1.8"
+
+[[deps.BitIntegers]]
+deps = ["Random"]
+git-tree-sha1 = "a55462dfddabc34bc97d3a7403a2ca2802179ae6"
+uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
+version = "0.3.1"
+
+[[deps.CSV]]
+deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"]
+git-tree-sha1 = "a44910ceb69b0d44fe262dd451ab11ead3ed0be8"
+uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
+version = "0.10.13"
+
+[[deps.CodecLz4]]
+deps = ["Lz4_jll", "TranscodingStreams"]
+git-tree-sha1 = "b8aecef9f90530cf322a8386630ec18485c17991"
+uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561"
+version = "0.4.3"
+
+[[deps.CodecZlib]]
+deps = ["TranscodingStreams", "Zlib_jll"]
+git-tree-sha1 = "59939d8a997469ee05c4b4944560a820f9ba0d73"
+uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
+version = "0.7.4"
+
+[[deps.CodecZstd]]
+deps = ["TranscodingStreams", "Zstd_jll"]
+git-tree-sha1 = "23373fecba848397b1705f6183188a0c0bc86917"
+uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2"
+version = "0.8.2"
+
+[[deps.Compat]]
+deps = ["TOML", "UUIDs"]
+git-tree-sha1 = "c955881e3c981181362ae4088b35995446298b80"
+uuid = "34da2185-b29b-5c13-b0c7-acf172513d20"
+version = "4.14.0"
+weakdeps = ["Dates", "LinearAlgebra"]
+
+ [deps.Compat.extensions]
+ CompatLinearAlgebraExt = "LinearAlgebra"
+
+[[deps.CompilerSupportLibraries_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae"
+version = "1.1.0+0"
+
+[[deps.ConcurrentUtilities]]
+deps = ["Serialization", "Sockets"]
+git-tree-sha1 = "6cbbd4d241d7e6579ab354737f4dd95ca43946e1"
+uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
+version = "2.4.1"
+
+[[deps.Crayons]]
+git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15"
+uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f"
+version = "4.1.1"
+
+[[deps.DataAPI]]
+git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe"
+uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a"
+version = "1.16.0"
+
+[[deps.DataFrames]]
+deps = ["Compat", "DataAPI", "DataStructures", "Future", "InlineStrings", "InvertedIndices", "IteratorInterfaceExtensions", "LinearAlgebra", "Markdown", "Missings", "PooledArrays", "PrecompileTools", "PrettyTables", "Printf", "REPL", "Random", "Reexport", "SentinelArrays", "SortingAlgorithms", "Statistics", "TableTraits", "Tables", "Unicode"]
+git-tree-sha1 = "04c738083f29f86e62c8afc341f0967d8717bdb8"
+uuid = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
+version = "1.6.1"
+
+[[deps.DataStructures]]
+deps = ["Compat", "InteractiveUtils", "OrderedCollections"]
+git-tree-sha1 = "0f4b5d62a88d8f59003e43c25a8a90de9eb76317"
+uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
+version = "0.18.18"
+
+[[deps.DataValueInterfaces]]
+git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6"
+uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464"
+version = "1.0.0"
+
+[[deps.Dates]]
+deps = ["Printf"]
+uuid = "ade2ca70-3891-5945-98fb-dc099432e06a"
+
+[[deps.Distributed]]
+deps = ["Random", "Serialization", "Sockets"]
+uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b"
+
+[[deps.DocStringExtensions]]
+deps = ["LibGit2"]
+git-tree-sha1 = "2fb1e02f2b635d0845df5d7c167fec4dd739b00d"
+uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+version = "0.9.3"
+
+[[deps.Downloads]]
+deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"]
+uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
+version = "1.6.0"
+
+[[deps.EnumX]]
+git-tree-sha1 = "bdb1942cd4c45e3c678fd11569d5cccd80976237"
+uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56"
+version = "1.0.4"
+
+[[deps.ExceptionUnwrapping]]
+deps = ["Test"]
+git-tree-sha1 = "dcb08a0d93ec0b1cdc4af184b26b591e9695423a"
+uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4"
+version = "0.1.10"
+
+[[deps.ExprTools]]
+git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec"
+uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04"
+version = "0.1.10"
+
+[[deps.FilePathsBase]]
+deps = ["Compat", "Dates", "Mmap", "Printf", "Test", "UUIDs"]
+git-tree-sha1 = "9f00e42f8d99fdde64d40c8ea5d14269a2e2c1aa"
+uuid = "48062228-2e41-5def-b9a4-89aafe57970f"
+version = "0.9.21"
+
+[[deps.FileWatching]]
+uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee"
+
+[[deps.Future]]
+deps = ["Random"]
+uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820"
+
+[[deps.HTTP]]
+deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"]
+git-tree-sha1 = "995f762e0182ebc50548c434c171a5bb6635f8e4"
+uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
+version = "1.10.4"
+
+[[deps.InlineStrings]]
+deps = ["Parsers"]
+git-tree-sha1 = "9cc2baf75c6d09f9da536ddf58eb2f29dedaf461"
+uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48"
+version = "1.4.0"
+
+[[deps.InteractiveUtils]]
+deps = ["Markdown"]
+uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
+
+[[deps.InvertedIndices]]
+git-tree-sha1 = "0dc7b50b8d436461be01300fd8cd45aa0274b038"
+uuid = "41ab1584-1d38-5bbf-9106-f11c6c58b48f"
+version = "1.3.0"
+
+[[deps.IteratorInterfaceExtensions]]
+git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856"
+uuid = "82899510-4779-5014-852e-03e436cf321d"
+version = "1.0.0"
+
+[[deps.JLLWrappers]]
+deps = ["Artifacts", "Preferences"]
+git-tree-sha1 = "7e5d6779a1e09a36db2a7b6cff50942a0a7d0fca"
+uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210"
+version = "1.5.0"
+
+[[deps.JSON3]]
+deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"]
+git-tree-sha1 = "eb3edce0ed4fa32f75a0a11217433c31d56bd48b"
+uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+version = "1.14.0"
+weakdeps = ["ArrowTypes"]
+
+ [deps.JSON3.extensions]
+ JSON3ArrowExt = ["ArrowTypes"]
+
+[[deps.LaTeXStrings]]
+git-tree-sha1 = "50901ebc375ed41dbf8058da26f9de442febbbec"
+uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f"
+version = "1.3.1"
+
+[[deps.LazyArtifacts]]
+deps = ["Artifacts", "Pkg"]
+uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3"
+
+[[deps.LibCURL]]
+deps = ["LibCURL_jll", "MozillaCACerts_jll"]
+uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21"
+version = "0.6.4"
+
+[[deps.LibCURL_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"]
+uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0"
+version = "8.4.0+0"
+
+[[deps.LibGit2]]
+deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"]
+uuid = "76f85450-5226-5b5a-8eaa-529ad045b433"
+
+[[deps.LibGit2_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"]
+uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5"
+version = "1.6.4+0"
+
+[[deps.LibSSH2_jll]]
+deps = ["Artifacts", "Libdl", "MbedTLS_jll"]
+uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8"
+version = "1.11.0+1"
+
+[[deps.Libdl]]
+uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
+
+[[deps.LinearAlgebra]]
+deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"]
+uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+
+[[deps.Logging]]
+uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
+
+[[deps.LoggingExtras]]
+deps = ["Dates", "Logging"]
+git-tree-sha1 = "c1dd6d7978c12545b4179fb6153b9250c96b0075"
+uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36"
+version = "1.0.3"
+
+[[deps.Lz4_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl"]
+git-tree-sha1 = "6c26c5e8a4203d43b5497be3ec5d4e0c3cde240a"
+uuid = "5ced341a-0733-55b8-9ab6-a4889d929147"
+version = "1.9.4+0"
+
+[[deps.Markdown]]
+deps = ["Base64"]
+uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
+
+[[deps.MbedTLS]]
+deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"]
+git-tree-sha1 = "c067a280ddc25f196b5e7df3877c6b226d390aaf"
+uuid = "739be429-bea8-5141-9913-cc70e7f3736d"
+version = "1.1.9"
+
+[[deps.MbedTLS_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1"
+version = "2.28.2+1"
+
+[[deps.Missings]]
+deps = ["DataAPI"]
+git-tree-sha1 = "f66bdc5de519e8f8ae43bdc598782d35a25b1272"
+uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28"
+version = "1.1.0"
+
+[[deps.Mmap]]
+uuid = "a63ad114-7e13-5084-954f-fe012c677804"
+
+[[deps.Mocking]]
+deps = ["Compat", "ExprTools"]
+git-tree-sha1 = "4cc0c5a83933648b615c36c2b956d94fda70641e"
+uuid = "78c3b35d-d492-501b-9361-3d52fe80e533"
+version = "0.7.7"
+
+[[deps.MozillaCACerts_jll]]
+uuid = "14a3606d-f60d-562e-9121-12d972cd8159"
+version = "2023.1.10"
+
+[[deps.NetworkOptions]]
+uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908"
+version = "1.2.0"
+
+[[deps.OpenBLAS_jll]]
+deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"]
+uuid = "4536629a-c528-5b80-bd46-f80d51c5b363"
+version = "0.3.23+4"
+
+[[deps.OpenSSL]]
+deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "OpenSSL_jll", "Sockets"]
+git-tree-sha1 = "af81a32750ebc831ee28bdaaba6e1067decef51e"
+uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c"
+version = "1.4.2"
+
+[[deps.OpenSSL_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl"]
+git-tree-sha1 = "60e3045590bd104a16fefb12836c00c0ef8c7f8c"
+uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95"
+version = "3.0.13+0"
+
+[[deps.OrderedCollections]]
+git-tree-sha1 = "dfdf5519f235516220579f949664f1bf44e741c5"
+uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+version = "1.6.3"
+
+[[deps.Parameters]]
+deps = ["OrderedCollections", "UnPack"]
+git-tree-sha1 = "34c0e9ad262e5f7fc75b10a9952ca7692cfc5fbe"
+uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a"
+version = "0.12.3"
+
+[[deps.Parsers]]
+deps = ["Dates", "PrecompileTools", "UUIDs"]
+git-tree-sha1 = "8489905bcdbcfac64d1daa51ca07c0d8f0283821"
+uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0"
+version = "2.8.1"
+
+[[deps.Pkg]]
+deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"]
+uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
+version = "1.10.0"
+
+[[deps.PooledArrays]]
+deps = ["DataAPI", "Future"]
+git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3"
+uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720"
+version = "1.4.3"
+
+[[deps.PrecompileTools]]
+deps = ["Preferences"]
+git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f"
+uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
+version = "1.2.1"
+
+[[deps.Preferences]]
+deps = ["TOML"]
+git-tree-sha1 = "9306f6085165d270f7e3db02af26a400d580f5c6"
+uuid = "21216c6a-2e73-6563-6e65-726566657250"
+version = "1.4.3"
+
+[[deps.PrettyTables]]
+deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "Reexport", "StringManipulation", "Tables"]
+git-tree-sha1 = "88b895d13d53b5577fd53379d913b9ab9ac82660"
+uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d"
+version = "2.3.1"
+
+[[deps.Printf]]
+deps = ["Unicode"]
+uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+
+[[deps.REPL]]
+deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"]
+uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
+
+[[deps.Random]]
+deps = ["SHA"]
+uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+
+[[deps.Reexport]]
+git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b"
+uuid = "189a3867-3050-52da-a836-e630ba90ab69"
+version = "1.2.2"
+
+[[deps.SHA]]
+uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
+version = "0.7.0"
+
+[[deps.Scratch]]
+deps = ["Dates"]
+git-tree-sha1 = "3bac05bc7e74a75fd9cba4295cde4045d9fe2386"
+uuid = "6c6a2e73-6563-6170-7368-637461726353"
+version = "1.2.1"
+
+[[deps.SentinelArrays]]
+deps = ["Dates", "Random"]
+git-tree-sha1 = "0e7508ff27ba32f26cd459474ca2ede1bc10991f"
+uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c"
+version = "1.4.1"
+
+[[deps.Serialization]]
+uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
+
+[[deps.SimpleBufferStream]]
+git-tree-sha1 = "874e8867b33a00e784c8a7e4b60afe9e037b74e1"
+uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7"
+version = "1.1.0"
+
+[[deps.Sockets]]
+uuid = "6462fe0b-24de-5631-8697-dd941f90decc"
+
+[[deps.SortingAlgorithms]]
+deps = ["DataStructures"]
+git-tree-sha1 = "66e0a8e672a0bdfca2c3f5937efb8538b9ddc085"
+uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c"
+version = "1.2.1"
+
+[[deps.SparseArrays]]
+deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"]
+uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
+version = "1.10.0"
+
+[[deps.Statistics]]
+deps = ["LinearAlgebra", "SparseArrays"]
+uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
+version = "1.10.0"
+
+[[deps.StringManipulation]]
+deps = ["PrecompileTools"]
+git-tree-sha1 = "a04cabe79c5f01f4d723cc6704070ada0b9d46d5"
+uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
+version = "0.3.4"
+
+[[deps.StructTypes]]
+deps = ["Dates", "UUIDs"]
+git-tree-sha1 = "ca4bccb03acf9faaf4137a9abc1881ed1841aa70"
+uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4"
+version = "1.10.0"
+
+[[deps.SuiteSparse_jll]]
+deps = ["Artifacts", "Libdl", "libblastrampoline_jll"]
+uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c"
+version = "7.2.1+1"
+
+[[deps.TOML]]
+deps = ["Dates"]
+uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
+version = "1.0.3"
+
+[[deps.TZJData]]
+deps = ["Artifacts"]
+git-tree-sha1 = "b69f8338df046774bd975b13be9d297eca5efacb"
+uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7"
+version = "1.1.0+2023d"
+
+[[deps.TableTraits]]
+deps = ["IteratorInterfaceExtensions"]
+git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39"
+uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c"
+version = "1.0.1"
+
+[[deps.Tables]]
+deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "OrderedCollections", "TableTraits"]
+git-tree-sha1 = "cb76cf677714c095e535e3501ac7954732aeea2d"
+uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
+version = "1.11.1"
+
+[[deps.Tar]]
+deps = ["ArgTools", "SHA"]
+uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e"
+version = "1.10.0"
+
+[[deps.Test]]
+deps = ["InteractiveUtils", "Logging", "Random", "Serialization"]
+uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[[deps.TextWrap]]
+git-tree-sha1 = "9250ef9b01b66667380cf3275b3f7488d0e25faf"
+uuid = "b718987f-49a8-5099-9789-dcd902bef87d"
+version = "1.0.1"
+
+[[deps.TimeZones]]
+deps = ["Artifacts", "Dates", "Downloads", "InlineStrings", "LazyArtifacts", "Mocking", "Printf", "Scratch", "TZJData", "Unicode", "p7zip_jll"]
+git-tree-sha1 = "89e64d61ef3cd9e80f7fc12b7d13db2d75a23c03"
+uuid = "f269a46b-ccf7-5d73-abea-4c690281aa53"
+version = "1.13.0"
+
+ [deps.TimeZones.extensions]
+ TimeZonesRecipesBaseExt = "RecipesBase"
+
+ [deps.TimeZones.weakdeps]
+ RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+
+[[deps.TranscodingStreams]]
+git-tree-sha1 = "a09c933bebed12501890d8e92946bbab6a1690f1"
+uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa"
+version = "0.10.5"
+weakdeps = ["Random", "Test"]
+
+ [deps.TranscodingStreams.extensions]
+ TestExt = ["Test", "Random"]
+
+[[deps.URIs]]
+git-tree-sha1 = "67db6cc7b3821e19ebe75791a9dd19c9b1188f2b"
+uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
+version = "1.5.1"
+
+[[deps.UUIDs]]
+deps = ["Random", "SHA"]
+uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
+
+[[deps.UnPack]]
+git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b"
+uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed"
+version = "1.0.2"
+
+[[deps.Unicode]]
+uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
+
+[[deps.Visor]]
+deps = ["DataStructures", "Dates", "Logging", "UUIDs"]
+git-tree-sha1 = "232783e703febab4ccb5e651fd562fbf673df8ff"
+uuid = "cf786855-3531-4b86-ba6e-3e33dce7dcdb"
+version = "0.6.0"
+
+[[deps.WeakRefStrings]]
+deps = ["DataAPI", "InlineStrings", "Parsers"]
+git-tree-sha1 = "b1be2855ed9ed8eac54e5caff2afcdb442d52c23"
+uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5"
+version = "1.4.2"
+
+[[deps.WorkerUtilities]]
+git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7"
+uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60"
+version = "1.6.1"
+
+[[deps.ZMQ]]
+deps = ["FileWatching", "Sockets", "ZeroMQ_jll"]
+git-tree-sha1 = "356d2bdcc0bce90aabee1d1c0f6d6f301eda8f77"
+uuid = "c2297ded-f4af-51ae-bb23-16f91089e4e1"
+version = "1.2.2"
+
+[[deps.ZeroMQ_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl", "libsodium_jll"]
+git-tree-sha1 = "42f97fb27394378591666ab0e9cee369e6d0e1f9"
+uuid = "8f1865be-045e-5c20-9c9f-bfbfb0764568"
+version = "4.3.5+0"
+
+[[deps.Zlib_jll]]
+deps = ["Libdl"]
+uuid = "83775a58-1f1d-513f-b197-d71354ab007a"
+version = "1.2.13+1"
+
+[[deps.Zstd_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl"]
+git-tree-sha1 = "49ce682769cd5de6c72dcf1b94ed7790cd08974c"
+uuid = "3161d3a3-bdf6-5164-811a-617609db77b4"
+version = "1.5.5+0"
+
+[[deps.libblastrampoline_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "8e850b90-86db-534c-a0d3-1478176c7d93"
+version = "5.8.0+1"
+
+[[deps.libsodium_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"]
+git-tree-sha1 = "848ab3d00fe39d6fbc2a8641048f8f272af1c51e"
+uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
+version = "1.0.20+0"
+
+[[deps.nghttp2_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+version = "1.52.0+1"
+
+[[deps.p7zip_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0"
+version = "17.4.0+2"
diff --git a/Project.toml b/Project.toml
new file mode 100644
index 0000000..a7dad77
--- /dev/null
+++ b/Project.toml
@@ -0,0 +1,65 @@
+name = "Rembus"
+uuid = "aa126574-2ec3-42c9-8bf5-7f62001d6bce"
+authors = ["Attilio "]
+version = "0.1.0"
+
+[deps]
+ArgParse = "c7e460c6-2fb9-53a9-8c5b-16f535851c63"
+Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
+Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
+CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
+DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
+DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
+Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
+Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b"
+DocStringExtensions = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+FileWatching = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee"
+HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
+MbedTLS = "739be429-bea8-5141-9913-cc70e7f3736d"
+Parameters = "d96e819e-fc66-5662-9728-84c9c7592b0a"
+PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
+Preferences = "21216c6a-2e73-6563-6e65-726566657250"
+Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+Reexport = "189a3867-3050-52da-a836-e630ba90ab69"
+Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
+Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
+URIs = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
+UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
+Visor = "cf786855-3531-4b86-ba6e-3e33dce7dcdb"
+ZMQ = "c2297ded-f4af-51ae-bb23-16f91089e4e1"
+
+[compat]
+ArgParse = "1"
+Arrow = "2"
+CSV = "0.10"
+DataFrames = "1"
+DataStructures = "0.18"
+DocStringExtensions = "0.9"
+HTTP = "1"
+JSON3 = "1"
+MbedTLS = "1"
+Parameters = "0.12"
+PrecompileTools = "1"
+Preferences = "1"
+Reexport = "1"
+URIs = "1"
+Visor = "0"
+ZMQ = "1"
+
+[extras]
+SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f"
+Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[preferences.Rembus]
+metering = false
+offline_store = "disk"
+overwrite_connection = true
+pathlib = "lib"
+rembus_cfg = "CaronteExt"
+stacktrace = false
+
+[targets]
+test = ["Test", "SafeTestsets"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b77ce3a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+# Rembus
+
+[](https://attdona.github.io/Rembus.jl/stable/)
+[](https://attdona.github.io/Rembus.jl/dev/)
+[](https://github.com/attdona/Rembus.jl/actions/workflows/CI.yml?query=branch%3Amain)
+[](https://codecov.io/gh/attdona/Rembus.jl)
+[](https://github.com/invenia/BlueStyle)
+
+Rembus is a middleware to implement high performance and fault-tolerant distributed applications.
+
+## Key Features
+
+* Built-in support for exchanging DataFrames.
+
+* Macro-based API that make writing RPC and Pub/Sub applications simple and fast.
+
+* Multiple transport protocols: Tcp, Web Socket, ZeroMQ.
+
+* Binary message encoding using [CBOR](https://cbor.io/).
+
+## The broker
+
+Start the broker:
+
+```sh
+julia -e "using Rembus; caronte()"
+```
+
+## RPC server
+
+```julia
+@component "myserver"
+
+function myservice(arg1)
+ return "hello $arg1 💗"
+end
+
+@expose myservice
+
+# Serve forever until Ctrl-C
+# in REPL forever() is not needed
+forever()
+```
+
+> The `@component` macro declares a unique name for the component that get known to the broker.
+> On the broker side such identity permits to bind a twin operating on the behalf of the component either when it is offline.
+
+## RPC client
+
+```julia
+response = @rpc myservice("rembus")
+```
+
+> When a name is not declared with `@component` then a random uuid identifier is associated with the component each time the application starts.
+
+## Pub/Sub subscriber
+
+```julia
+@component "myconsumer"
+
+function mytopic(df::DataFrame)
+ println("mean_a=$(mean(df.a)), mean_b=$(mean(df.b))")
+end
+
+@subscribe mytopic
+
+# forever() serves forever until Ctrl-C
+# in REPL forever() is not needed
+forever()
+```
+
+## Pub/Sub publisher
+
+```julia
+df = DataFrame(a=1:1_000_000, b=rand(1_000_000))
+
+@publish mytopic(df)
+```
diff --git a/bin/caronte b/bin/caronte
new file mode 100755
index 0000000..0122f1d
--- /dev/null
+++ b/bin/caronte
@@ -0,0 +1,9 @@
+#!/bin/bash
+#=
+BINDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+exec julia --threads auto --color=no -e "include(popfirst!(ARGS))" \
+ --project=$BINDIR/.. --startup-file=no "${BASH_SOURCE[0]}" "$@"
+=#
+using Rembus
+
+Rembus.caronte()
\ No newline at end of file
diff --git a/bin/create_app b/bin/create_app
new file mode 100755
index 0000000..bf0fe9b
--- /dev/null
+++ b/bin/create_app
@@ -0,0 +1,16 @@
+#!/bin/bash
+BIN_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+PKG_DIR=$(dirname $BIN_DIR)
+
+cat << EOF | julia --startup-file=no --threads auto --project=${PKG_DIR}
+
+using PackageCompiler
+
+try
+ create_app(".", "build"; executables = ["caronte"=>"caronted"], force=true)
+catch e
+ println(e)
+ exit(1)
+end
+EOF
+
diff --git a/bin/init_keystore b/bin/init_keystore
new file mode 100755
index 0000000..99cde2f
--- /dev/null
+++ b/bin/init_keystore
@@ -0,0 +1,78 @@
+#! /bin/bash
+
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+
+NAME=caronte
+DNS_NAME=$NAME
+KEYSTORE=${HOME}/keystore
+TRUSTED_IP_ADDRESS=""
+
+echo $KEYSTORE
+
+while getopts 'n:k:i:h' opt; do
+ case "$opt" in
+ k)
+ KEYSTORE=${OPTARG}
+ ;;
+ n)
+ DNS_NAME=${OPTARG}
+ ;;
+ i)
+ TRUSTED_IP_ADDRESS=${OPTARG}
+ ;;
+ ?|h)
+ echo "Usage: $(basename $0) [-n server_dns] [-i ip_address] [-k keystore_dir]"
+ exit 1
+ ;;
+ esac
+done
+shift "$(($OPTIND -1))"
+
+if [ -d $KEYSTORE ]
+then
+ echo "keystore dir exists, init aborted"
+ exit 1
+else
+ mkdir $KEYSTORE
+ cd $KEYSTORE
+fi
+
+# Create root CA
+CA_CRT=rembus-ca.crt
+CA_KEY=rembus-ca.key
+
+openssl req -x509 \
+ -sha256 -days 356 \
+ -nodes \
+ -newkey rsa:2048 \
+ -subj "/CN=Rembus/C=IT/L=Trento" \
+ -keyout ${CA_KEY} -out ${CA_CRT}
+
+# create ext file
+cat > ${NAME}.ext <> ${NAME}.ext < "index.md",
+ "Configuration" => "configuration.md",
+ "Plain API" => "api.md",
+ "Supervised API" => "supervised_api.md",
+ ],
+)
+
+deploydocs(; repo="github.com/cardo-org/Rembus.jl", devbranch="main")
diff --git a/docs/src/api.md b/docs/src/api.md
new file mode 100644
index 0000000..ae918d5
--- /dev/null
+++ b/docs/src/api.md
@@ -0,0 +1,27 @@
+# Plain API
+
+There is a set of functions that provide a plain API:
+
+- connect
+- publish
+- rpc
+- expose
+- subscribe
+- unexpose
+- unsubscribe
+- close
+
+The `connect` function returns a connection handle used by the other APIs for exchanging data and commands.
+
+> This API does not provide automatic reconnection in case of network
+failures, if this happen the exception must be handled explicitly by the application.
+
+```julia
+using Rembus
+
+rb = connect("mycomponent")
+
+publish(rb, "metric", Dict("name"=>"trento/castello", "var"=>"T", "value"=>21.0))
+
+close(rb)
+```
diff --git a/docs/src/cheatsheet.md b/docs/src/cheatsheet.md
new file mode 100644
index 0000000..3711c46
--- /dev/null
+++ b/docs/src/cheatsheet.md
@@ -0,0 +1,97 @@
+# Rembus Cheat Sheet
+
+## Startup and teardown
+
+Connect to the broker with identity `myname`:
+
+```julia
+@component "myname"
+```
+
+Close the connection and terminate the component:
+
+```julia
+@terminate
+```
+
+Loop unless `Ctrl-C` or `shutdown()`:
+
+```julia
+forever()
+```
+
+> **NOTE:** `forever` is required by `@subscribe` and `@expose` unless you are in the REPL.
+
+Terminate background Rembus task and return from `forever()`:
+
+```julia
+shutdown()
+```
+
+## Pub/Sub: 1 publisher and N subscribers
+
+Publish a message with topic `mytopic` and data payload that is the CBOR encoding
+of `[arg1, arg2, arg3]`:
+
+```julia
+@publish mytopic(arg1, arg2, arg3)
+```
+
+Subscribe to topic `mytopic`, the arguments `arg1, arg2, arg3` are the CBOR decoded
+values of the data payload:
+
+```julia
+# Method `mytopic` is called for each published message.
+function mytopic(arg1, arg2, arg3)
+ # do something
+end
+
+# Two different modes of subscription:
+@subscribe mytopic from_now # declare interest to topic mytopic handling newer messages
+@subscribe mytopic before_now # messages from the past and not received because offline
+@subscribe mytopic # default to from_now
+```
+
+Start and stop to call subscribed methods when a published message is received:
+
+```reactive
+@reactive
+@reactive_off
+```
+
+Remove the topic subscription:
+
+```julia
+@unsubscribe mytopic
+```
+
+By default reactive in enabled.
+
+## Remote Procedure Call
+
+Call the remote method `myrpc` exposed by a component:
+
+```julia
+response = @rpc myrpc(arg1, arg2)
+```
+
+> **NOTE:** in case of successfull invocation the `response` value is the remote method return value, othervise an exception is thrown.
+
+Expose a method implementation:
+
+```julia
+function myrpc(arg1, arg2)
+ # evaluate body and return response ...
+ return response
+end
+
+@expose myrpc(arg1, arg2)
+```
+
+Stop to serve the RPC method:
+
+```julia
+@unexpose myrpc
+```
+
+
diff --git a/docs/src/configuration.md b/docs/src/configuration.md
new file mode 100644
index 0000000..01a5948
--- /dev/null
+++ b/docs/src/configuration.md
@@ -0,0 +1,147 @@
+# Configuartion
+
+## Broker environment variables
+
+The broker setup is affected by the following environment variables.
+
+| Variable |Default| Descr |
+|----------|-------|-------|
+|`BROKER_DB`|\$HOME/caronte | Root dir for configuration files and cached messages to be delivered to offline components opting for retroactive mode|
+|`BROKER_TCP_PORT`|8000|use `tls://:$BROKER_TCP_PORT` for serving TLS protocol|
+|`BROKER_WS_PORT`|8001|use `wss://:$BROKER_WS_PORT` for serving WSS protocol|
+|`BROKER_ZMQ_PORT`|8002|ZeroMQ port `zmq://:$BROKER_ZMQ_PORT`|
+|`REMBUS_DEBUG`|0| "1": enable debug traces|
+|`REMBUS_KEYSTORE`|\$HOME/keystore| Directory of broker certificate `caronte.crt` and broker secret key `caronte.key`|
+
+## Component environment variables
+
+A Rembus component is affected by the following environement variables.
+
+| Variable |Default| Descr |
+|----------|-------|-------|
+|`REMBUS_BASE_URL`|ws://localhost:8000|Default base url when defining component with a simple string instead of a complete url. `@component "myclient"` is equivalent to `@component` `"ws://localhost:8000/myclient"`|
+|`REMBUS_CA`|rembus-ca.crt|CA certificate file name. This file has to be in `$REMBUS_KEYSTORE` directory|
+|`REMBUS_DEBUG`|0| "1": enable debug traces|
+|`REMBUS_KEYSTORE`|\$HOME/keystore| Directory of CA certificate|
+|`REMBUS_TIMEOUT`|5| Maximum number of seconds waiting for rpc responses|
+|`HTTP_CA_BUNDLE`|\$REMBUS\_KEYSTORE/\$REMBUS\_CA|CA certificate|
+
+## Database structure
+
+Rembus configuration data and secret materials is persisted to `BROKER_DB` directory.
+
+The database directory has the following layout:
+
+```sh
+> cd $REMBUS_DB
+> tree .
+
+.
+├── admins.json
+├── apps
+│ ├── bar
+│ └── foo
+├── impls.json
+├── interests.json
+├── owners.csv
+├── token_app.csv
+├── topic_auth.json
+├── twins
+ ├── bar
+ └── foo
+
+
+```
+
+where `foo` and `bar` are the component identifiers (`cid`) of the two registered components that have at least one retroactive topic.
+
+In case the component are offline the undelivered messages are temporarly persisted into `twins/bar` and `/twins/foo` files.
+
+### Admin privileges
+
+`admins.json` contains the array of `cid` entries that have admin permission.
+
+```text
+> cat admins.json
+["foo", "bar"]
+```
+
+### RPC implementors
+
+`impls.json` is a map with `topic` as keywords and an array of `cid` as values that define which component implements which rpc topic.
+
+For example:
+
+```text
+> cat impls.json
+{
+ "topic_1":["foo"],
+ "topic_2":["foo", "bar"]
+}
+```
+
+* `foo` component implements `topic_1` and `topic_2` rpc methods.
+* `bar` component implements `topic_2` rpc method.
+
+### PubSub subscribers
+
+`twins.json` is a map with `cid` as keyword and a map as value.
+
+The keys of the map are the subscribed topics and the boolean value is true if the
+subscription is retroactive:
+
+```text
+> cat twins.json
+{
+ "mycomponent":{"mytopic1": true, "mytopic2": false}
+}
+```
+
+`mycomponent` is interested to `mytopic1` and `mytopic2` messages, and `mytopic2` subscribed
+with option `before_now`:
+
+```julia
+@subscribe mytopic2 before_now
+```
+
+### Private topics
+
+`topic_auth.json` is a map with `topic` as keywords and an array of `cid` as values.
+
+For example if:
+
+```text
+> cat topic_auth..json
+{
+ "foo":["myconsumer","myproducer"]
+}
+```
+
+then only components `myconsumer` and `myproducer` are allowed to bind to the topic `foo`.
+
+### Users authorized to register components
+
+`owners.csv` is a csv file containing users allowed to register components.
+
+The `pin` column is the PIN token needed for registration.
+
+```text
+> cat owners.csv
+pin,uid,name,enabled
+482dc7eb,paperoga@topolinia.com,Paperoga,false
+58e26283,paperino@topolinia.com,Paperino,false
+```
+
+### Components ownership
+
+`token_app.csv` is a csv file containing the mapping between the registered components and the user that performed the registration.
+
+`uid` is the user identity and `app` is the component identifier.
+
+For example if the user Paperoga registered the component `foo` then:
+
+```text
+> cat token_app.csv
+uid,app
+paperoga@topolinia.com,foo
+```
diff --git a/docs/src/index.md b/docs/src/index.md
new file mode 100644
index 0000000..1a52b41
--- /dev/null
+++ b/docs/src/index.md
@@ -0,0 +1,96 @@
+# Rembus
+
+```@meta
+CurrentModule = Rembus
+```
+
+## Broker
+
+Starting the broker is simple as:
+
+```sh
+julia -e "using Rembus; caronte()"
+```
+
+Providing a startup script could be useful. The following `caronte` script suffice:
+
+```julia
+##!/bin/bash
+#=
+BINDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
+exec julia --threads auto --color=no -e "include(popfirst!(ARGS))" \
+ --project=$BINDIR/.. --startup-file=no "${BASH_SOURCE[0]}" "$@"
+=#
+using Rembus
+
+Rembus.caronte()
+```
+
+If `caronte` is in `PATH` then executing:
+
+```sh
+shell> caronte
+```
+
+starts the broker with setup controlled by the [Broker environment variables](@ref).
+
+## Component
+
+A `Component` is a broker client who uses the Rembus protocol for RPC commands and
+for streaming data in a Pub/Sub fashion.
+
+The macro `@component` declares a component whom identity and the connection parameters are defined with an URL:
+
+```julia
+component_url = "[://][][:/]"
+
+@component component_url
+```
+
+`` is one of:
+
+- `ws` web socket
+- `wss` secure web socket
+- `tcp` tcp socket
+- `tls` TLS over tcp socket
+- `zmq` ZeroMQ socket
+
+`` and `` are the hostname/ip and the port of the broker listening endpoint.
+
+`` is the unique name of the component.
+
+For example:
+
+```julia
+@component "ws://caronte.org:8000/myclient"
+```
+
+defines the component `myclient` that communicates with the broker hosted on `caronte.org`, listening on port `8000` and accepting web socket connections.
+
+### Default component URL parameters
+
+The string that define a component may be simplified by using the enviroment
+variable `REMBUS_BASE_URL` that set the connection default parameters:
+
+For example:
+
+```sh
+REMBUS_BASE_URL=ws://localhost:8000
+```
+
+define the default protocol, host and port, so that the above `component_url` may be simplified as:
+
+```julia
+@component "myclient"
+```
+
+Uses the web socket protocol to connect to `localhost` on port `8000`.
+
+## Index
+
+```@index
+```
+
+```@autodocs
+Modules = [Rembus]
+```
diff --git a/docs/src/supervised_api.md b/docs/src/supervised_api.md
new file mode 100644
index 0000000..3f8e173
--- /dev/null
+++ b/docs/src/supervised_api.md
@@ -0,0 +1,25 @@
+# Supervised API
+
+Rembus aims to write distributed applications simple and fun.
+
+But beside struggling to provide a simple and lean API one of the main points of Rembus is its ability to be fault-tolerant respect to networks and application failures.
+
+For example the following RPC service will run forever and it will reconnect
+automatically to the broker in case of network failures or to broker unavailabity due
+to shutdown or failures, there aren't boilerplates for reliable connection management.
+
+```julia
+@component "mycomponent"
+
+function myservice(input::DataFrame)
+ # run your super-cool logic and get back the result
+ result = my_logic(input)
+ return df
+end
+
+@expose myservice
+
+forever()
+```
+
+Fault-tolerance holds equally for publish/subscribe setups: connection failures recovers automatically and published messages are cached and delivered as soon as possible.
diff --git a/examples/Examples.md b/examples/Examples.md
new file mode 100644
index 0000000..dc30b5e
--- /dev/null
+++ b/examples/Examples.md
@@ -0,0 +1,110 @@
+# Examples
+
+To play with the examples gets the required dependencies:
+
+```
+> alias j='julia --project=. --startup-file=no'
+> j -e 'using Pkg; Pkg.develop(url=".."); Pkg.instantiate()'
+```
+and then starts a broker application:
+
+```
+> j 'using Rembus; caronte()'
+```
+
+## subscriber.jl
+
+In standard parlance `subscriber.jl` example implements a message subscriber/consumer: an application that listen to messages published to a set of logical channels, usually called topics.
+
+Indeed the example implements also a RPC service and show that a DataFrame-based message is a first-class citizen of the Rembus middleware.
+
+To see it in action:
+
+```shell
+> j subscriber.jl
+```
+
+Open a REPL and send some messages:
+
+```julia
+using Rembus
+
+@publish announcement("geppeto", "Version 2.0 of pinocchio is a beautiful boy")
+@publish announcement("pinocchio", "Cat and Fox are my new friends")
+```
+
+To get back all published announcements invoke the RPC service `all_announcements`:
+
+```julia
+df = @rpc all_announcements()
+```
+
+Oh yes! The returned value is a `DataFrame`.
+
+
+Below there are some more details just for exposing the core concepts of Rembus.
+
+### The minimal subscriber
+
+In a distributed system governed by the Rembus middleware there are two types of applications, Brokers and Components:
+
+- a Broker routes messages between Components;
+- a Component sends and receives messages and may be a publisher, a subscriber, a RPC client, a RPC Server
+ or all of these roles;
+
+The first step for a Component application is to bind to a Broker and declare its name:
+
+```julia
+@component "ws://caronte.com:8000/organizer"
+```
+The above declares a component `organizer` that will connect to the broker hosted at `caronte.com` with protocol `ws` served at port `8000`.
+
+> There are some sensible defaults that may help to keep the code clean:
+> the url of the broker may be set with the environment variable REMBUS_BASE_URL:
+>
+> `export REMBUS_BASE_URL=://:`
+>
+> If `REMBUS_BASE_URL` is not defined the default url will be `ws://127.0.0.1:8000`.
+>
+> In this case the component may be declared as:
+>
+> `@component "organizer"`
+
+
+Suppose the `organizer` wants to receive all messages published to the `announcement` topic.
+
+The messages are expected to have two fields: an username and a string containing a message announcement.
+
+> In general messages exchanged between distributed components may have any numbers of fields with primitive types mapped to [CBOR](https://www.rfc-editor.org/rfc/rfc8949.html#name-cbor-data-models) types.
+
+How do we consume such messages?
+
+With a method which name equals to the topic name and with a number of arguments equals to the message fields:
+
+```julia
+function announcement(username, post)
+ # do something with the post of username
+end
+```
+
+What remain to do is elevate such julia method as a consumer of the topic `announcement`
+using the macro `@subscribe`:
+
+```julia
+@subscribe announcement
+```
+
+The full code for this minimal `organizer` component that you can run in a REPL is then:
+
+```julia
+using Rembus
+
+@component "organizer"
+
+function announcement(username, post)
+ println("[$username]: $post")
+end
+
+@subscribe announcement
+
+```
diff --git a/examples/Manifest.toml b/examples/Manifest.toml
new file mode 100644
index 0000000..02751a9
--- /dev/null
+++ b/examples/Manifest.toml
@@ -0,0 +1,581 @@
+# This file is machine-generated - editing it directly is not advised
+
+julia_version = "1.10.2"
+manifest_format = "2.0"
+project_hash = "075ed3b1e4800ec37f8a72c8501c7bc2ead0d0a6"
+
+[[deps.ArgParse]]
+deps = ["Logging", "TextWrap"]
+git-tree-sha1 = "d4eccacaa3a632e8717556479d45502af44b4c17"
+uuid = "c7e460c6-2fb9-53a9-8c5b-16f535851c63"
+version = "1.1.5"
+
+[[deps.ArgTools]]
+uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f"
+version = "1.1.1"
+
+[[deps.Arrow]]
+deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "LoggingExtras", "Mmap", "PooledArrays", "SentinelArrays", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"]
+git-tree-sha1 = "29faa9835f77dee04b2833b2c9ee415223b3ebbd"
+uuid = "69666777-d1a9-59fb-9406-91d4454c9d45"
+version = "2.7.1"
+
+[[deps.ArrowTypes]]
+deps = ["Sockets", "UUIDs"]
+git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101"
+uuid = "31f734f8-188a-4ce0-8406-c8a06bd891cd"
+version = "2.3.0"
+
+[[deps.Artifacts]]
+uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33"
+
+[[deps.Base64]]
+uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
+
+[[deps.BitFlags]]
+git-tree-sha1 = "2dc09997850d68179b69dafb58ae806167a32b1b"
+uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35"
+version = "0.1.8"
+
+[[deps.BitIntegers]]
+deps = ["Random"]
+git-tree-sha1 = "a55462dfddabc34bc97d3a7403a2ca2802179ae6"
+uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1"
+version = "0.3.1"
+
+[[deps.CSV]]
+deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"]
+git-tree-sha1 = "a44910ceb69b0d44fe262dd451ab11ead3ed0be8"
+uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
+version = "0.10.13"
+
+[[deps.CodecLz4]]
+deps = ["Lz4_jll", "TranscodingStreams"]
+git-tree-sha1 = "b8aecef9f90530cf322a8386630ec18485c17991"
+uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561"
+version = "0.4.3"
+
+[[deps.CodecZlib]]
+deps = ["TranscodingStreams", "Zlib_jll"]
+git-tree-sha1 = "59939d8a997469ee05c4b4944560a820f9ba0d73"
+uuid = "944b1d66-785c-5afd-91f1-9de20f533193"
+version = "0.7.4"
+
+[[deps.CodecZstd]]
+deps = ["TranscodingStreams", "Zstd_jll"]
+git-tree-sha1 = "23373fecba848397b1705f6183188a0c0bc86917"
+uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2"
+version = "0.8.2"
+
+[[deps.Compat]]
+deps = ["TOML", "UUIDs"]
+git-tree-sha1 = "c955881e3c981181362ae4088b35995446298b80"
+uuid = "34da2185-b29b-5c13-b0c7-acf172513d20"
+version = "4.14.0"
+weakdeps = ["Dates", "LinearAlgebra"]
+
+ [deps.Compat.extensions]
+ CompatLinearAlgebraExt = "LinearAlgebra"
+
+[[deps.CompilerSupportLibraries_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae"
+version = "1.1.0+0"
+
+[[deps.ConcurrentUtilities]]
+deps = ["Serialization", "Sockets"]
+git-tree-sha1 = "6cbbd4d241d7e6579ab354737f4dd95ca43946e1"
+uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
+version = "2.4.1"
+
+[[deps.Crayons]]
+git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15"
+uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f"
+version = "4.1.1"
+
+[[deps.DataAPI]]
+git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe"
+uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a"
+version = "1.16.0"
+
+[[deps.DataFrames]]
+deps = ["Compat", "DataAPI", "DataStructures", "Future", "InlineStrings", "InvertedIndices", "IteratorInterfaceExtensions", "LinearAlgebra", "Markdown", "Missings", "PooledArrays", "PrecompileTools", "PrettyTables", "Printf", "REPL", "Random", "Reexport", "SentinelArrays", "SortingAlgorithms", "Statistics", "TableTraits", "Tables", "Unicode"]
+git-tree-sha1 = "04c738083f29f86e62c8afc341f0967d8717bdb8"
+uuid = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
+version = "1.6.1"
+
+[[deps.DataStructures]]
+deps = ["Compat", "InteractiveUtils", "OrderedCollections"]
+git-tree-sha1 = "0f4b5d62a88d8f59003e43c25a8a90de9eb76317"
+uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
+version = "0.18.18"
+
+[[deps.DataValueInterfaces]]
+git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6"
+uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464"
+version = "1.0.0"
+
+[[deps.Dates]]
+deps = ["Printf"]
+uuid = "ade2ca70-3891-5945-98fb-dc099432e06a"
+
+[[deps.Distributed]]
+deps = ["Random", "Serialization", "Sockets"]
+uuid = "8ba89e20-285c-5b6f-9357-94700520ee1b"
+
+[[deps.DocStringExtensions]]
+deps = ["LibGit2"]
+git-tree-sha1 = "2fb1e02f2b635d0845df5d7c167fec4dd739b00d"
+uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae"
+version = "0.9.3"
+
+[[deps.Downloads]]
+deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"]
+uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
+version = "1.6.0"
+
+[[deps.EnumX]]
+git-tree-sha1 = "bdb1942cd4c45e3c678fd11569d5cccd80976237"
+uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56"
+version = "1.0.4"
+
+[[deps.ExceptionUnwrapping]]
+deps = ["Test"]
+git-tree-sha1 = "dcb08a0d93ec0b1cdc4af184b26b591e9695423a"
+uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4"
+version = "0.1.10"
+
+[[deps.ExprTools]]
+git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec"
+uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04"
+version = "0.1.10"
+
+[[deps.FilePathsBase]]
+deps = ["Compat", "Dates", "Mmap", "Printf", "Test", "UUIDs"]
+git-tree-sha1 = "9f00e42f8d99fdde64d40c8ea5d14269a2e2c1aa"
+uuid = "48062228-2e41-5def-b9a4-89aafe57970f"
+version = "0.9.21"
+
+[[deps.FileWatching]]
+uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee"
+
+[[deps.Future]]
+deps = ["Random"]
+uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820"
+
+[[deps.HTTP]]
+deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"]
+git-tree-sha1 = "995f762e0182ebc50548c434c171a5bb6635f8e4"
+uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3"
+version = "1.10.4"
+
+[[deps.InlineStrings]]
+deps = ["Parsers"]
+git-tree-sha1 = "9cc2baf75c6d09f9da536ddf58eb2f29dedaf461"
+uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48"
+version = "1.4.0"
+
+[[deps.InteractiveUtils]]
+deps = ["Markdown"]
+uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240"
+
+[[deps.InvertedIndices]]
+git-tree-sha1 = "0dc7b50b8d436461be01300fd8cd45aa0274b038"
+uuid = "41ab1584-1d38-5bbf-9106-f11c6c58b48f"
+version = "1.3.0"
+
+[[deps.IteratorInterfaceExtensions]]
+git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856"
+uuid = "82899510-4779-5014-852e-03e436cf321d"
+version = "1.0.0"
+
+[[deps.JLLWrappers]]
+deps = ["Artifacts", "Preferences"]
+git-tree-sha1 = "7e5d6779a1e09a36db2a7b6cff50942a0a7d0fca"
+uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210"
+version = "1.5.0"
+
+[[deps.JSON3]]
+deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"]
+git-tree-sha1 = "eb3edce0ed4fa32f75a0a11217433c31d56bd48b"
+uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
+version = "1.14.0"
+weakdeps = ["ArrowTypes"]
+
+ [deps.JSON3.extensions]
+ JSON3ArrowExt = ["ArrowTypes"]
+
+[[deps.LaTeXStrings]]
+git-tree-sha1 = "50901ebc375ed41dbf8058da26f9de442febbbec"
+uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f"
+version = "1.3.1"
+
+[[deps.LazyArtifacts]]
+deps = ["Artifacts", "Pkg"]
+uuid = "4af54fe1-eca0-43a8-85a7-787d91b784e3"
+
+[[deps.LibCURL]]
+deps = ["LibCURL_jll", "MozillaCACerts_jll"]
+uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21"
+version = "0.6.4"
+
+[[deps.LibCURL_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll", "Zlib_jll", "nghttp2_jll"]
+uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0"
+version = "8.4.0+0"
+
+[[deps.LibGit2]]
+deps = ["Base64", "LibGit2_jll", "NetworkOptions", "Printf", "SHA"]
+uuid = "76f85450-5226-5b5a-8eaa-529ad045b433"
+
+[[deps.LibGit2_jll]]
+deps = ["Artifacts", "LibSSH2_jll", "Libdl", "MbedTLS_jll"]
+uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5"
+version = "1.6.4+0"
+
+[[deps.LibSSH2_jll]]
+deps = ["Artifacts", "Libdl", "MbedTLS_jll"]
+uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8"
+version = "1.11.0+1"
+
+[[deps.Libdl]]
+uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb"
+
+[[deps.LinearAlgebra]]
+deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"]
+uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
+
+[[deps.Logging]]
+uuid = "56ddb016-857b-54e1-b83d-db4d58db5568"
+
+[[deps.LoggingExtras]]
+deps = ["Dates", "Logging"]
+git-tree-sha1 = "c1dd6d7978c12545b4179fb6153b9250c96b0075"
+uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36"
+version = "1.0.3"
+
+[[deps.Lz4_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl"]
+git-tree-sha1 = "6c26c5e8a4203d43b5497be3ec5d4e0c3cde240a"
+uuid = "5ced341a-0733-55b8-9ab6-a4889d929147"
+version = "1.9.4+0"
+
+[[deps.Markdown]]
+deps = ["Base64"]
+uuid = "d6f4376e-aef5-505a-96c1-9c027394607a"
+
+[[deps.MbedTLS]]
+deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"]
+git-tree-sha1 = "c067a280ddc25f196b5e7df3877c6b226d390aaf"
+uuid = "739be429-bea8-5141-9913-cc70e7f3736d"
+version = "1.1.9"
+
+[[deps.MbedTLS_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1"
+version = "2.28.2+1"
+
+[[deps.Missings]]
+deps = ["DataAPI"]
+git-tree-sha1 = "f66bdc5de519e8f8ae43bdc598782d35a25b1272"
+uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28"
+version = "1.1.0"
+
+[[deps.Mmap]]
+uuid = "a63ad114-7e13-5084-954f-fe012c677804"
+
+[[deps.Mocking]]
+deps = ["Compat", "ExprTools"]
+git-tree-sha1 = "4cc0c5a83933648b615c36c2b956d94fda70641e"
+uuid = "78c3b35d-d492-501b-9361-3d52fe80e533"
+version = "0.7.7"
+
+[[deps.MozillaCACerts_jll]]
+uuid = "14a3606d-f60d-562e-9121-12d972cd8159"
+version = "2023.1.10"
+
+[[deps.NetworkOptions]]
+uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908"
+version = "1.2.0"
+
+[[deps.OpenBLAS_jll]]
+deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"]
+uuid = "4536629a-c528-5b80-bd46-f80d51c5b363"
+version = "0.3.23+4"
+
+[[deps.OpenSSL]]
+deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "OpenSSL_jll", "Sockets"]
+git-tree-sha1 = "af81a32750ebc831ee28bdaaba6e1067decef51e"
+uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c"
+version = "1.4.2"
+
+[[deps.OpenSSL_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl"]
+git-tree-sha1 = "60e3045590bd104a16fefb12836c00c0ef8c7f8c"
+uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95"
+version = "3.0.13+0"
+
+[[deps.OrderedCollections]]
+git-tree-sha1 = "dfdf5519f235516220579f949664f1bf44e741c5"
+uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d"
+version = "1.6.3"
+
+[[deps.Parameters]]
+deps = ["OrderedCollections", "UnPack"]
+git-tree-sha1 = "34c0e9ad262e5f7fc75b10a9952ca7692cfc5fbe"
+uuid = "d96e819e-fc66-5662-9728-84c9c7592b0a"
+version = "0.12.3"
+
+[[deps.Parsers]]
+deps = ["Dates", "PrecompileTools", "UUIDs"]
+git-tree-sha1 = "8489905bcdbcfac64d1daa51ca07c0d8f0283821"
+uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0"
+version = "2.8.1"
+
+[[deps.Pkg]]
+deps = ["Artifacts", "Dates", "Downloads", "FileWatching", "LibGit2", "Libdl", "Logging", "Markdown", "Printf", "REPL", "Random", "SHA", "Serialization", "TOML", "Tar", "UUIDs", "p7zip_jll"]
+uuid = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
+version = "1.10.0"
+
+[[deps.PooledArrays]]
+deps = ["DataAPI", "Future"]
+git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3"
+uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720"
+version = "1.4.3"
+
+[[deps.PrecompileTools]]
+deps = ["Preferences"]
+git-tree-sha1 = "5aa36f7049a63a1528fe8f7c3f2113413ffd4e1f"
+uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
+version = "1.2.1"
+
+[[deps.Preferences]]
+deps = ["TOML"]
+git-tree-sha1 = "9306f6085165d270f7e3db02af26a400d580f5c6"
+uuid = "21216c6a-2e73-6563-6e65-726566657250"
+version = "1.4.3"
+
+[[deps.PrettyTables]]
+deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "Reexport", "StringManipulation", "Tables"]
+git-tree-sha1 = "88b895d13d53b5577fd53379d913b9ab9ac82660"
+uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d"
+version = "2.3.1"
+
+[[deps.Printf]]
+deps = ["Unicode"]
+uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7"
+
+[[deps.REPL]]
+deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"]
+uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
+
+[[deps.Random]]
+deps = ["SHA"]
+uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+
+[[deps.Reexport]]
+git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b"
+uuid = "189a3867-3050-52da-a836-e630ba90ab69"
+version = "1.2.2"
+
+[[deps.Rembus]]
+deps = ["ArgParse", "Arrow", "Base64", "CSV", "DataFrames", "DataStructures", "Dates", "Distributed", "DocStringExtensions", "FileWatching", "HTTP", "JSON3", "Logging", "MbedTLS", "Parameters", "PrecompileTools", "Preferences", "Printf", "Random", "Reexport", "Serialization", "Sockets", "URIs", "UUIDs", "Visor", "ZMQ"]
+path = ".."
+uuid = "aa126574-2ec3-42c9-8bf5-7f62001d6bce"
+version = "0.1.0"
+
+[[deps.SHA]]
+uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce"
+version = "0.7.0"
+
+[[deps.Scratch]]
+deps = ["Dates"]
+git-tree-sha1 = "3bac05bc7e74a75fd9cba4295cde4045d9fe2386"
+uuid = "6c6a2e73-6563-6170-7368-637461726353"
+version = "1.2.1"
+
+[[deps.SentinelArrays]]
+deps = ["Dates", "Random"]
+git-tree-sha1 = "0e7508ff27ba32f26cd459474ca2ede1bc10991f"
+uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c"
+version = "1.4.1"
+
+[[deps.Serialization]]
+uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b"
+
+[[deps.SimpleBufferStream]]
+git-tree-sha1 = "874e8867b33a00e784c8a7e4b60afe9e037b74e1"
+uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7"
+version = "1.1.0"
+
+[[deps.Sockets]]
+uuid = "6462fe0b-24de-5631-8697-dd941f90decc"
+
+[[deps.SortingAlgorithms]]
+deps = ["DataStructures"]
+git-tree-sha1 = "66e0a8e672a0bdfca2c3f5937efb8538b9ddc085"
+uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c"
+version = "1.2.1"
+
+[[deps.SparseArrays]]
+deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"]
+uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
+version = "1.10.0"
+
+[[deps.Statistics]]
+deps = ["LinearAlgebra", "SparseArrays"]
+uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
+version = "1.10.0"
+
+[[deps.StringManipulation]]
+deps = ["PrecompileTools"]
+git-tree-sha1 = "a04cabe79c5f01f4d723cc6704070ada0b9d46d5"
+uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e"
+version = "0.3.4"
+
+[[deps.StructTypes]]
+deps = ["Dates", "UUIDs"]
+git-tree-sha1 = "ca4bccb03acf9faaf4137a9abc1881ed1841aa70"
+uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4"
+version = "1.10.0"
+
+[[deps.SuiteSparse_jll]]
+deps = ["Artifacts", "Libdl", "libblastrampoline_jll"]
+uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c"
+version = "7.2.1+1"
+
+[[deps.TOML]]
+deps = ["Dates"]
+uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
+version = "1.0.3"
+
+[[deps.TZJData]]
+deps = ["Artifacts"]
+git-tree-sha1 = "b69f8338df046774bd975b13be9d297eca5efacb"
+uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7"
+version = "1.1.0+2023d"
+
+[[deps.TableTraits]]
+deps = ["IteratorInterfaceExtensions"]
+git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39"
+uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c"
+version = "1.0.1"
+
+[[deps.Tables]]
+deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "LinearAlgebra", "OrderedCollections", "TableTraits"]
+git-tree-sha1 = "cb76cf677714c095e535e3501ac7954732aeea2d"
+uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c"
+version = "1.11.1"
+
+[[deps.Tar]]
+deps = ["ArgTools", "SHA"]
+uuid = "a4e569a6-e804-4fa4-b0f3-eef7a1d5b13e"
+version = "1.10.0"
+
+[[deps.Test]]
+deps = ["InteractiveUtils", "Logging", "Random", "Serialization"]
+uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
+
+[[deps.TextWrap]]
+git-tree-sha1 = "9250ef9b01b66667380cf3275b3f7488d0e25faf"
+uuid = "b718987f-49a8-5099-9789-dcd902bef87d"
+version = "1.0.1"
+
+[[deps.TimeZones]]
+deps = ["Artifacts", "Dates", "Downloads", "InlineStrings", "LazyArtifacts", "Mocking", "Printf", "Scratch", "TZJData", "Unicode", "p7zip_jll"]
+git-tree-sha1 = "89e64d61ef3cd9e80f7fc12b7d13db2d75a23c03"
+uuid = "f269a46b-ccf7-5d73-abea-4c690281aa53"
+version = "1.13.0"
+
+ [deps.TimeZones.extensions]
+ TimeZonesRecipesBaseExt = "RecipesBase"
+
+ [deps.TimeZones.weakdeps]
+ RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01"
+
+[[deps.TranscodingStreams]]
+git-tree-sha1 = "a09c933bebed12501890d8e92946bbab6a1690f1"
+uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa"
+version = "0.10.5"
+weakdeps = ["Random", "Test"]
+
+ [deps.TranscodingStreams.extensions]
+ TestExt = ["Test", "Random"]
+
+[[deps.URIs]]
+git-tree-sha1 = "67db6cc7b3821e19ebe75791a9dd19c9b1188f2b"
+uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4"
+version = "1.5.1"
+
+[[deps.UUIDs]]
+deps = ["Random", "SHA"]
+uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
+
+[[deps.UnPack]]
+git-tree-sha1 = "387c1f73762231e86e0c9c5443ce3b4a0a9a0c2b"
+uuid = "3a884ed6-31ef-47d7-9d2a-63182c4928ed"
+version = "1.0.2"
+
+[[deps.Unicode]]
+uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"
+
+[[deps.Visor]]
+deps = ["DataStructures", "Dates", "Logging", "UUIDs"]
+git-tree-sha1 = "232783e703febab4ccb5e651fd562fbf673df8ff"
+uuid = "cf786855-3531-4b86-ba6e-3e33dce7dcdb"
+version = "0.6.0"
+
+[[deps.WeakRefStrings]]
+deps = ["DataAPI", "InlineStrings", "Parsers"]
+git-tree-sha1 = "b1be2855ed9ed8eac54e5caff2afcdb442d52c23"
+uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5"
+version = "1.4.2"
+
+[[deps.WorkerUtilities]]
+git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7"
+uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60"
+version = "1.6.1"
+
+[[deps.ZMQ]]
+deps = ["FileWatching", "Sockets", "ZeroMQ_jll"]
+git-tree-sha1 = "356d2bdcc0bce90aabee1d1c0f6d6f301eda8f77"
+uuid = "c2297ded-f4af-51ae-bb23-16f91089e4e1"
+version = "1.2.2"
+
+[[deps.ZeroMQ_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl", "libsodium_jll"]
+git-tree-sha1 = "42f97fb27394378591666ab0e9cee369e6d0e1f9"
+uuid = "8f1865be-045e-5c20-9c9f-bfbfb0764568"
+version = "4.3.5+0"
+
+[[deps.Zlib_jll]]
+deps = ["Libdl"]
+uuid = "83775a58-1f1d-513f-b197-d71354ab007a"
+version = "1.2.13+1"
+
+[[deps.Zstd_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl"]
+git-tree-sha1 = "49ce682769cd5de6c72dcf1b94ed7790cd08974c"
+uuid = "3161d3a3-bdf6-5164-811a-617609db77b4"
+version = "1.5.5+0"
+
+[[deps.libblastrampoline_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "8e850b90-86db-534c-a0d3-1478176c7d93"
+version = "5.8.0+1"
+
+[[deps.libsodium_jll]]
+deps = ["Artifacts", "JLLWrappers", "Libdl", "Pkg"]
+git-tree-sha1 = "848ab3d00fe39d6fbc2a8641048f8f272af1c51e"
+uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8"
+version = "1.0.20+0"
+
+[[deps.nghttp2_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d"
+version = "1.52.0+1"
+
+[[deps.p7zip_jll]]
+deps = ["Artifacts", "Libdl"]
+uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0"
+version = "17.4.0+2"
diff --git a/examples/Project.toml b/examples/Project.toml
new file mode 100644
index 0000000..cb69970
--- /dev/null
+++ b/examples/Project.toml
@@ -0,0 +1,3 @@
+[deps]
+DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
+Rembus = "aa126574-2ec3-42c9-8bf5-7f62001d6bce"
diff --git a/examples/subscriber.jl b/examples/subscriber.jl
new file mode 100755
index 0000000..914ef07
--- /dev/null
+++ b/examples/subscriber.jl
@@ -0,0 +1,46 @@
+using DataFrames
+using Rembus
+
+# A custom structure where announcements are stored
+# just to demonstrate the strong symbiosis between Rembus and
+# the dataframe concept it is used a Julia DataFrame.
+mutable struct Announcements
+ df::DataFrame
+ Announcements() = new(DataFrame(:user => [], :message => []))
+end
+
+# A sort of global context to share state between exposed and subscribed methods.
+const ANN_DB = Announcements()
+
+# This get called for each message received on topic "announcement"
+function announcement(ann_db, user, message)
+ @info "news from [$user]: $message"
+ push!(ann_db.df, [user, message])
+end
+
+# This get called for each RPC request.
+# Return a Dataframe object.
+all_announcements(ann_db) = return ann_db.df
+
+# Naming a component is optional, you can comment the following line.
+# A component without a name became a sort of "anonymous" entity that
+# assumes an ephemeral identity each time it connects to the broker.
+@component "organizer"
+
+# Subscribe to the topic "announcement". the option before_now declare interest
+# in messages published in time intervals when the component was offline.
+@subscribe announcement before_now
+
+# If a component is "anonymous", the above @component line commented out, then the
+# messages published when the component is offline get lost even if @subscribe
+# use the option before_now.
+
+# Shared object between all subscribers and exposers methods.
+# When declared it get used as the first argument of the exposed/subscribed methods.
+@shared ANN_DB
+
+# Expose the method that returns the announcements dataframe
+@expose all_announcements
+
+# await forever for client requests or Ctrl-C termination command.
+forever()
diff --git a/src/Rembus.jl b/src/Rembus.jl
new file mode 100644
index 0000000..2bff79e
--- /dev/null
+++ b/src/Rembus.jl
@@ -0,0 +1,2017 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+module Rembus
+
+using ArgParse
+using Arrow
+using Base64
+using CSV
+using DocStringExtensions
+using DataFrames
+using Dates
+using DataStructures
+using FileWatching
+using HTTP
+using JSON3
+using Logging
+using MbedTLS
+using Random
+using Reexport
+using Sockets
+using Parameters
+using PrecompileTools
+using Preferences
+using Printf
+using URIs
+using Serialization
+using UUIDs
+@reexport using Visor
+using ZMQ
+
+
+export @component
+export @enable_ack, @disable_ack
+export @expose, @unexpose
+export @subscribe, @unsubscribe
+export @rpc
+export @publish
+export @reactive, @unreactive
+export @shared
+export @rpc_timeout
+export @terminate
+
+# rembus client api
+export connect
+export expose, unexpose
+export subscribe, unsubscribe
+export direct
+export rpc
+export publish
+export reactive, unreactive
+export enable_ack, disable_ack
+export authorize, unauthorize
+export private_topic, public_topic
+export provide
+export admin
+export close
+export enable_debug, disable_debug
+export isconnected
+export rembus
+export setting
+export shared
+export set_balancer
+export forever
+
+# broker api
+export caronte, session, context
+
+export RembusError
+export RembusTimeout
+export RembusDisconnect
+export RpcMethodNotFound, RpcMethodUnavailable, RpcMethodLoopback, RpcMethodException
+
+include("configuration.jl")
+include("constants.jl")
+include("logger.jl")
+include("cbor.jl")
+include("encode.jl")
+include("decode.jl")
+include("protocol.jl")
+include("broker.jl")
+include("transport.jl")
+include("admin.jl")
+include("store.jl")
+include("register.jl")
+
+function __init__()
+ Visor.setroot(intensity=3)
+ atexit(shutdown)
+end
+
+struct ConnectionClosed <: Exception
+end
+
+# A message error received from the broker.
+abstract type RembusException <: Exception end
+
+# An error response from the broker that is not one of:
+# STS_METHOD_NOT_FOUND, STS_METHOD_EXCEPTION, STS_METHOD_LOOPBACK, STS_METHOD_UNAVAILABLE
+Base.@kwdef struct RembusError <: RembusException
+ code::UInt8
+ cid::Union{String,Nothing} = nothing
+ topic::Union{String,Nothing} = nothing
+ reason::Union{String,Nothing} = nothing
+end
+
+"""
+`RpcMethodNotFound` is thrown from a rpc request when a remote method is unknown.
+
+fields:
+$(FIELDS)
+
+## RPC Client
+```julia
+@rpc coolservice()
+```
+Output:
+```
+ERROR: Rembus.RpcMethodNotFound("rembus", "coolservice")
+Stacktrace:
+...
+```
+"""
+struct RpcMethodNotFound <: RembusException
+ "component name"
+ cid::String
+ "service name"
+ topic::String
+end
+
+"""
+ RpcMethodUnavailable
+
+Thrown when a RPC method is unavailable.
+
+A method is considered unavailable when some component that expose the method is
+currently disconnected from the broker.
+
+# Fields
+$(FIELDS)
+"""
+struct RpcMethodUnavailable <: RembusException
+ "component name"
+ cid::String
+ "service name"
+ topic::String
+end
+
+"""
+ RpcMethodLoopback
+
+Thrown when a RPC request to a locally exposed method.
+
+# Fields
+$(FIELDS)
+"""
+struct RpcMethodLoopback <: RembusException
+ "component name"
+ cid::String
+ "service name"
+ topic::String
+end
+
+"""
+ RpcMethodException
+
+Thrown when a RPC method throws an exception.
+
+# Fields
+$(FIELDS)
+
+## Exposer
+```julia
+@expose foo(name::AbstractString) = "hello " * name
+```
+## RPC client
+```julia
+try
+ @rpc foo(1)
+catch e
+ @error e.reason
+end
+```
+Output:
+```
+┌ Error: MethodError: no method matching foo(::UInt64)
+│
+│ Closest candidates are:
+│ foo(!Matched::AbstractString)
+│ @ Main REPL[2]:1
+└ @ Main REPL
+```
+"""
+struct RpcMethodException <: RembusException
+ "component name"
+ cid::String
+ "service name"
+ topic::String
+ "remote exception description"
+ reason::String
+end
+
+"""
+ RembusTimeout
+
+Thrown when a response it is not received.
+"""
+struct RembusTimeout <: RembusException
+ msg::String
+ RembusTimeout(msg) = new(msg)
+end
+
+"""
+ RembusDisconnect
+
+Thrown when a rembus connection get unexpectedly down.
+"""
+struct RembusDisconnect <: RembusException
+end
+
+# Workaround lock for HTTP.WebSockets methods (close and send)
+# Under heavy concurrent messaging data corruption may happens.
+websocketlock = ReentrantLock()
+
+function rembuserror(raise::Bool=true; code, cid=nothing, topic=nothing, reason=nothing)
+ if code == STS_METHOD_NOT_FOUND
+ err = RpcMethodNotFound(cid, topic)
+ elseif code == STS_METHOD_EXCEPTION
+ err = RpcMethodException(cid, topic, reason)
+ elseif code == STS_METHOD_LOOPBACK
+ err = RpcMethodLoopback(cid, topic)
+ elseif code == STS_METHOD_UNAVAILABLE
+ err = RpcMethodUnavailable(cid, topic)
+ else
+ err = RembusError(code=code, cid=cid, topic=topic, reason=reason)
+ end
+
+ if raise
+ throw(err)
+ else
+ return err
+ end
+end
+
+struct Component
+ id::String
+ protocol::Symbol
+ host::String
+ port::UInt16
+ props::Dict{String,String}
+
+ function Component(url::String)
+ baseurl = get(ENV, "REMBUS_BASE_URL", "ws://127.0.0.1:8000")
+ baseuri = URI(baseurl)
+ uri = URI(url)
+ props = queryparams(uri)
+
+ host = uri.host
+ if host == ""
+ host = baseuri.host
+ end
+
+ portstr = uri.port
+ if portstr == ""
+ portstr = baseuri.port
+ end
+
+ port = parse(UInt16, portstr)
+
+ proto = uri.scheme
+ if proto == ""
+ name = uri.path
+ protocol = Symbol(baseuri.scheme)
+ elseif proto in ["ws", "wss", "tcp", "tls", "zmq"]
+ name = startswith(uri.path, "/") ? uri.path[2:end] : uri.path
+ protocol = Symbol(proto)
+ else
+ error("wrong url $url: unknown protocol $proto")
+ end
+ if isempty(name)
+ name = "rembus"
+ end
+ return new(name, protocol, host, port, props)
+ end
+end
+
+brokerurl(c::Component) = "$(c.protocol == :zmq ? :tcp : c.protocol)://$(c.host):$(c.port)"
+
+struct CastCall
+ topic::String
+ data::Any
+end
+
+Base.show(io::IO, call::CastCall) = print(io, call.topic)
+
+abstract type RBHandle end
+
+mutable struct RBConnection <: RBHandle
+ shared::Any
+ socket::Any
+ reactive::Bool
+ client::Component
+ receiver::Dict{String,Function}
+ out::Dict{UInt128,Condition}
+ context::Union{Nothing,ZMQ.Context}
+ RBConnection(name::String) = new(
+ missing, nothing, false, Component(name), Dict(), Dict(), nothing
+ )
+ RBConnection(client=getcomponent()) = new(
+ missing, nothing, false, client, Dict(), Dict(), nothing
+ )
+end
+
+Base.isless(rb1::RBConnection, rb2::RBConnection) = length(rb1.out) < length(rb2.out)
+
+function Base.show(io::IO, rb::RBConnection)
+ return print(io, "client [$(rb.client.id)], isconnected: $(isconnected(rb))")
+end
+
+mutable struct RBPool <: RBHandle
+ last_invoked::Dict{String,Int} # topic => index of last used connection
+ connections::Vector{RBConnection}
+ RBPool(conns::Vector{RBConnection}=[]) = new(Dict(), conns)
+end
+
+keystore_dir() = get(ENV, "REMBUS_KEYSTORE", joinpath(get(ENV, "HOME", "."), "keystore"))
+
+request_timeout() = parse(Float32, get(ENV, "REMBUS_TIMEOUT", "5"))
+
+getcomponent() = Component(Rembus.CONFIG.cid)
+
+function name2proc(name::AbstractString, startproc=false, setanonymous=false)
+ return name2proc(Component(name), startproc, setanonymous)
+end
+
+function name2proc(cmp::Component, startproc=false, setanonymous=false)
+ proc = from(cmp.id)
+ if proc === nothing
+ if setanonymous && CONFIG.cid == "rembus"
+ proc = startup(rembus())
+ else
+ throw(Visor.UnknownProcess(cmp.id))
+ end
+ end
+
+ if startproc && !isdefined(proc, :task)
+ Visor.startchain(proc)
+ end
+
+ return proc
+end
+
+"""
+ @component "url"
+
+Set the name of the component and the protocol for connecting
+to the broker.
+
+`url` may be:
+- "myname": use \\\$REMBUS\\_BASE\\_URL for connection parameters
+- "tcp://host:port/myname": tcp connection
+- "ws://host:port/myname": web socket connection
+- "zmq://host:port/myname": ZeroMQ connection
+"""
+macro component(name)
+ quote
+ Rembus.CONFIG.cid = $(esc(name))
+ Visor.startup(rembus())
+ end
+end
+
+"""
+ @terminate
+
+Close the connection and terminate the component.
+"""
+macro terminate(name=getcomponent())
+ quote
+ shutdown(name2proc($(esc(name))))
+ Rembus.CONFIG.cid = "rembus"
+ nothing
+ end
+end
+
+"""
+ @rpc_timeout value
+
+Set the rpc request timeout in seconds.
+"""
+macro rpc_timeout(value)
+ quote
+ ENV["REMBUS_TIMEOUT"] = $(esc(value))
+ end
+end
+
+macro rembus(cid=nothing)
+ quote
+ startup(rembus($(esc(cid))))
+ end
+end
+
+function holder_expr(shared, cid=getcomponent())
+ ex = :(call(
+ Rembus.name2proc("cid", false, false),
+ Rembus.SetHolder(aaa),
+ timeout=Rembus.request_timeout()
+ ))
+ ex.args[3].args[2] = shared
+ ex.args[2].args[2] = cid
+ ex
+end
+
+"""
+ @shared container
+
+Bind a `container` object that is passed as the first argument of the subscribed
+component functions.
+
+The `container` is useful for mantaining a state.
+
+```julia
+using Rembus
+
+# keep the number of processed messages
+mutable struct Context
+ msgcount::UInt
+end
+
+function topic(context::Context, arg1, arg2)
+ context.msgcount += 1
+ some_logic(arg1, arg2)
+end
+
+ctx = Context(0)
+@subscribe topic
+@shared ctx
+@reactive
+```
+
+Using `@shared` to set a `container` object means that if some component
+`publish topic(arg1,arg2)` then the method `foo(container,arg2,arg2)` will be called.
+
+"""
+macro shared(container)
+ ex = holder_expr(container)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+macro shared(cid, container)
+ ex = holder_expr(container, cid)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+function publish_expr(topic, cid=getcomponent())
+ ext = :(cast(Rembus.name2proc("cid", true, true), Rembus.CastCall(t, [])))
+
+ fn = string(topic.args[1])
+ ext.args[2].args[2] = cid
+ ext.args[3].args[2] = fn
+
+ args = topic.args[2:end]
+ ext.args[3].args[3].args = args
+ ext
+end
+
+"""
+ @publish topic(arg1,arg2,...)
+
+Publish a message to `topic` logic channel.
+
+The function `topic(arg1,arg2,...)` will be called on each connected component subscribed
+to `topic`.
+
+## Publisher
+```julia
+@publish foo("gfr", 54.2)
+```
+
+## Subscriber
+```julia
+function foo(name, value)
+ println("do something with \$name=\$value")
+end
+
+@subscribe foo
+@reactive
+
+supervise()
+```
+"""
+macro publish(topic)
+ ext = publish_expr(topic)
+ quote
+ $(esc(ext))
+ end
+end
+
+macro publish(cid, topic)
+ ext = publish_expr(topic, cid)
+ quote
+ $(esc(ext))
+ end
+end
+
+function rpc_expr(topic, cid=getcomponent())
+ ext = :(call(
+ Rembus.name2proc("cid", true, true),
+ Rembus.CastCall(t, []),
+ timeout=Rembus.request_timeout()
+ ))
+ fn = string(topic.args[1])
+ ext.args[2].args[2] = cid
+ ext.args[3].args[2] = fn
+
+ args = topic.args[2:end]
+ ext.args[3].args[3].args = args
+ ext
+end
+
+"""
+ @rpc service(arg1,...)
+
+Call the remote `service` method and return its outcome.
+
+The outcome may be the a return value or a [`RpcMethodException`](@ref) if the remote
+throws an exception.
+
+The `service` method must match the signature of an exposed remote `service` method.
+
+Components may subscribe to `service` for receiving the `service` request.
+
+## Exposer
+```julia
+function mymethod(x, y)
+ return evaluate(x,y)
+end
+
+@expose mymethod
+supervise()
+```
+
+## RPC client
+```julia
+response = @rpc mymethod(x,y)
+```
+
+## Subscriber
+```julia
+function service(x, y)
+ ...
+end
+
+@subscribe service
+@reactive
+
+supervise()
+
+```
+"""
+macro rpc(topic)
+ ext = rpc_expr(topic)
+ quote
+ $(esc(ext))
+ end
+end
+
+macro rpc(cid, topic)
+ ext = rpc_expr(topic, cid)
+ quote
+ $(esc(ext))
+ end
+end
+
+fnname(fn::Expr) = fn.args[1].args[1]
+fnname(fn::Symbol) = fn
+
+function expose_expr(fn, cid=getcomponent())
+ ex = :(call(
+ Rembus.name2proc("cid", true, true),
+ Rembus.AddImpl(aaa),
+ timeout=Rembus.request_timeout()
+ ))
+ ex.args[3].args[2] = fnname(fn)
+ ex.args[2].args[2] = cid
+ ex
+end
+
+function subscribe_expr(fn, mode::Symbol, cid=getcomponent())
+ if mode == :from_now
+ sts = false
+ elseif mode == :before_now
+ sts = true
+ else
+ return :(throw(
+ ErrorException("subscribe invalid mode: must be from_now or before_now")
+ ))
+ end
+ ex = :(call(
+ Rembus.name2proc(Rembus.getcomponent(), true, true),
+ Rembus.AddInterest(aaa, $sts),
+ timeout=Rembus.request_timeout()
+ ))
+ ex.args[3].args[2] = fnname(fn)
+ ex.args[2].args[2] = cid
+ ex
+end
+
+"""
+ @expose fn
+
+Expose all the methods of the function `fn`.
+
+## Example
+
+Expose the function `mycalc` that implements a service that may accept two numbers or a
+string and number:
+
+```julia
+mycalc(x::Number, y::Number) = x+y
+mycalc(x::String, y::Number) = length(x)*y
+
+@expose mycalc
+```
+Call `mycal` service using the correct types of arguments:
+
+```julia
+# ok
+julia> response = @rpc mycalc(1,2)
+0x0000000000000003
+
+# ok
+julia> response = @rpc mycalc("hello",2.0)
+10.0
+```
+
+If the RPC client call `mycalc` with the argument's type that
+do not respect the signatures of the exposed service
+then it throws [`RpcMethodException`](@ref)
+
+```julia
+julia> response = @rpc mycalc("hello","world")
+ERROR: RpcMethodException("rembus", "mycalc", "MethodError: no method matching \
+mycalc(::String, ::String) ...
+```
+"""
+macro expose(fn::Symbol)
+ ex = expose_expr(fn)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @expose function fn(arg1,...)
+ ...
+ end
+
+Expose the function expression.
+"""
+macro expose(fn::Expr)
+ ex = expose_expr(fn)
+ quote
+ $(esc(fn))
+ $(esc(ex))
+ nothing
+ end
+end
+
+macro expose(cid, fn::Symbol)
+ ex = expose_expr(fn, cid)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+macro expose(cid, fn::Expr)
+ ex = expose_expr(fn, cid)
+ quote
+ $(esc(fn))
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @subscribe topic [mode]
+
+Setup a subscription to `topic` logic channel to handle messages from [`@publish`](@ref)
+or [`@rpc`](@ref).
+
+`mode` values`:
+- `from_now` (default): receive messages published from now.
+- `before_now`: receive messages published when the component was offline.
+
+Messages starts to be delivered to `topic` when reactivity is enabled with `@reactive`
+macro.
+
+## Subscriber
+```julia
+function foo(arg1, arg2)
+ ...
+end
+
+@subscribe foo
+@reactive
+
+supervise()
+```
+
+## Publisher
+```julia
+@publish foo("gfr", 54.2)
+```
+"""
+macro subscribe(fn::Symbol, mode::Symbol=:from_now)
+ ex = subscribe_expr(fn, mode)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @subscribe function fn(args...)
+ ...
+ end [mode]
+
+Subscribe the function expression.
+"""
+macro subscribe(fn::Expr, mode::Symbol=:from_now)
+ ex = subscribe_expr(fn, mode)
+ quote
+ $(esc(fn))
+ $(esc(ex))
+ nothing
+ end
+end
+
+macro subscribe(cid, fn::Expr, mode::Symbol=:from_now)
+ ex = subscribe_expr(fn, mode, cid)
+ quote
+ $(esc(fn))
+ $(esc(ex))
+ nothing
+ end
+end
+
+macro subscribe(cid, fn::Symbol, mode::Symbol=:from_now)
+ ex = subscribe_expr(fn, mode, cid)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @unexpose fn
+
+The methods of `fn` function is no more available to rpc clients.
+"""
+macro unexpose(fn::Symbol)
+ :(@unexpose getcomponent() $(esc(fn)))
+end
+
+macro unexpose(cid, fn)
+ ex = :(call(
+ Rembus.name2proc("cid"),
+ Rembus.RemoveImpl(aaa),
+ timeout=Rembus.request_timeout()
+ ))
+ ex.args[3].args[2] = fn
+ ex.args[2].args[2] = cid
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @unsubscribe mytopic
+
+The methods of `mytopic` function stop to handle messages
+published to topic `mytopic`.
+"""
+macro unsubscribe(fn::Symbol)
+ :(@unsubscribe getcomponent() $(esc(fn)))
+end
+
+macro unsubscribe(cid, fn)
+ ex = :(call(
+ Rembus.name2proc("cid"),
+ Rembus.RemoveInterest(aaa),
+ timeout=Rembus.request_timeout()
+ ))
+ ex.args[3].args[2] = fn
+ ex.args[2].args[2] = cid
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+function reactive_expr(reactive, cid=nothing)
+ if cid === nothing
+ id = getcomponent()
+ else
+ id = cid
+ end
+ ex = :(call(
+ Rembus.name2proc("cid"),
+ Rembus.Reactive($reactive),
+ timeout=Rembus.request_timeout()
+ ))
+ ex.args[2].args[2] = id
+ ex
+end
+
+function enable_ack_expr(enable, cid=nothing)
+ if cid === nothing
+ id = getcomponent()
+ else
+ id = cid
+ end
+ ex = :(call(
+ Rembus.name2proc("cid"),
+ Rembus.EnableAck($enable),
+ timeout=Rembus.request_timeout()
+ ))
+ ex.args[2].args[2] = id
+ ex
+end
+
+"""
+ @reactive
+
+The subscribed methods start to handle published messages.
+"""
+macro reactive(cid=nothing)
+ ex = reactive_expr(true, cid)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @unreactive
+
+The subscribed methods stop to handle published messages.
+"""
+macro unreactive(cid=nothing)
+ ex = reactive_expr(false, cid)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @enable_ack
+
+Enable acknowledge receipt of published messages.
+
+This feature assure that messages get delivered at least one time to the
+subscribed component.
+
+For default the acknowledge is disabled.
+"""
+macro enable_ack(cid=nothing)
+ ex = enable_ack_expr(true, cid)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+"""
+ @disable_ack
+
+Disable acknowledge receipt of published messages.
+
+This feature assure that messages get delivered at least one to the
+subscribed component.
+"""
+macro disable_ack(cid=nothing)
+ ex = disable_ack_expr(false, cid)
+ quote
+ $(esc(ex))
+ nothing
+ end
+end
+
+struct SetHolder
+ shared::Any
+end
+
+struct AddImpl
+ fn::Function
+end
+
+struct RemoveImpl
+ fn::Function
+end
+
+struct AddInterest
+ fn::Function
+ retroactive::Bool
+end
+
+struct RemoveInterest
+ fn::Function
+end
+
+struct Reactive
+ status::Bool
+end
+struct EnableAck
+ status::Bool
+end
+
+function provide(server::Embedded, func::Function)
+ server.topic_function[string(func)] = func
+end
+
+"""
+ shared(rb::RBHandle, ctx)
+
+Bind a `ctx` context object to the `rb` component.
+
+When a `ctx` context object is bound then it will be the first argument of subscribed and
+exposed methods.
+"""
+shared(rb::RBHandle, ctx) = rb.shared = ctx
+
+function rembus(cid=nothing)
+ if cid === nothing
+ id = Rembus.CONFIG.cid
+ else
+ id = cid
+ end
+
+ cmp = Component(id)
+
+ if haskey(cmp.props, "server")
+ caronte(wait=false, exit_when_done=false)
+
+ while true
+ proc = from("caronte.serve_zeromq")
+ sleep(0.2)
+ if proc !== nothing && proc.status === Visor.running
+ break
+ end
+ end
+ end
+
+ rb = RBConnection(cmp)
+ process(
+ cmp.id,
+ rembus_task,
+ args=(rb, cmp.protocol),
+ debounce_time=CONFIG.connection_retry_period,
+ force_interrupt_after=3.0)
+end
+
+function rembus_task(pd, rb, protocol=:ws)
+ try
+ @debug "starting rembus process: $pd, protocol:$protocol"
+
+ connect(pd, rb)
+ for msg in pd.inbox
+ @debug "[$pd] recv: $msg"
+ if isshutdown(msg)
+ return
+ elseif isa(msg, Exception)
+ if isa(msg, ConnectionClosed) && isconnected(rb)
+ @debug "[$pd] ignoring connection closed message"
+ continue
+ end
+ @info "[$pd] rembus task: $msg"
+ throw(msg)
+ elseif isrequest(msg)
+ req = msg.request
+ if isa(req, SetHolder)
+ result = shared(rb, msg.request.shared)
+ elseif isa(req, AddImpl)
+ result = expose(
+ rb, string(msg.request.fn), msg.request.fn, exceptionerror=false
+ )
+ elseif isa(req, RemoveImpl)
+ result = unexpose(rb, string(msg.request.fn), exceptionerror=false)
+ elseif isa(req, AddInterest)
+ result = subscribe(
+ rb,
+ string(msg.request.fn),
+ msg.request.fn,
+ msg.request.retroactive,
+ exceptionerror=false
+ )
+ elseif isa(req, RemoveInterest)
+ result = unsubscribe(rb, string(msg.request.fn), exceptionerror=false)
+ elseif isa(req, Reactive)
+ if req.status
+ result = reactive(rb, exceptionerror=false)
+ else
+ result = unreactive(rb, exceptionerror=false)
+ end
+ else
+ result = rpc(
+ rb, msg.request.topic, msg.request.data, exceptionerror=false
+ )
+ end
+ reply(msg, result)
+ else
+ publish(rb, msg.topic, msg.data)
+ end
+ end
+ catch e
+ if isa(e, HTTP.Exceptions.ConnectError)
+ msg = "[$pd]: $(e.url) connection error"
+ else
+ msg = "[$pd]: $e"
+ end
+ @error msg
+ @showerror e
+ rethrow()
+ finally
+ @debug "[$pd]: terminating"
+ close(rb)
+ end
+end
+
+mutable struct NullProcess <: Visor.Supervised
+ id::String
+ inbox::Channel
+ NullProcess(id) = new(id, Channel(1))
+end
+
+add_receiver(ctx, method_name, impl) = ctx.receiver[method_name] = impl
+
+remove_receiver(ctx, method_name) = delete!(ctx.receiver, method_name)
+
+function when_connected(fn, rb)
+ while !isconnected(rb)
+ sleep(1)
+ end
+ fn()
+end
+
+#=
+ invoke(rb::RBConnection, topic::AbstractString, msg::RembusMsg)
+
+Invoke the method registered with `topic` name.
+=#
+function invoke(rb::RBConnection, topic::AbstractString, msg::RembusMsg)
+ if isa(msg.data, Vector)
+ if rb.shared === missing
+ return STS_SUCCESS, rb.receiver[topic](msg.data...)
+ else
+ return STS_SUCCESS, rb.receiver[topic](rb.shared, msg.data...)
+ end
+ else
+ if rb.shared === missing
+ return STS_SUCCESS, rb.receiver[topic](msg.data)
+ else
+ return STS_SUCCESS, rb.receiver[topic](rb.shared, msg.data)
+ end
+ end
+end
+
+#=
+ invoke_latest(rb::RBConnection, topic::AbstractString, msg::RembusMsg)
+
+Invoke the method registered with `topic` name using `Base.invokelatest`.
+=#
+function invoke_latest(rb::RBConnection, topic::AbstractString, msg::RembusMsg)
+ if isa(msg.data, Vector)
+ if rb.shared === missing
+ return STS_SUCCESS, Base.invokelatest(rb.receiver[topic], msg.data...)
+ else
+ return (
+ STS_SUCCESS, Base.invokelatest(rb.receiver[topic], rb.shared, msg.data...)
+ )
+ end
+ else
+ if rb.shared === missing
+ return STS_SUCCESS, Base.invokelatest(rb.receiver[topic], msg.data)
+ else
+ return STS_SUCCESS, Base.invokelatest(rb.receiver[topic], rb.shared, msg.data)
+ end
+ end
+end
+
+#=
+ invoke_glob(rb::RBConnection, topic::AbstractString, msg::RembusMsg)
+
+Invoke the method registered with `*` name for received messages with any topic.
+=#
+function invoke_glob(rb::RBConnection, msg::RembusMsg)
+ if isa(msg.data, Vector)
+ if rb.shared === missing
+ return STS_SUCCESS, rb.receiver["*"](msg.topic, msg.data...)
+ else
+ return STS_SUCCESS, rb.receiver["*"](rb.shared, msg.topic, msg.data...)
+ end
+ else
+ if rb.shared === missing
+ return STS_SUCCESS, rb.receiver["*"](msg.topic, msg.data)
+ else
+ return STS_SUCCESS, rb.receiver["*"](rb.shared, msg.topic, msg.data)
+ end
+ end
+end
+
+function rembus_handler(rb, msg, receiver)
+ fn::String = msg.topic
+ if haskey(rb.receiver, fn)
+ try
+ return receiver(rb, fn, msg)
+ catch e
+ @showerror e
+ io = IOBuffer()
+ showerror(io, e)
+ return STS_METHOD_EXCEPTION, String(take!(io))
+ end
+ elseif haskey(rb.receiver, "*")
+ try
+ invoke_glob(rb, msg)
+ return STS_SUCCESS, nothing
+ catch e
+ rethrow()
+ end
+ end
+ # no response: it is a broadcasted message or a published message
+ return STS_SUCCESS, nothing
+end
+
+function handle_input(rb, msg)
+ #@debug "<< [$(rb.client.id)] <- $msg"
+
+ if isresponse(msg)
+ if haskey(rb.out, msg.id)
+ # prevent timeout because when jit compiling
+ # notify() may be called before wait()
+ yield()
+
+ while notify(rb.out[msg.id], msg) == 0
+ @info "$msg: notifying too early"
+ sleep(0.0001)
+ end
+ else
+ # it is a response without a waiting Condition
+ if msg.data === nothing
+ @async ping(rb)
+ elseif msg.status == STS_CHALLENGE
+ @async resend_attestate(rb, msg)
+ else
+ @warn "ignoring response: $msg"
+ end
+ end
+ else
+ if isinteractive()
+ sts, result = rembus_handler(rb, msg, invoke_latest)
+ else
+ sts, result = rembus_handler(rb, msg, invoke)
+ end
+
+ if sts === STS_METHOD_EXCEPTION
+ @warn "[$(msg.topic)]: $result"
+ end
+
+ if isa(msg, RpcReqMsg)
+ response = ResMsg(msg.id, sts, result)
+ @debug "response: $response"
+ transport_send(rb.socket, response)
+ elseif isa(msg, PubSubMsg) && (msg.flags & 0x80) == 0x80
+ # check if ack is enabled
+ # @debug "$msg sending Ack with hash=$(msg.hash)"
+ transport_send(rb.socket, AckMsg(msg.hash))
+ end
+ end
+
+ return nothing
+end
+
+function trim_msg(msg)
+ if length(msg) > 500
+ return "$(first(msg, 500)) ..."
+ else
+ return msg
+ end
+end
+
+function parse_msg(rb, response)
+ try
+ msg = connected_socket_load(response)
+ handle_input(rb, msg)
+ catch e
+ @error "parse_msg: $e\n"
+ @showerror e
+ end
+
+ return nothing
+end
+
+keep_alive(socket::TCPSocket) = nothing
+
+function keep_alive(socket::WebSockets.WebSocket)
+ CONFIG.ws_ping_interval == 0 && return
+
+ while true
+ sleep(CONFIG.ws_ping_interval)
+ if isopen(socket.io)
+ @debug "socket ping"
+ ping(socket)
+ else
+ @debug "socket connection closed, keep alive done"
+ break
+ end
+ end
+end
+
+isok(sock::HTTP.WebSockets.WebSocket, e) = HTTP.WebSockets.isok(e)
+isok(sock, e) = false
+
+processput!(process::NullProcess, e) = nothing
+processput!(process::Visor.Process, e) = put!(process.inbox, e)
+
+function read_socket(socket, process, rb, isconnected::Condition)
+ try
+ rb.socket = socket
+ # signal to the initiator function _connect that the connection is up.
+ notify(isconnected)
+
+ # enable connection alive watchdog
+ @async keep_alive(rb.socket)
+ while isopen(socket)
+ response = transport_read(socket)
+ if !isempty(response)
+ @async parse_msg(rb, response)
+ else
+ @debug "[$(rb.client.id)] connection closed"
+ end
+ end
+ @info "[$process] socket closed"
+ catch e
+ @debug "[$(rb.client.id)] connection closed: $e"
+ if !isa(e, HTTP.WebSockets.WebSocketError) ||
+ !isa(e.message, HTTP.WebSockets.CloseFrameBody) ||
+ e.message.status != 1000
+ @showerror e
+ processput!(process, e)
+ end
+ end
+end
+
+function ws_connect(rb, process, isconnected::Condition)
+ try
+ if !haskey(ENV, "HTTP_CA_BUNDLE")
+ trust_store = keystore_dir()
+ ca_file = get(ENV, "REMBUS_CA", "rembus-ca.crt")
+ ENV["HTTP_CA_BUNDLE"] = joinpath(trust_store, ca_file)
+ end
+
+ url = brokerurl(rb.client)
+
+ if startswith(url, "wss:")
+ HTTP.WebSockets.open(socket -> begin
+ read_socket(socket, process, rb, isconnected)
+ end, url)
+ else
+ HTTP.WebSockets.open(socket -> begin
+ ## Sockets.nagle(socket.io.io, false)
+ ## Sockets.quickack(socket.io.io, true)
+ read_socket(socket, process, rb, isconnected)
+ end, url, idle_timeout=1, forcenew=true)
+ end
+ catch e
+ notify(isconnected, e, error=true)
+ @showerror e
+ end
+end
+
+function zmq_receive(rb)
+ while true
+ try
+ msg = zmq_load(rb.socket)
+ handle_input(rb, msg)
+ catch e
+ if !isopen(rb.socket)
+ break
+ else
+ @error "[zmq_receive] error: $e"
+ @showerror e
+ end
+ end
+ end
+ @debug "zmq socket closed"
+end
+
+function zmq_connect(rb)
+ rb.context = ZMQ.Context()
+ rb.socket = ZMQ.Socket(rb.context, DEALER)
+ rb.socket.linger = 1
+ try
+ url = brokerurl(rb.client)
+ ZMQ.connect(rb.socket, url)
+ @async zmq_receive(rb)
+
+ CONFIG.zmq_ping_interval > 0 && Timer(tmr -> ping(rb), CONFIG.zmq_ping_interval)
+ catch e
+ @showerror e
+ close(rb.socket)
+ close(rb.context)
+ rethrow()
+ end
+
+ return nothing
+end
+
+function tcp_connect(rb, process, isconnected::Condition)
+ try
+ trust_store = keystore_dir()
+ ca_file = get(ENV, "REMBUS_CA", "rembus-ca.crt")
+
+ url = brokerurl(rb.client)
+ uri = URI(url)
+
+ cacert = get(ENV, "HTTP_CA_BUNDLE", joinpath(trust_store, ca_file))
+
+ @debug "connecting to $(uri.scheme):$(uri.host):$(uri.port)"
+ if uri.scheme == "tls"
+ entropy = MbedTLS.Entropy()
+ rng = MbedTLS.CtrDrbg()
+ MbedTLS.seed!(rng, entropy)
+
+ ctx = MbedTLS.SSLContext()
+
+ sslconf = MbedTLS.SSLConfig(true)
+ MbedTLS.config_defaults!(sslconf)
+
+ MbedTLS.rng!(sslconf, rng)
+
+ MbedTLS.ca_chain!(sslconf, MbedTLS.crt_parse(read(cacert, String)))
+
+ function show_debug(level, filename, number, msg)
+ println((level, filename, number, msg))
+ end
+
+ MbedTLS.dbg!(sslconf, show_debug)
+
+ sock = Sockets.connect(uri.host, parse(Int, uri.port))
+ MbedTLS.setup!(ctx, sslconf)
+ MbedTLS.set_bio!(ctx, sock)
+ MbedTLS.handshake(ctx)
+
+ @async read_socket(ctx, process, rb, isconnected)
+ elseif uri.scheme == "tcp"
+ sock = Sockets.connect(uri.host, parse(Int, uri.port))
+ @async read_socket(sock, process, rb, isconnected)
+ end
+ catch e
+ @error "tcp_connect: $e"
+ notify(isconnected, e, error=true)
+ end
+end
+
+function pkfile(name)
+ cfgdir = joinpath(get(ENV, "HOME", "."), APP_CONFIG_DIR, "rembus")
+ if !isdir(cfgdir)
+ mkpath(cfgdir)
+ end
+
+ return joinpath(cfgdir, name)
+end
+
+function loadkey(name::AbstractString)
+ file = pkfile(name)
+ @debug "keyfile: $file"
+ if isfile(file)
+ return MbedTLS.parse_keyfile(file)
+ end
+
+ return missing
+end
+
+function resend_attestate(rb, response)
+ try
+ msg = attestate(rb, response)
+ rembus_write(rb, msg)
+ catch e
+ @error "resend_attestate: $e"
+ @showerror e
+ end
+
+ return nothing
+end
+
+function attestate(rb, response)
+ file = pkfile(rb.client.id)
+ if !isfile(file)
+ error("unable to find $(rb.client.id) secret")
+ end
+
+ try
+ ctx = MbedTLS.parse_keyfile(file)
+ plain = encode([Vector{UInt8}(response.data), rb.client.id])
+ hash = MbedTLS.digest(MD_SHA256, plain)
+ signature = MbedTLS.sign(ctx, MD_SHA256, hash, MersenneTwister(0))
+ return Attestation(rb.client.id, signature)
+ catch e
+ if isa(e, MbedTLS.MbedException)
+ # try with a plain secret
+ secret = readline(file)
+ plain = encode([response.data, secret])
+ hash = MbedTLS.digest(MD_SHA256, plain)
+ @debug "[$(rb.client.id)] digest: $hash"
+ return Attestation(rb.client.id, hash)
+ end
+ end
+end
+
+function authenticate(rb)
+ if rb.client.id == "rembus"
+ return nothing
+ end
+
+ reason = nothing
+ msg = IdentityMsg(rb.client.id)
+ response = wait_response(rb, msg, request_timeout())
+
+ if isa(response, RembusTimeout)
+ close(rb.socket)
+ throw(response)
+ elseif (response.status == STS_CHALLENGE)
+ msg = attestate(rb, response)
+ response = wait_response(rb, msg, request_timeout())
+ end
+
+ if (response.status != STS_SUCCESS)
+ # Avoid DOS attack: this has to be the server work!!
+ close(rb.socket)
+ rembuserror(code=response.status, reason=reason)
+ end
+
+ return nothing
+end
+
+function _connect(rb, process)
+ if rb.client.protocol === :ws || rb.client.protocol === :wss
+ isconnected = Condition()
+ @async ws_connect(rb, process, isconnected)
+ wait(isconnected)
+ elseif rb.client.protocol === :tcp || rb.client.protocol === :tls
+ isconnected = Condition()
+ @async tcp_connect(rb, process, isconnected)
+ wait(isconnected)
+ elseif rb.client.protocol === :zmq
+ zmq_connect(rb)
+ else
+ throw(ErrorException(
+ "wrong protocol $(rb.client.protocol): must be tcp|tls|zmq|ws|wss"
+ ))
+ end
+
+ return rb
+end
+
+function ping(socket)
+ try
+ WebSockets.ping(socket)
+ catch e
+ @error "socket ping: $e"
+ end
+
+ return nothing
+end
+
+function rembus_write(rb::RBHandle, msg)
+ @debug ">> [$(rb.client.id)] -> $msg"
+ transport_send(rb.socket, msg)
+ return nothing
+end
+
+function rembus_block_write(rb::RBHandle, msg, cond)
+ @debug ">> [$(rb.client.id)] -> $msg"
+ transport_send(rb.socket, msg)
+ wait(cond)
+ return nothing
+end
+
+function configure(rb::RBHandle, retroactives=Dict(), interests=Dict(), impls=Dict())
+ for (topic, fn) in retroactives
+ subscribe(rb, topic, fn, true)
+ end
+ for (topic, fn) in interests
+ subscribe(rb, topic, fn, false)
+ end
+ for (topic, fn) in impls
+ expose(rb, topic, fn)
+ end
+
+ return rb
+end
+
+function isconnected(rb::RBConnection)
+ if rb.socket === nothing
+ false
+ else
+ if isa(rb.socket, WebSockets.WebSocket)
+ return isopen(rb.socket.io)
+ elseif isa(rb.socket, TCPSocket)
+ return !(
+ rb.socket.status === Base.StatusClosed ||
+ rb.socket.status === Base.StatusEOF
+ )
+ elseif isa(rb.socket, ZMQ.Socket)
+ return isopen(rb.socket)
+ else
+ return !(
+ rb.socket.bio.status === Base.StatusClosed ||
+ rb.socket.bio.status === Base.StatusEOF
+ )
+ end
+ end
+end
+
+isconnected(rb::RBPool) = any(c -> isconnected(c), rb.connections)
+
+function connect(rb::RBConnection)
+ if !isconnected(rb)
+ _connect(rb, NullProcess(rb.client.id))
+ authenticate(rb)
+ end
+
+ return rb
+end
+
+"""
+ connect()
+
+Connect anonymously to the broker.
+
+A random v4 UUID is used as component identifier.
+"""
+function connect()
+ rb = RBConnection()
+ return connect(rb)
+end
+
+"""
+ connect(url::AbstractString)::RBHandle
+
+Connect to the broker.
+
+The returned rembus handler do not auto-reconnect in case of a fault condition.
+
+The returned `RBHandle` handle represents a connected component
+used for the Rembus APIs. For example:
+
+```julia
+using Rembus
+rb = connect("mycomponent")
+publish(rb, "temperature", ["room_1", 21.5])
+```
+"""
+function connect(url::AbstractString)::RBHandle
+ process = NullProcess(url)
+ rb = RBConnection(process.id)
+ return connect(rb)
+end
+
+#=
+ connect(process::Visor.Supervised, rb::RBHandle)
+
+Connect the component defined by the `rb` handle to the broker.
+
+The supervised task `process` receives an `Exception` message when
+an exception is thrown by the `read_socket()`.
+
+The `process` supervisor try to auto-reconnect if an exception occurs.
+=#
+function connect(process::Visor.Supervised, rb::RBHandle)
+ _connect(rb, process)
+ authenticate(rb)
+ if rb.reactive
+ reactive(rb)
+ end
+
+ return rb
+end
+
+function connect(rb::RBPool)
+ for c in rb.connections
+ try
+ connect(c)
+ catch e
+ if isa(e, RembusError)
+ @warn "error: $e"
+ else
+ @warn "connection failed: $(e.url)"
+ end
+ end
+ end
+
+ return rb
+end
+
+function connect(urls::Vector)
+ pool = RBPool([RBConnection(url) for url in urls])
+ return connect(pool)
+end
+
+function login(rb::RBHandle, cid::AbstractString, secret::AbstractString)
+ try
+ challenge = rpc(rb, "challenge")
+ attestation = MbedTLS.digest(MbedTLS.MD_SHA256, encode([challenge, secret]))
+ @debug "[$cid] digest: $attestation"
+ rpc(rb, "login", [cid, attestation]) || error("invalid password")
+ catch e
+ error("login failed: $e")
+ end
+
+ return nothing
+end
+
+function Base.close(rb::RBHandle)
+ try
+ if rb.socket !== nothing
+ if isa(rb.socket, ZMQ.Socket)
+ transport_send(rb.socket, Close())
+ close(rb.context)
+ else
+ lock(websocketlock) do
+ close(rb.socket)
+ end
+ end
+ end
+ catch e
+ @warn "[$(rb.client.id)] close: $e"
+ end
+
+ return nothing
+end
+
+function assert_rembus(process::Visor.Process)
+ if length(process.args) == 0 || !isa(process.args[1], RBHandle)
+ throw(ErrorException("invalid $process process: not a rembus process"))
+ end
+end
+
+function enable_debug(rb::RBHandle)
+ return rpcreq(rb, AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => ENABLE_DEBUG_CMD)))
+end
+
+function disable_debug(rb::RBHandle)
+ return rpcreq(rb, AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => DISABLE_DEBUG_CMD)))
+end
+
+function broker_config(rb::RBHandle; exceptionerror=true)
+ return rpcreq(rb,
+ AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => BROKER_CONFIG_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function load_config(rb::RBHandle; exceptionerror=true)
+ return rpcreq(rb,
+ AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => LOAD_CONFIG_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function save_config(rb::RBHandle; exceptionerror=true)
+ return rpcreq(rb,
+ AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => SAVE_CONFIG_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function disable_ack(rb::RBHandle; exceptionerror=true)
+ return rpcreq(rb,
+ AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => DISABLE_ACK_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function enable_ack(rb::RBHandle, timeout=5; exceptionerror=true)
+ return rpcreq(rb,
+ AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => ENABLE_ACK_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+"""
+ unreactive(rb::RBHandle, timeout=5; exceptionerror=true)
+
+Stop the delivery of published message.
+"""
+function unreactive(rb::RBHandle; exceptionerror=true)
+ response = rpcreq(rb,
+ AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => REACTIVE_CMD, STATUS => false)),
+ exceptionerror=exceptionerror
+ )
+ rb.reactive = false
+
+ return response
+end
+
+"""
+ reactive(rb::RBHandle, timeout=5; exceptionerror=true)
+
+Start the delivery of published messages for which there was declared
+an interest with [`subscribe`](@ref).
+"""
+function reactive(rb::RBHandle; exceptionerror=true)
+ response = rpcreq(rb,
+ AdminReqMsg(BROKER_CONFIG, Dict(COMMAND => REACTIVE_CMD, STATUS => true)),
+ exceptionerror=exceptionerror
+ )
+ rb.reactive = true
+
+ return response
+end
+
+"""
+ subscribe(
+ rb::RBHandle, topic::AbstractString, fn::Function, retroactive::Bool=false;
+ exceptionerror=true
+ )
+
+Declare interest for messages published on `topic`.
+
+The function `fn` is called when a message is received on `topic` and
+[`reactive`](@ref) put the `rb` component in reactive mode.
+
+If `retroactive` is `true` then `rb` component will receive messages published when it was
+offline.
+"""
+function subscribe(
+ rb::RBHandle, topic::AbstractString, fn::Function, retroactive::Bool=false;
+ exceptionerror=true
+)
+ add_receiver(rb, topic, fn)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => ADD_INTEREST_CMD, RETROACTIVE => retroactive)),
+ exceptionerror=exceptionerror
+ )
+end
+
+"""
+ unsubscribe(rb::RBHandle, topic::AbstractString; exceptionerror=true)
+
+No more messages published on `topic` will be delivered to `rb` component.
+"""
+function unsubscribe(rb::RBHandle, topic::AbstractString; exceptionerror=true)
+ remove_receiver(rb, topic)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => REMOVE_INTEREST_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+"""
+ expose(rb::RBHandle, topic::AbstractString, fn::Function; exceptionerror=true)
+
+The methods of function `fn` are registered to be executed when
+a RPC `topic` request is received.
+
+The returned value is the RPC response returned to the RPC client.
+"""
+function expose(rb::RBHandle, topic::AbstractString, fn::Function; exceptionerror=true)
+ add_receiver(rb, topic, fn)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => ADD_IMPL_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+"""
+ unexpose(rb::RBHandle, topic::AbstractString; exceptionerror=true)
+
+Stop servicing RPC `topic` request.
+"""
+function unexpose(rb::RBHandle, topic::AbstractString; exceptionerror=true)
+ remove_receiver(rb, topic)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => REMOVE_IMPL_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function private_topic(rb::RBHandle, topic::AbstractString; exceptionerror=true)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => PRIVATE_TOPIC_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function public_topic(rb::RBHandle, topic::AbstractString; exceptionerror=true)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => PUBLIC_TOPIC_CMD)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function authorize(
+ rb::RBHandle, client::AbstractString, topic::AbstractString;
+ exceptionerror=true
+)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => AUTHORIZE_CMD, CID => client)),
+ exceptionerror=exceptionerror
+ )
+end
+
+function unauthorize(
+ rb::RBHandle, client::AbstractString, topic::AbstractString;
+ exceptionerror=true
+)
+ return rpcreq(rb,
+ AdminReqMsg(topic, Dict(COMMAND => UNAUTHORIZE_CMD, CID => client)),
+ exceptionerror=exceptionerror
+ )
+end
+
+# """
+# ping(rb::RBHandle)
+#
+# Send a ping message to check if the broker is online.
+#
+# Required by ZeroMQ socket.
+# """
+function ping(rb::RBHandle)
+ try
+ rpcreq(rb, PingMsg(rb.client.id))
+ CONFIG.zmq_ping_interval > 0 && Timer(tmr -> ping(rb), CONFIG.zmq_ping_interval)
+ catch e
+ @debug "[$(rb.client.id)]: pong not received"
+ @showerror e
+ end
+
+ return nothing
+end
+
+"""
+ publish(rb::RBHandle, topic::AbstractString, data=[])
+
+Publish `data` values on topic `topic`.
+
+`data` may be any type of data, but if the components are implemented in different languages
+then data has to be a DataFrame or a [CBOR](https://www.rfc-editor.org/rfc/rfc8949.html)
+basic data type.
+"""
+function publish(rb::RBHandle, topic::AbstractString, data=[])
+ return rembus_write(rb, PubSubMsg(topic, data))
+end
+
+"""
+ rpc(rb::RBHandle,
+ topic::AbstractString,
+ data=nothing;
+ exceptionerror=true,
+ timeout=request_timeout())
+
+Call the remote `topic` method with arguments extracted from `data`.
+
+## Exposer
+
+```julia
+using Rembus
+using Statistics
+
+@expose service_noargs() = "success"
+
+@expose service_name(name) = "hello " * name
+
+@expose service_dictionary(d) = mean(values(d))
+
+@expose function service_multiple_args(name, score, flags)
+ isa(name, String) && isa(score, Float64) && isa(flags, Vector)
+end
+```
+
+## RPC client
+
+```julia
+using Rembus
+
+rb = connect()
+
+rcp(rb, "service_noargs")
+
+rpc(rb, "service_name", "hello world")
+
+rpc(rb, "service_dictionary", Dict("r1"=>13.3, "r2"=>3.0))
+
+rpc(rb, "service_multiple_args", ["name", 1.0, ["red"=>1,"blue"=>2,"yellow"=>3]])
+```
+"""
+function rpc(rb::RBHandle, topic::AbstractString, data=[];
+ exceptionerror=true, timeout=request_timeout())
+ rpcreq(rb, RpcReqMsg(topic, data), exceptionerror=exceptionerror, timeout=timeout)
+end
+
+function direct(
+ rb::RBHandle, target::AbstractString, topic::AbstractString, data=nothing;
+ exceptionerror=false
+)
+ return rpcreq(rb, RpcReqMsg(topic, data, target), exceptionerror=exceptionerror)
+end
+
+function response_timeout(condition::Condition, msg::RembusMsg)
+ if hasproperty(msg, :topic)
+ descr = "[$(msg.topic)]: request timeout"
+ else
+ descr = "[$msg]: request timeout"
+ end
+ notify(condition, RembusTimeout(descr), error=false)
+
+ return nothing
+end
+
+# https://github.com/JuliaLang/julia/issues/36217
+function wait_response(rb::RBHandle, msg::RembusMsg, timeout)
+ mid::UInt128 = msg.id
+ resp_cond = Condition()
+ rb.out[mid] = resp_cond
+ t = Timer((tim) -> response_timeout(resp_cond, msg), timeout)
+ # @async ensures that wait is always triggered before notify
+ rembus_write(rb, msg)
+ try
+ return wait(resp_cond)
+ catch e
+ @debug "[$msg]: response timeout ($e)"
+ rethrow()
+ finally
+ close(t)
+ delete!(rb.out, mid)
+ end
+end
+
+# Send a RpcReqMsg message to rembus and return the response.
+function rpcreq(handle::RBHandle, msg; exceptionerror=true, timeout=request_timeout())
+ outcome = nothing
+ !isconnected(handle) && error("connection is down")
+
+ if isa(handle, RBPool)
+ if CONFIG.balancer === "first_up"
+ rb = first_up(handle, msg.topic, handle.connections)
+ elseif CONFIG.balancer === "round_robin"
+ rb = round_robin(handle, msg.topic, handle.connections)
+ else
+ rb = less_busy(handle, msg.topic, handle.connections)
+ end
+ else
+ rb = handle
+ end
+
+ response = wait_response(rb, msg, timeout)
+ if isa(response, RembusTimeout)
+ outcome = response
+ if exceptionerror
+ throw(outcome)
+ end
+ elseif response.status == STS_SUCCESS
+ outcome = response.data
+ else
+ outcome = rembuserror(exceptionerror, code=response.status,
+ cid=rb.client.id,
+ topic=msg.topic,
+ reason=response.data)
+ if exceptionerror
+ throw(outcome)
+ end
+ end
+
+ return outcome
+end
+
+function broker_shutdown()
+ admin = connect("admin")
+ rpcreq(admin, AdminReqMsg("__config__", Dict(COMMAND => SHUTDOWN_CMD)))
+end
+
+function forever()
+ process = from(CONFIG.cid)
+ if process !== nothing
+ cmp = process.args[1]
+ reactive(cmp)
+ # Don't block the REPL!
+ isinteractive() ? nothing : supervise()
+ end
+end
+
+@setup_workload begin
+ ENV["REMBUS_ZMQ_PING_INTERVAL"] = "0"
+ ENV["REMBUS_WS_PING_INTERVAL"] = "0"
+ @compile_workload begin
+ sv = Rembus.caronte(wait=false, exit_when_done=false)
+ Rembus.islistening(10)
+ include("precompile.jl")
+ shutdown()
+ end
+end
+
+end # module
diff --git a/src/admin.jl b/src/admin.jl
new file mode 100644
index 0000000..7251ac9
--- /dev/null
+++ b/src/admin.jl
@@ -0,0 +1,212 @@
+"""
+ private_topic(router, twin, msg)
+
+Administration command to declare a private topic.
+"""
+function private_topic(router, twin, msg)
+ sts = STS_SUCCESS
+ if isadmin(router, twin, PRIVATE_TOPIC_CMD)
+ callback_and(Symbol(PRIVATE_TOPIC_CMD), router, twin, msg) do
+ if !haskey(router.topic_auth, msg.topic)
+ router.topic_auth[msg.topic] = Dict()
+ end
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ return sts
+end
+
+"""
+ public_topic(router, twin, msg)
+
+Administration command to reset a topic to public.
+"""
+function public_topic(router, twin, msg)
+ sts = STS_SUCCESS
+ if isadmin(router, twin, PUBLIC_TOPIC_CMD)
+ callback_and(Symbol(PUBLIC_TOPIC_CMD), router, twin, msg) do
+ delete!(router.topic_auth, msg.topic)
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ return sts
+end
+
+"""
+ authorize(router, twin, msg)
+
+Administration command to authorize a component to publish/subscribe to a private topic.
+"""
+function authorize(router, twin, msg)
+ sts = STS_SUCCESS
+ if isadmin(router, twin, AUTHORIZE_CMD) &&
+ haskey(msg.data, CID) &&
+ !isempty(msg.data[CID])
+ callback_and(Symbol(AUTHORIZE_CMD), router, twin, msg) do
+ if !haskey(router.topic_auth, msg.topic)
+ router.topic_auth[msg.topic] = Dict()
+ end
+ router.topic_auth[msg.topic][msg.data[CID]] = true
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+
+ return sts
+end
+
+"""
+ unauthorize(router, twin, msg)
+
+Administration command to unauthorize a component to publish/subscribe to a private topic.
+"""
+function unauthorize(router, twin, msg)
+ sts = STS_SUCCESS
+ if isadmin(router, twin, UNAUTHORIZE_CMD) &&
+ haskey(msg.data, CID) &&
+ !isempty(msg.data[CID])
+ callback_and(Symbol(UNAUTHORIZE_CMD), router, twin, msg) do
+ if haskey(router.topic_auth, msg.topic)
+ delete!(router.topic_auth[msg.topic], msg.data[CID])
+ end
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+
+ return sts
+end
+
+function admin_command(router, twin, msg::AdminReqMsg)
+ if !isa(msg.data, Dict) || !haskey(msg.data, COMMAND)
+ return AdminResMsg(msg.id, STS_GENERIC_ERROR, nothing)
+ end
+
+ sts = STS_SUCCESS
+ data = nothing
+ cmd = msg.data[COMMAND]
+ if cmd == ADD_INTEREST_CMD
+ if isauth(router, twin, msg.topic)
+ callback_and(Symbol(ADD_INTEREST_CMD), router, twin, msg) do
+ retroactive = get(msg.data, RETROACTIVE, true)
+ twin.retroactive[msg.topic] = retroactive
+ if haskey(router.topic_interests, msg.topic)
+ push!(router.topic_interests[msg.topic], twin)
+ else
+ router.topic_interests[msg.topic] = Set([twin])
+ end
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ elseif cmd == ADD_IMPL_CMD
+ if isauth(router, twin, msg.topic)
+ callback_and(Symbol(ADD_IMPL_CMD), router, twin, msg) do
+ if haskey(router.topic_impls, msg.topic)
+ push!(router.topic_impls[msg.topic], twin)
+ else
+ router.topic_impls[msg.topic] = Set([twin])
+ end
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ elseif cmd == REMOVE_INTEREST_CMD
+ if isauth(router, twin, msg.topic)
+ callback_and(Symbol(REMOVE_INTEREST_CMD), router, twin, msg) do
+ if haskey(router.topic_interests, msg.topic)
+ if twin in router.topic_interests[msg.topic]
+ delete!(router.topic_interests[msg.topic], twin)
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ # remove from twin configuration
+ if haskey(twin.retroactive, msg.topic)
+ delete!(twin.retroactive, msg.topic)
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ elseif cmd == REMOVE_IMPL_CMD
+ if isauth(router, twin, msg.topic)
+ callback_and(Symbol(REMOVE_IMPL_CMD), router, twin, msg) do
+ if haskey(router.topic_impls, msg.topic)
+ if twin in router.topic_impls[msg.topic]
+ delete!(router.topic_impls[msg.topic], twin)
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ elseif cmd == TOPICS_CONFIG_CMD
+ # only admins is authorized
+ if isadmin(router, twin, cmd)
+ data = Dict()
+ for (topic, cids) in router.topic_auth
+ data[topic] = collect(keys(cids))
+ end
+ else
+ sts = STS_GENERIC_ERROR
+ end
+ elseif cmd == PRIVATE_TOPIC_CMD
+ sts = private_topic(router, twin, msg)
+ elseif cmd == PUBLIC_TOPIC_CMD
+ sts = public_topic(router, twin, msg)
+ elseif cmd == AUTHORIZE_CMD
+ sts = authorize(router, twin, msg)
+ elseif cmd == UNAUTHORIZE_CMD
+ sts = unauthorize(router, twin, msg)
+ elseif cmd == REACTIVE_CMD
+ enabled = get(msg.data, STATUS, false)
+ if enabled
+ start_reactive(twin)
+ else
+ twin.reactive = false
+ end
+ elseif cmd === ENABLE_ACK_CMD
+ twin.qos = with_ack
+ elseif cmd === DISABLE_ACK_CMD
+ twin.qos = fast
+ elseif cmd === BROKER_CONFIG_CMD
+ data = router_configuration(router)
+ elseif cmd === LOAD_CONFIG_CMD
+ # first save data to disk and then return the configuration
+ load_configuration(router)
+ elseif cmd === SAVE_CONFIG_CMD
+ # first save data to disk and then return the configuration
+ save_configuration(router)
+ elseif cmd === RESET_ROUTER_CMD
+ empty!(router.topic_impls)
+ empty!(router.topic_interests)
+ elseif cmd == SHUTDOWN_CMD
+ @debug "shutting down ..."
+ sts = STS_SHUTDOWN
+ try
+ Visor.shutdown(router.process.supervisor)
+ catch e
+ @error "$SHUTDOWN_CMD: $e"
+ end
+ elseif cmd == ENABLE_DEBUG_CMD
+ CONFIG.debug_modules = [Rembus, Visor]
+ elseif cmd == DISABLE_DEBUG_CMD
+ CONFIG.debug_modules = []
+ elseif cmd == UPTIME_CMD
+ data = uptime(router)
+ else
+ @error "invalid admin command: $cmd"
+ sts = STS_UNKNOWN_ADMIN_CMD
+ end
+
+ return ResMsg(msg.id, sts, data)
+end
diff --git a/src/broker.jl b/src/broker.jl
new file mode 100644
index 0000000..d9edae9
--- /dev/null
+++ b/src/broker.jl
@@ -0,0 +1,1781 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+@enum QoS fast with_ack
+
+abstract type AbstractRouter end
+
+#=
+Twin is the broker-side image of a component.
+
+`sock` is the socket handle when the protocol is tcp/tls or ws/wss.
+
+For ZMQ sockets one socket is shared between all twins.
+=#
+mutable struct Twin
+ router::AbstractRouter
+ id::String
+ session::Dict{String,Any}
+ hasname::Bool
+ isauth::Bool
+ sock::Any
+ retroactive::Dict{String,Bool}
+ mq::Queue{PubSubMsg}
+ out::Channel # router inbox
+ sent::Dict{UInt128,Any} # msg.id => timestamp of sending
+ acktimer::Dict{UInt128,Timer}
+ qos::QoS
+ mfile::Union{IOStream,Nothing}
+ reactive::Bool
+ process::Visor.Process
+
+ Twin(router, id, out_channel) = new(
+ router,
+ id,
+ Dict(),
+ false,
+ false,
+ nothing,
+ Dict(),
+ Queue{PubSubMsg}(),
+ out_channel,
+ Dict(),
+ Dict(),
+ fast,
+ nothing,
+ true,
+ )
+ Twin(router, id, queue, out_channel) = new(
+ router,
+ id,
+ Dict(),
+ false,
+ false,
+ nothing,
+ Dict(),
+ queue,
+ out_channel,
+ Dict(),
+ Dict(),
+ fast,
+ nothing,
+ true,
+ )
+end
+
+mutable struct Msg
+ ptype::UInt8
+ content::RembusMsg
+ twchannel::Twin
+ reqdata::Any
+ Msg(ptype, content, src) = new(ptype, content, src)
+ Msg(ptype, content, src, reqdata) = new(ptype, content, src, reqdata)
+end
+
+struct SentData
+ sending_ts::Float64
+ request::Msg
+end
+
+mutable struct Embedded <: AbstractRouter
+ topic_function::Dict{String,Function}
+ id_twin::Dict{String,Twin}
+ process::Visor.Process
+ ws_server::Sockets.TCPServer
+ Embedded() = new(
+ Dict(),
+ Dict(),
+ )
+end
+
+mutable struct Router <: AbstractRouter
+ start_ts::Float64
+ address2twin::Dict{Vector{UInt8},Twin} # zeromq address => twin
+ twin2address::Dict{String,Vector{UInt8}} # twin id => zeromq address
+ mid2address::Dict{UInt128,Vector{UInt8}} # message.id => zeromq connection address
+ topic_impls::Dict{String,OrderedSet{Twin}} # topic => twins implementor
+ last_invoked::Dict{String,Int} # topic => twin index last called
+ topic_interests::Dict{String,Set{Twin}} # topic => twins subscribed to topic
+ id_twin::Dict{String,Twin}
+ topic_function::Dict{String,Function}
+ topic_auth::Dict{String,Dict{String,Bool}} # topic => {twin.id => true}
+ admins::Set{String}
+ twin_initialize::Function
+ twin_finalize::Function
+ park::Function
+ unpark::Function
+ process::Visor.Process
+ server::Sockets.TCPServer
+ ws_server::Sockets.TCPServer
+ zmqsocket::ZMQ.Socket
+ owners::DataFrame
+ token_app::DataFrame
+ Router() = new(
+ time(),
+ Dict(),
+ Dict(),
+ Dict(),
+ Dict(),
+ Dict(),
+ Dict(),
+ Dict(),
+ Dict(),
+ Dict(),
+ Set(),
+ twin_initialize,
+ twin_finalize,
+ park,
+ unpark
+ )
+end
+
+Base.hash(t::Twin, h::UInt) = hash(t.id, hash(:Twin, h))
+Base.:(==)(a::Twin, b::Twin) = isequal(a.id, b.id)
+Base.show(io::IO, t::Twin) = print(io, t.id)
+
+function Base.show(io::IO, msg::Msg)
+ if (isa(msg.content, ResMsg) || isa(msg.content, PubSubMsg)) &&
+ isa(msg.content.data, Vector{UInt8})
+ len = length(msg.content.data)
+ cnt = len > 10 ? msg.content.data[1:10] : msg.content.data
+ print(io, "binary[$len] $cnt ...")
+ else
+ print(io, "$(msg.content)")
+ end
+end
+
+macro mlog(str)
+ quote
+ if CONFIG.metering
+ @info $(esc(str))
+ end
+ end
+end
+
+macro rawlog(msg)
+ quote
+ if CONFIG.rawdump
+ @info $(esc(msg))
+ end
+ end
+end
+
+## Twin related functions
+
+twin_initialize(ctx, twin) = (ctx, twin) -> ()
+
+twin_finalize(ctx, twin) = (ctx, twin) -> ()
+
+offline(twin::Twin) = ((twin.sock === nothing) || !isopen(twin.sock))
+
+session(twin::Twin) = twin.session
+
+function create_twin(id, router::Embedded, queue=Queue{PubSubMsg}())
+ if haskey(router.id_twin, id)
+ return router.id_twin[id]
+ else
+ twin = Twin(router, id, Channel())
+ spec = process(id, twin_task, args=(twin,))
+ startup(from("caronte.twins"), spec)
+ router.id_twin[id] = twin
+ return twin
+ end
+end
+
+named_twin(id, router) = haskey(router.id_twin, id) ? router.id_twin[id] : nothing
+
+function create_twin(id, router, queue=Queue{PubSubMsg}())
+ if haskey(router.id_twin, id)
+ return router.id_twin[id]
+ else
+ twin = Twin(router, id, queue, router.process.inbox)
+ spec = process(id, twin_task, args=(twin,))
+ startup(from("caronte.twins"), spec)
+ router.id_twin[id] = twin
+ twin_initialize(CONFIG.broker_ctx, twin)
+ return twin
+ end
+end
+
+#=
+ offline!(twin)
+
+Unbind the ZMQ socket from the twin.
+=#
+function offline!(twin)
+ @debug "[$twin] closing: going offline"
+ twin.sock = nothing
+
+ return nothing
+end
+
+#=
+ destroy_twin(twin, router)
+
+Remove the twin from the system.
+
+Shutdown the process and remove the twin from the router.
+=#
+function destroy_twin(twin, router)
+ if isdefined(twin, :process)
+ Visor.shutdown(twin.process)
+ end
+
+ # Remove from address2twin
+ filter!(((k, v),) -> twin != v, router.address2twin)
+
+ # Remove from topic_impls
+ for topic in keys(router.topic_impls)
+ delete!(router.topic_impls[topic], twin)
+ if isempty(router.topic_impls[topic])
+ delete!(router.topic_impls, topic)
+ end
+ end
+
+ # Remove from topic_interests
+ for topic in keys(router.topic_interests)
+ delete!(router.topic_interests[topic], twin)
+ if isempty(router.topic_interests[topic])
+ delete!(router.topic_interests, topic)
+ end
+ end
+
+ delete!(router.id_twin, twin.id)
+ return nothing
+end
+
+function destroy_twin(twin, router::Embedded)
+ if isdefined(twin, :process)
+ Visor.shutdown(twin.process)
+ end
+
+ delete!(router.id_twin, twin.id)
+ return nothing
+end
+
+
+#=
+ dequeue_messages(twin)
+
+Take the in-memory messages and send to the component.
+=#
+function dequeue_messages(twin)
+ while !isempty(twin.mq) && isopen(twin.sock.io)
+ if twin.qos === with_ack
+ io = transport_file_io(first(twin.mq))
+ if write_waitack(twin.sock, io, 3)
+ popfirst!(twin.mq)
+ else
+ detach(twin)
+ end
+ elseif twin.qos === fast
+ transport_write(twin.sock, first(twin.mq))
+ end
+ end
+
+ return nothing
+end
+
+function start_reactive(twin)
+ twin.reactive = true
+ # sends to real client
+ # all enqueued messages
+ @async twin.router.unpark(CONFIG.broker_ctx, twin)
+ return nothing
+end
+
+function verify_signature(twin, msg)
+ challenge = pop!(twin.session, "challenge")
+ @debug "verify signature, challenge $challenge"
+ file = pubkey_file(msg.cid)
+
+ try
+ ctx = MbedTLS.parse_public_keyfile(file)
+ plain = encode([challenge, msg.cid])
+ hash = MbedTLS.digest(MD_SHA256, plain)
+ MbedTLS.verify(ctx, MD_SHA256, hash, msg.signature)
+ catch e
+ if isa(e, MbedTLS.MbedException) &&
+ e.ret == MbedTLS.MBEDTLS_ERR_RSA_VERIFY_FAILED
+ rethrow()
+ end
+ # try with a plain secret
+ @debug "verify signature with password string"
+ secret = readline(file)
+ plain = encode([challenge, secret])
+ digest = MbedTLS.digest(MbedTLS.MD_SHA256, plain)
+ @debug "[$twin] digest plain: $plain"
+ if digest != msg.signature
+ error("authentication failed")
+ end
+ end
+
+ return true
+end
+
+#=
+ setidentity(router, twin, msg)
+
+Update twin identity parameters.
+=#
+function setidentity(router, twin, msg, isauth=false)
+ # get the eventually created twin associate with cid
+ namedtwin = create_twin(msg.cid, router)
+ # move the opened socket
+ namedtwin.sock = twin.sock
+ # destroy the anonymous process
+ destroy_twin(twin, router)
+ namedtwin.hasname = true
+ namedtwin.isauth = isauth
+ return namedtwin
+end
+
+function login(router, twin, msg)
+ if haskey(router.topic_function, "login")
+ router.topic_function["login"](twin, msg.cid, msg.signature) ||
+ error("authentication failed")
+ else
+ verify_signature(twin, msg)
+ end
+
+ @debug "[$(msg.cid)] is authenticated"
+ return nothing
+end
+
+#=
+ attestation(router, twin, msg, authenticate=true, ispong=false)
+
+Authenticate the client.
+
+If authentication fails then close the websocket.
+=#
+function attestation(router, twin, msg, authenticate=true)
+ @debug "[$twin] binding cid: $(msg.cid), authenticate: $authenticate"
+ sts = STS_SUCCESS
+ reason = nothing
+ authtwin = nothing
+ try
+ if authenticate
+ login(router, twin, msg)
+ end
+ authtwin = setidentity(router, twin, msg, authenticate)
+ catch e
+ @error "[$(msg.cid)] attestation: $e"
+ sts = STS_GENERIC_ERROR
+ reason = isa(e, ErrorException) ? e.msg : string(e)
+ end
+
+ response = ResMsg(msg.id, sts, reason)
+ @mlog("[$twin] -> $response")
+ transport_send(twin, twin.sock, response)
+
+ if sts !== STS_SUCCESS
+ detach(twin)
+ end
+
+ return authtwin
+end
+
+function attestation(router::Embedded, twin, msg)
+ @debug "[$twin] authenticating cid: $(msg.cid)"
+ sts = STS_SUCCESS
+ reason = nothing
+ try
+ login(router, twin, msg)
+ @debug "[$(msg.cid)] is authenticated"
+ twin.hasname = true
+ twin.isauth = true
+ catch e
+ @error "[$(msg.cid)] attestation: $e"
+ sts = STS_GENERIC_ERROR
+ reason = isa(e, ErrorException) ? e.msg : string(e)
+ end
+
+ response = ResMsg(msg.id, sts, reason)
+ @mlog("[$twin] -> $response")
+ transport_send(twin, twin.sock, response)
+
+ if sts !== STS_SUCCESS
+ detach(twin)
+ end
+
+ return twin
+end
+
+function get_token(router, userid, id::UInt128)
+ vals = UInt8[(id>>24)&0xff, (id>>16)&0xff, (id>>8)&0xff, id&0xff]
+ token = bytes2hex(vals)
+ df = router.owners[(router.owners.pin.==token).&(router.owners.uid.==userid), :]
+ if isempty(df)
+ @info "user [$userid]: invalid token"
+ return nothing
+ else
+ @debug "user [$userid]: token is valid"
+ return token
+ end
+end
+
+#=
+ register(router, twin, msg)
+
+Register a client app.
+=#
+function register(router, twin, msg)
+ @debug "[$(twin.id)] registering pubkey of $(msg.cid), id: $(msg.id)"
+ sts = STS_SUCCESS
+ reason = nothing
+ token = get_token(router, msg.userid, msg.id)
+ if token === nothing
+ sts = STS_GENERIC_ERROR
+ reason = "wrong pin"
+ elseif twin.isauth
+ sts = STS_GENERIC_ERROR
+ reason = "already registered"
+ elseif isregistered(msg.cid)
+ sts = STS_NAME_ALREADY_TAKEN
+ reason = "name $(msg.cid) not available for registration"
+ else
+ try
+ save_pubkey(msg.cid, msg.pubkey)
+ if !(msg.cid in router.token_app.app)
+ push!(router.token_app, [msg.userid, msg.cid])
+ end
+ save_token_app(router.token_app)
+ catch e
+ @error "[$(msg.cid)] register: $e"
+ sts = STS_GENERIC_ERROR
+ end
+ end
+ response = ResMsg(msg.id, sts, reason)
+ @mlog("[$twin] -> $response")
+ transport_send(twin, twin.sock, response)
+
+end
+
+#=
+ unregister(twin, msg)
+
+Unregister a client app.
+=#
+function unregister(router, twin, msg)
+ @debug "[$twin] unregistering $(msg.cid), isauth: $(twin.isauth)"
+ sts = STS_SUCCESS
+ reason = nothing
+
+ if !twin.isauth
+ sts = STS_GENERIC_ERROR
+ reason = "invalid operation"
+ elseif twin.id != msg.cid
+ sts = STS_GENERIC_ERROR
+ reason = "invalid cid"
+ else
+ try
+ remove_pubkey(msg.cid)
+ deleteat!(router.token_app, router.token_app.app .== msg.cid)
+ save_token_app(router.token_app)
+ catch e
+ @error "[$(msg.cid)] unregister: $e"
+ sts = STS_GENERIC_ERROR
+ end
+ end
+ response = ResMsg(msg.id, sts, reason)
+ @mlog("[$twin] -> $response")
+ transport_send(twin, twin.sock, response)
+
+end
+
+# Response to an Rpc request
+function rpc_response(router, twin, msg)
+ if haskey(twin.sent, msg.id)
+ twinput = twin.sent[msg.id].request.twchannel
+ reqdata = twin.sent[msg.id].request.content
+ put!(router.process.inbox, Msg(TYPE_RESPONSE, msg, twinput, reqdata))
+
+ elapsed = time() - twin.sent[msg.id].sending_ts
+ if CONFIG.metering
+ @debug "[$(twin.id)][$(reqdata.topic)] exec elapsed time: $elapsed secs"
+ end
+
+ delete!(twin.sent, msg.id)
+ else
+ @error "[$twin] unexpected response: $msg"
+ end
+end
+
+function admin_msg(router, twin, msg)
+ admin_res = admin_command(router, twin, msg)
+ @debug "admin response: $admin_res"
+ transport_send(twin, twin.sock, admin_res, true)
+ if admin_res.status === STS_SHUTDOWN
+ save_configuration(router)
+ end
+
+ return nothing
+end
+
+function embedded_msg(router::Embedded, twin::Twin, msg::RembusMsg)
+ (found, resmsg) = embedded_eval(router, twin, msg)
+
+ if found
+ if isa(resmsg, ResMsg)
+ response = Msg(TYPE_RESPONSE, resmsg, twin)
+ respond(router, response)
+ end
+ else
+ @debug "[embedded] no provider for [$(msg.topic)]"
+ if isa(msg, RpcReqMsg)
+ response = Msg(TYPE_RESPONSE, ResMsg(msg, STS_METHOD_NOT_FOUND, nothing), twin)
+ respond(router, response)
+ end
+ end
+
+ return nothing
+end
+
+rpc_request(router::Embedded, twin, msg) = embedded_msg(router, twin, msg)
+
+pubsub_msg(router::Embedded, twin, msg) = embedded_msg(router, twin, msg)
+
+function rpc_request(router, twin, msg)
+ if !isauth(router, twin, msg.topic)
+ put!(
+ twin.process.inbox,
+ Msg(TYPE_RESPONSE, ResMsg(msg, STS_GENERIC_ERROR, "unauthorized"), twin)
+ )
+ elseif msg.target !== nothing
+ # it is a direct rpc
+ if haskey(router.id_twin, msg.target)
+ target_twin = router.id_twin[msg.target]
+ if offline(target_twin)
+ put!(
+ twin.process.inbox,
+ Msg(TYPE_RESPONSE, ResMsg(msg, STS_TARGET_DOWN, msg.target), twin)
+ )
+ else
+ put!(target_twin.process.inbox, Msg(TYPE_RPC, msg, twin))
+ end
+ else
+ # target twin is unavailable
+ put!(
+ twin.process.inbox,
+ Msg(TYPE_RESPONSE, ResMsg(msg, STS_TARGET_NOT_FOUND, msg.target), twin)
+ )
+ end
+ else
+ # msg is routable, get it to router
+ @debug "[$twin] to router: $(prettystr(msg))"
+ put!(router.process.inbox, Msg(TYPE_RPC, msg, twin, msg))
+ end
+
+ return nothing
+end
+
+function pubsub_msg(router, twin, msg)
+ if !isauth(router, twin, msg.topic)
+ @warn "[$twin] is not authorized to publish on $(msg.topic)"
+ else
+ # msg is routable, get it to router
+ @debug "[$twin] to router: $(prettystr(msg))"
+ put!(router.process.inbox, Msg(TYPE_PUB, msg, twin))
+ end
+
+ return nothing
+end
+
+function ack_msg(twin, msg)
+ if twin.qos === with_ack
+ msgid = msg.hash
+ if haskey(twin.acktimer, msgid)
+ close(twin.acktimer[msgid])
+ delete!(twin.acktimer, msgid)
+ end
+ end
+
+ return nothing
+end
+
+function receiver_exception(router, twin, e)
+ if isconnectionerror(twin.sock, e)
+ if close_is_ok(twin.sock, e)
+ @debug "[$twin] connection closed"
+ else
+ @error "[$twin] connection closed: $e"
+ @showerror e
+ end
+ elseif isa(e, InterruptException)
+ rethrow()
+ elseif isa(e, ArgumentError)
+ @error "[$twin] invalid message format: $e"
+ @showerror e
+ else
+ @error "[$twin] internal error: $e"
+ @showerror e
+ end
+end
+
+function end_receiver(twin)
+ if twin.hasname
+ detach(twin)
+ else
+ destroy_twin(twin, twin.router)
+ end
+end
+
+#=
+ twin_receiver(router, twin)
+
+Receive messages from the client socket.
+=#
+function twin_receiver(router, twin)
+ @debug "client [$(twin.id)] is connected"
+ try
+ ws = twin.sock
+ while isopen(ws)
+ payload = transport_read(ws)
+ if isempty(payload)
+ twin.sock = nothing
+ @debug "client [$(twin.id)]: connection close"
+ break
+ end
+ msg::RembusMsg = broker_parse(payload)
+ @mlog("[$(twin.id)] <- $(prettystr(msg))")
+
+ if isa(msg, IdentityMsg)
+ error("identity: already authenticated")
+ elseif isa(msg, Register)
+ error("register: already authenticated")
+ elseif isa(msg, Unregister)
+ unregister(router, twin, msg)
+ elseif isa(msg, Attestation)
+ error("attestation: already authenticated")
+ elseif isa(msg, ResMsg)
+ rpc_response(router, twin, msg)
+ elseif isa(msg, AdminReqMsg)
+ admin_msg(router, twin, msg)
+ elseif isa(msg, RpcReqMsg)
+ rpc_request(router, twin, msg)
+ elseif isa(msg, PubSubMsg)
+ pubsub_msg(router, twin, msg)
+ elseif isa(msg, AckMsg)
+ ack_msg(twin, msg)
+ end
+ end
+ catch e
+ receiver_exception(router, twin, e)
+ @showerror e
+ finally
+ end_receiver(twin)
+ end
+
+ return nothing
+end
+
+function isauthenticated(session)
+ return haskey(session, "isauthenticated") && session["isauthenticated"] == true
+end
+
+function challenge(router, twin, msg)
+ if isauthenticated(twin.session)
+ error("already authenticated")
+ elseif haskey(router.topic_function, "challenge")
+ challenge = router.topic_function["challenge"](twin)
+ else
+ challenge = rand(RandomDevice(), UInt8, 4)
+ end
+ twin.session["challenge"] = challenge
+ return ResMsg(msg.id, STS_CHALLENGE, challenge)
+end
+
+#=
+ anonymous_twin_receiver(router, twin)
+
+Receive messages from the client socket.
+=#
+function anonymous_twin_receiver(router, twin)
+ @debug "anonymous client [$(twin.id)] is connected"
+ try
+ ws = twin.sock
+ while isopen(ws)
+ payload = transport_read(ws)
+ if isempty(payload)
+ twin.sock = nothing
+ @debug "client [$(twin.id)]: connection close"
+ break
+ end
+ msg::RembusMsg = broker_parse(payload)
+ @mlog("[$(twin.id)] <- $(prettystr(msg))")
+
+ if isa(msg, IdentityMsg)
+ @debug "[$twin] auth identity: $(msg.cid)"
+ if isempty(msg.cid)
+ transport_send(twin, ws, ResMsg(msg.id, STS_GENERIC_ERROR, "empty cid"))
+ end
+ # close connection if a client with the same cid is already connected
+ named = named_twin(msg.cid, router)
+ if named !== nothing && !offline(named)
+ @warn "a component with id [$(msg.cid)] is already connected"
+ close(ws)
+ else
+ # check if cid is registered
+ rembus_login = isfile(joinpath(CONFIG.db, "apps", msg.cid))
+
+ if rembus_login
+ # authentication mode, send the challenge
+ response = challenge(router, twin, msg)
+ else
+ authtwin = setidentity(router, twin, msg)
+ transport_send(authtwin, ws, ResMsg(msg.id, STS_SUCCESS, nothing))
+ return authtwin
+ end
+ @mlog("[$(twin.id)] -> $response")
+ transport_send(twin, ws, response)
+ end
+ elseif isa(msg, Register)
+ register(router, twin, msg)
+ elseif isa(msg, Unregister)
+ unregister(router, twin, msg)
+ elseif isa(msg, Attestation)
+ return attestation(router, twin, msg)
+ elseif isa(msg, ResMsg)
+ rpc_response(router, twin, msg)
+ elseif isa(msg, AdminReqMsg)
+ admin_msg(router, twin, msg)
+ elseif isa(msg, RpcReqMsg)
+ rpc_request(router, twin, msg)
+ elseif isa(msg, PubSubMsg)
+ pubsub_msg(router, twin, msg)
+ elseif isa(msg, AckMsg)
+ ack_msg(twin, msg)
+ end
+ end
+ catch e
+ receiver_exception(router, twin, e)
+ finally
+ end_receiver(twin)
+ end
+
+ return nothing
+end
+
+function zeromq_receiver(router::Router)
+ while true
+ try
+ pkt::ZMQPacket = zmq_message(router)
+ id = pkt.identity
+
+ if haskey(router.address2twin, id)
+ twin = router.address2twin[id]
+ else
+ @debug "creating anonymous twin from identity $id ($(bytes2zid(id)))"
+ # create the twin
+ twin = create_twin(string(bytes2zid(id)), router)
+ @debug "[anonymous] client bound to twin id [$twin]"
+ router.address2twin[id] = twin
+ router.twin2address[twin.id] = id
+ twin.sock = router.zmqsocket
+ end
+
+ msg::RembusMsg = broker_parse(router, pkt)
+ @mlog("[ZMQ][$twin] <- $(prettystr(msg))")
+
+ if isa(msg, IdentityMsg)
+ @debug "[$twin] auth identity: $(msg.cid)"
+ # check if cid is registered
+ rembus_login = isfile(joinpath(CONFIG.db, "apps", msg.cid))
+ if rembus_login
+ # authentication mode, send the challenge
+ response = challenge(router, twin, msg)
+ else
+ identity_upgrade(router, twin, msg, id, authenticate=false)
+ continue
+ end
+ @mlog("[ZMQ][$twin] -> $response")
+ transport_send(twin, router.zmqsocket, response)
+ elseif isa(msg, PingMsg)
+ if (twin.id != msg.cid)
+ # broker restarted
+ # start the authentication flow if cid is registered
+ @debug "lost connection to broker: restarting $(msg.cid)"
+ rembus_login = isfile(joinpath(CONFIG.db, "apps", msg.cid))
+ if rembus_login
+ response = challenge(router, twin, msg)
+ transport_send(twin, router.zmqsocket, response)
+ else
+ identity_upgrade(router, twin, msg, id, authenticate=false)
+ end
+
+ else
+ if twin.sock !== nothing
+ pong(twin.sock, msg.id, id)
+
+ # check if there are cached messages
+ if twin.reactive
+ unpark_messages(CONFIG.broker_ctx, twin)
+ end
+ end
+ end
+ elseif isa(msg, Register)
+ register(router, twin, msg)
+ elseif isa(msg, Unregister)
+ unregister(router, twin, msg)
+ elseif isa(msg, Attestation)
+ identity_upgrade(router, twin, msg, id, authenticate=true)
+ elseif isa(msg, ResMsg)
+ rpc_response(router, twin, msg)
+ elseif isa(msg, AdminReqMsg)
+ admin_msg(router, twin, msg)
+ elseif isa(msg, RpcReqMsg)
+ rpc_request(router, twin, msg)
+ elseif isa(msg, PubSubMsg)
+ pubsub_msg(router, twin, msg)
+ elseif isa(msg, AckMsg)
+ ack_msg(twin, msg)
+ elseif isa(msg, Close)
+ offline!(twin)
+ elseif isa(msg, Remove)
+ destroy_twin(twin, router)
+ end
+ catch e
+ if isa(e, Visor.ProcessInterrupt) || isa(e, ZMQ.StateError)
+ rethrow()
+ end
+ @warn "[ZMQ] recv error: $e"
+ @showerror e
+ end
+ end
+end
+
+function identity_upgrade(router, twin, msg, id; authenticate=false)
+ newtwin = attestation(router, twin, msg, authenticate)
+ if newtwin !== nothing
+ router.address2twin[id] = newtwin
+ delete!(router.twin2address, twin.id)
+ router.twin2address[newtwin.id] = id
+ end
+
+ return nothing
+end
+
+function close_is_ok(ws::WebSockets.WebSocket, e)
+ HTTP.WebSockets.isok(e)
+end
+
+function close_is_ok(ws::TCPSocket, e)
+ isa(e, ConnectionClosed)
+end
+
+close_is_ok(::Nothing, e) = true
+
+#=
+ detach(twin)
+
+Disconnect the twin from the ws/tcp channel.
+=#
+function detach(twin)
+ if twin.sock !== nothing
+ try
+ if !isa(twin.sock, ZMQ.Socket)
+ close(twin.sock)
+ end
+ catch e
+ @debug "error closing websocket: $e"
+ end
+ twin.sock = nothing
+ end
+
+ return nothing
+end
+
+#=
+ twin_task(self, twin)
+
+Twin task that read messages from router and send to client.
+
+It enqueues the input messages if the component is offline.
+=#
+function twin_task(self, twin)
+ twin.process = self
+ try
+ @debug "starting twin [$(twin.id)]"
+ for msg in self.inbox
+ if isshutdown(msg)
+ break
+ end
+ signal!(twin, msg)
+ end
+ catch e
+ @error "twin_task: $e" exception = (e, catch_backtrace())
+ end
+ @debug "[$twin] task done"
+end
+
+msghash(pkt) = UInt128(hash(pkt.topic)) + UInt128(hash(pkt.data)) << 64
+
+acklock = ReentrantLock()
+
+#=
+ handle_ack_timeout(tim, twin, msg, msgid)
+
+Persist a PubSub message in case the acknowledge message is not received.
+=#
+function handle_ack_timeout(tim, twin, msg, msgid)
+ lock(acklock) do
+ if haskey(twin.acktimer, msgid)
+ try
+ twin.router.park(CONFIG.broker_ctx, twin, msg)
+ catch e
+ @error "[$twin] ack_timeout: $e"
+ end
+ end
+ delete!(twin.acktimer, msgid)
+ end
+end
+
+function notreactive(twin, msg)
+ twin.reactive === false && isa(msg.content, PubSubMsg)
+end
+
+#=
+ signal!(twin, msg::Msg)
+
+Send `msg` to client or enqueue it if it is offline.
+
+Register the message into Twin.sent table.
+=#
+function signal!(twin, msg)
+ @debug "[$twin] message>>: $msg, offline:$(offline(twin)), type:$(msg.ptype)"
+ if (offline(twin) || notreactive(twin, msg)) && msg.ptype === TYPE_PUB
+ twin.router.park(CONFIG.broker_ctx, twin, msg.content)
+ else
+ # current message
+ if isa(msg.content, RpcReqMsg)
+ # add to sent table
+ twin.sent[msg.content.id] = SentData(time(), msg)
+ end
+
+ pkt = msg.content
+ @mlog "[$twin] -> $pkt"
+ try
+ transport_send(twin, twin.sock, pkt)
+ catch e
+ @debug "[$twin] going offline: $e"
+ detach(twin)
+ if msg.ptype === TYPE_PUB
+ twin.router.park(CONFIG.broker_ctx, twin, msg.content)
+ end
+ end
+ end
+
+ return nothing
+end
+
+### # Return true if Twin is interested to the message.
+### function interested(twin, message::Dict)::Bool
+### true
+### end
+
+#=
+ callback_or(fn::Function, router::AbstractRouter, callback::Symbol)
+
+Invoke `callback` function if it is injected via the plugin module otherwise invoke `fn`.
+=#
+function callback_or(fn::Function, router::AbstractRouter, callback::Symbol)
+ if CONFIG.broker_plugin !== nothing && isdefined(CONFIG.broker_plugin, callback)
+ cb = getfield(CONFIG.broker_plugin, callback)
+ cb(CONFIG.broker_ctx, router)
+ else
+ fn()
+ end
+end
+
+#=
+ callback_and(fn, cb::Symbol, router::AbstractRouter, twin::Twin, msg::RembusMsg)
+
+Get `cb` function and invoke it if is injected via the plugin module and then invoke `fn`.
+
+If callback throws an error then `fn` is not called.
+
+# Arguments
+
+- `fn::Function`: the function to invoke anyway if `cb` does not throw.
+- `cb::Symbol`: the name of the method defined in the external plugin
+- `router::AbstractRouter`: the instance of the broker router
+- `twin::Twin`: the target twin
+- `msg::RembusMsg`: the message to handle
+=#
+function callback_and(
+ fn::Function, cb::Symbol, router::AbstractRouter, twin::Twin, msg::RembusMsg
+)
+ try
+ if CONFIG.broker_plugin !== nothing && isdefined(CONFIG.broker_plugin, cb)
+ cb = getfield(CONFIG.broker_plugin, cb)
+ cb(CONFIG.broker_ctx, router, twin, msg)
+ end
+ fn()
+ catch e
+ @error "$cb callback error: $e"
+ end
+end
+
+"""
+ set_broker_plugin(extension::Module)
+
+Inject the module that implements the functions related to twin lifecycle.
+"""
+function set_broker_plugin(extension::Module)
+ CONFIG.broker_plugin = extension
+end
+
+"""
+ set_broker_context(ctx)
+
+Set the object to be passed ad first argument to functions related to twin lifecycle.
+
+Actually the functions that use `ctx` are:
+
+- `twin_initialize`
+- `twin_finalize`
+- `park`
+- `unpark`
+"""
+function set_broker_context(ctx)
+ CONFIG.broker_ctx = ctx
+end
+
+function command_line()
+ s = ArgParseSettings()
+ @add_arg_table! s begin
+ "--reset", "-r"
+ help = "factory reset, clean up broker configuration"
+ action = :store_true
+ "--secure", "-s"
+ help = "accept wss and tls connections on BROKER_WS_PORT and BROKER_TCP_PORT"
+ action = :store_true
+ "--debug", "-d"
+ help = "enable debug logs"
+ action = :store_true
+ end
+ return parse_args(s)
+end
+
+function caronte_reset()
+ foreach(rm, readdir(Rembus.twindir(), join=true))
+ foreach(rm, filter(isfile, readdir(Rembus.CONFIG.db, join=true)))
+end
+
+"""
+ caronte(; wait=true, exit_when_done=true)
+
+Start the broker.
+
+Return immediately when `wait` is false, otherwise blocks until shut down.
+
+Return instead of exiting if `exit_when_done` is false.
+"""
+function caronte(; wait=true, exit_when_done=true, args=Dict())
+ if isempty(args)
+ args = command_line()
+ end
+
+ if haskey(args, "debug") && args["debug"] === true
+ ENV["REMBUS_DEBUG"] = "1"
+ end
+
+ if haskey(args, "reset") && args["reset"] === true
+ Rembus.caronte_reset()
+ end
+
+ issecure = get(args, "secure", false)
+
+ setup(CONFIG)
+ router = Router()
+
+ tasks = [
+ supervisor("twins", terminateif=:shutdown),
+ process(broker, args=(router,)),
+ process(serve_tcp, args=(router, issecure), restart=:transient),
+ process(serve_ws, args=(router, issecure), restart=:transient, stop_waiting_after=2.0),
+ process(serve_zeromq, args=(router,), restart=:transient, debounce_time=2)
+ ]
+ sv = supervise(
+ [supervisor("caronte", tasks, strategy=:one_for_all, intensity=0)],
+ wait=wait
+ )
+ if exit_when_done
+ exit(0)
+ end
+
+ return sv
+end
+
+function caronted()::Cint
+ caronte()
+ return 0
+end
+
+
+function serve(embedded::Embedded; wait=true, exit_when_done=true, secure=false)
+ tasks = [
+ supervisor("twins", terminateif=:shutdown),
+ process(
+ serve_ws,
+ args=(embedded, secure),
+ restart=:transient,
+ stop_waiting_after=2.0
+ ),
+ ]
+ sv = supervise(
+ [supervisor("caronte", tasks, strategy=:one_for_one)],
+ intensity=5,
+ wait=wait
+ )
+ if exit_when_done
+ exit(0)
+ end
+
+ return sv
+end
+
+function router_configuration(router)
+ cfg = Dict("twins" => Dict())
+ for (tid, twin) in router.id_twin
+ cfg["twins"][tid] = Dict("retroactives" => twin.retroactive)
+ end
+
+ return cfg
+end
+
+prettystr(msg::RembusMsg) = "RembusMsg: $msg"
+
+function prettystr(msg::PubSubMsg)
+ if isa(msg.data, Vector{UInt8})
+ len = length(msg.data)
+ cnt = len > 10 ? msg.data[1:10] : msg.data
+ return "binary[$len] $cnt ..."
+ else
+ return msg.topic
+ end
+end
+
+function client_receiver(router::Router, sock)
+ cid = string(uuid4())
+ twin = create_twin(cid, router)
+ @debug "[anonymous] client bound to twin id [$cid]"
+
+ # start the trusted client task
+ twin.sock = sock
+
+ # ws/tcp socket receiver task
+ authtwin = anonymous_twin_receiver(router, twin)
+
+ # upgrade to named or authenticated twin if it returns true
+ if (authtwin !== nothing)
+ twin_receiver(router, authtwin)
+ end
+
+ return nothing
+end
+
+function client_receiver(router::Embedded, ws)
+ cid = string(uuid4())
+ twin = create_twin(cid, router)
+ @debug "[embedded] client bound to twin id [$cid]"
+ # start the trusted client task
+ twin.sock = ws
+ @debug "embedded receiver is connected"
+ while isopen(ws)
+ try
+ payload = transport_read(ws)
+ if isempty(payload)
+ @debug "[$twin]: connection close"
+ break
+ end
+ msg::RembusMsg = broker_parse(payload)
+ @mlog("[$twin] <- $(prettystr(msg))")
+
+ if isa(msg, RpcReqMsg)
+ rpc_request(router, twin, msg)
+ elseif isa(msg, PubSubMsg)
+ pubsub_msg(router, twin, msg)
+ elseif isa(msg, IdentityMsg)
+ dare = challenge(router, twin, msg)
+ response = Msg(TYPE_RESPONSE, dare, twin)
+ respond(router, response)
+ elseif isa(msg, Attestation)
+ attestation(router, twin, msg)
+ elseif isa(msg, AckMsg)
+ ack_msg(twin, msg)
+ end
+ catch e
+ @debug "[embedded] error: $e"
+ if isa(e, EOFError)
+ break
+ end
+ end
+ end
+ end_receiver(twin)
+
+ return nothing
+end
+
+function secure_config()
+ trust_store = keystore_dir()
+
+ entropy = MbedTLS.Entropy()
+ rng = MbedTLS.CtrDrbg()
+ MbedTLS.seed!(rng, entropy)
+
+ sslconfig = MbedTLS.SSLConfig(
+ joinpath(trust_store, "caronte.crt"),
+ joinpath(trust_store, "caronte.key")
+ )
+ MbedTLS.rng!(sslconfig, rng)
+
+ function show_debug(level, filename, number, msg)
+ @show level, filename, number, msg
+ end
+
+ MbedTLS.dbg!(sslconfig, show_debug)
+ return sslconfig
+end
+
+function listener(proc, router, sslconfig)
+ IP = "0.0.0.0"
+ caronte_port = parse(UInt16, get(ENV, "BROKER_WS_PORT", "8000"))
+
+ server = Sockets.listen(Sockets.InetAddr(parse(IPAddr, IP), caronte_port))
+ router.ws_server = server
+ proto = (sslconfig === nothing) ? "ws" : "wss"
+ @info "rembus up and running at port $proto:$caronte_port"
+
+ setphase(proc, :listen)
+
+ HTTP.WebSockets.listen(IP, caronte_port, server=server, sslconfig=sslconfig) do ws
+ client_receiver(router, ws)
+ end
+end
+
+function serve_ws(td, router, issecure=false)
+ sslconfig = nothing
+
+ if issecure
+ sslconfig = secure_config()
+ end
+
+ try
+ listener(td, router, sslconfig)
+ catch e
+ if !isa(e, Visor.ProcessInterrupt)
+ @error "ws server: $e"
+ @showerror e
+ end
+ rethrow()
+ finally
+ @debug "serve_ws closed"
+ setphase(td, :terminate)
+ isdefined(router, :ws_server) && close(router.ws_server)
+ end
+end
+
+function serve_zeromq(pd, router)
+ @debug "starting serve_zeromq [$(pd.id)]"
+ port = parse(UInt16, get(ENV, "BROKER_ZMQ_PORT", "8002"))
+ context = ZMQ.Context()
+ router.zmqsocket = Socket(context, ROUTER)
+ ZMQ.bind(router.zmqsocket, "tcp://*:$port")
+
+ try
+ @info "rembus up and running at zmq ports $port"
+ setphase(pd, :listen)
+ zeromq_receiver(router)
+ catch e
+ # consider ProcessInterrupt a normal termination because
+ # zeromq_receiver is not polling for supervisor shutdown message
+ if !isa(e, Visor.ProcessInterrupt)
+ @error "[serve_zeromq] error: $e"
+ rethrow()
+ end
+ finally
+ setphase(pd, :terminate)
+ ZMQ.close(router.zmqsocket)
+ ZMQ.close(context)
+ end
+end
+
+function serve_tcp(pd, router, issecure=false)
+ proto = "tcp"
+ server = nothing
+ try
+ IP = "0.0.0.0"
+ caronte_port = parse(UInt16, get(ENV, "BROKER_TCP_PORT", "8001"))
+ setphase(pd, :listen)
+
+ if issecure
+ proto = "tls"
+ sslconfig = secure_config()
+
+ server = Sockets.listen(Sockets.InetAddr(parse(IPAddr, IP), caronte_port))
+ router.server = server
+ @info "rembus up and running at port $proto:$caronte_port"
+ while true
+ try
+ sock = accept(server)
+ ctx = MbedTLS.SSLContext()
+ MbedTLS.setup!(ctx, sslconfig)
+ MbedTLS.associate!(ctx, sock)
+ MbedTLS.handshake(ctx)
+ @async client_receiver(router, ctx)
+ catch e
+ if !isa(e, Visor.ProcessInterrupt)
+ @error "tcp server: $e"
+ @showerror e
+ end
+ rethrow()
+ end
+ end
+ else
+ server = Sockets.listen(Sockets.InetAddr(parse(IPAddr, IP), caronte_port))
+ router.server = server
+ @info "rembus up and running at port $proto:$caronte_port"
+ while true
+ try
+ sock = accept(server)
+ @async client_receiver(router, sock)
+ catch e
+ if !isa(e, Visor.ProcessInterrupt)
+ @error "tcp server: $e"
+ @showerror e
+ end
+ rethrow()
+ end
+ end
+ end
+ finally
+ setphase(pd, :terminate)
+ server !== nothing && close(server)
+ end
+end
+
+function islistening(wait=5)
+ procs = ["caronte.serve_ws", "caronte.serve_tcp", "caronte.serve_zeromq"]
+ tcount = 0
+ while tcount < wait
+ if all(p -> getphase(p) === :listen, from.(procs))
+ return true
+ end
+ tcount += 0.2
+ end
+
+ return false
+end
+
+isconnected(twin) = twin.sock !== nothing && isopen(twin.sock)
+
+function first_up(router, topic, implementors)
+ @debug "[$topic] first_up balancer"
+ for target in implementors
+ @debug "[$topic] candidate target: $target"
+ if isconnected(target)
+ return target
+ end
+ end
+
+ return nothing
+end
+
+function round_robin(router, topic, implementors)
+ target = nothing
+ if !isempty(implementors)
+ len = length(implementors)
+ @debug "[$topic]: $len implementors"
+ current_index = get(router.last_invoked, topic, 0)
+ if current_index === 0
+ for impl in implementors
+ current_index += 1
+ !isconnected(impl) && continue
+ target = impl
+ router.last_invoked[topic] = current_index
+ break
+ end
+ else
+ cursor = 1
+ current_index = current_index >= len ? 1 : current_index + 1
+ for impl in implementors
+ if current_index > cursor
+ if target === nothing && isconnected(impl)
+ target = impl
+ router.last_invoked[topic] = cursor
+ end
+ cursor += 1
+ else
+ if isconnected(impl)
+ target = impl
+ router.last_invoked[topic] = cursor
+ break
+ else
+ cursor += 1
+ end
+ end
+ end
+ end
+ end
+
+ return target
+end
+
+Base.isless(t1::Twin, t2::Twin) = length(t1.sent) < length(t2.sent)
+
+function less_busy(router, topic, implementors)
+ up_and_running = [impl for impl in implementors if isconnected(impl)]
+ if isempty(up_and_running)
+ return nothing
+ else
+ return min(up_and_running...)
+ end
+end
+
+#=
+ select_twin(router, topic, implementors)
+
+Return an online implementor ready to execute the method associated to the topic.
+=#
+function select_twin(router, topic, implementors)
+ target = nothing
+ @info "[$topic] balancer: $(CONFIG.balancer)"
+ if CONFIG.balancer === "first_up"
+ target = first_up(router, topic, implementors)
+ elseif CONFIG.balancer === "round_robin"
+ target = round_robin(router, topic, implementors)
+ elseif CONFIG.balancer === "less_busy"
+ target = less_busy(router, topic, implementors)
+ end
+
+ return target
+end
+
+#=
+ broadcast!(router, msg)
+
+Broadcast the `topic` data `msg` to all interested clients.
+=#
+function broadcast!(router, msg)
+ authtwins = Set{Twin}()
+ if msg.ptype == TYPE_PUB
+ # the interest * (subscribe to all topics) is enabled
+ # only for pubsub messages and not for rpc methods.
+ topic = msg.content.topic
+ bmsg = msg.content
+
+ twins = get(router.topic_interests, "*", Set{Twin}())
+ # broadcast to twins that are admins and to twins that are authorized to
+ # subscribe to topic
+ for twin in twins
+ if twin.id in router.admins
+ push!(authtwins, twin)
+ elseif haskey(router.topic_auth, topic)
+ if haskey(router.topic_auth[topic], twin.id)
+ # it is a private topic, check if twin is authorized
+ push!(authtwins, twin)
+ end
+ else
+ # it is a public topic, all twins may be broadcasted
+ push!(authtwins, twin)
+ end
+ end
+ elseif isdefined(msg, :reqdata)
+ topic = msg.reqdata.topic
+ bmsg = PubSubMsg(topic, msg.reqdata.data)
+ else
+ @debug "no broadcast for [$msg]: request data not available or embedded method"
+ return nothing
+ end
+
+ union!(authtwins, get(router.topic_interests, topic, Set{Twin}()))
+ newmsg = Msg(TYPE_PUB, bmsg, msg.twchannel)
+
+ for tw in filter(t -> t.process.inbox != msg.twchannel.process.inbox, authtwins)
+ @debug "broadcasting $topic to $(tw.id): [$newmsg]"
+ put!(tw.process.inbox, newmsg)
+ end
+
+ return nothing
+end
+
+#=
+ isauth(router::Router, twin::Twin, topic::AbstractString)
+
+Return true if topic is public or client is authorized to bind to topic.
+=#
+function isauth(router::Router, twin::Twin, topic::AbstractString)
+ # check if topic is private
+ if haskey(router.topic_auth, topic)
+ # check if twin is authorized to bind to topic
+ if !haskey(router.topic_auth[topic], twin.id)
+ return false
+ end
+ end
+
+ # topic is public or twin is authorized
+ return true
+end
+
+#=
+ isadmin(router, twin, cmd)
+Check if twin client has admin privilege.
+=#
+function isadmin(router, twin, cmd)
+ sts = twin.id in router.admins
+ if !sts
+ @error "$cmd failed: $(twin.id) not authorized"
+ end
+
+ return sts
+end
+
+function respond(router::Router, msg::Msg)
+ put!(msg.twchannel.process.inbox, msg)
+
+ if msg.content.status != STS_SUCCESS
+ return
+ end
+
+ # broadcast! to all interested twins
+ broadcast!(router, msg)
+
+ return nothing
+end
+
+respond(router::Embedded, msg::Msg) = put!(msg.twchannel.process.inbox, msg)
+
+function uptime(router)
+ utime = time() - router.start_ts
+ return "up for $(Int(floor(utime))) seconds"
+end
+
+function getargs(data)
+ if isa(data, ZMQ.Message)
+ args = decode(Vector{UInt8}(data))
+ else
+ args = data
+ end
+ if args isa Vector
+ return args
+ elseif args === nothing
+ return []
+ else
+ return [args]
+ end
+end
+
+function embedded_eval(router, twin::Twin, msg::RembusMsg)
+ result = nothing
+ sts = STS_GENERIC_ERROR
+ if haskey(router.topic_function, msg.topic)
+ try
+ result = router.topic_function[msg.topic](twin, getargs(msg.data)...)
+ sts = STS_SUCCESS
+ catch e
+ @debug "[$(msg.topic)] embedded error (method too young?): $e"
+ result = "$e"
+ sts = STS_METHOD_EXCEPTION
+
+ if isa(e, MethodError)
+ try
+ result = Base.invokelatest(
+ router.topic_function[msg.topic],
+ twin,
+ getargs(msg.data)...
+ )
+ sts = STS_SUCCESS
+ catch e
+ result = "$e"
+ end
+ end
+ end
+
+ return (true, isa(msg, RpcReqMsg) ? ResMsg(msg, sts, result) : nothing)
+ else
+ return (false, nothing)
+ end
+end
+
+function caronte_embedded_method(router, twin::Twin, msg::RembusMsg)
+ (found, resmsg) = embedded_eval(router, twin, msg)
+
+ if found
+ if isa(resmsg, ResMsg)
+ response = Msg(TYPE_RESPONSE, resmsg, twin)
+ respond(router, response)
+ end
+ end
+
+ return found
+end
+
+#=
+ broker(self, router)
+
+Rembus broker main task.
+=#
+function broker(self, router)
+ @debug "[broker] starting"
+ try
+ router.process = self
+ init(router)
+
+ # example for registering a broker implementor
+ router.topic_function["uptime"] = (session) -> uptime(router)
+ router.topic_function["version"] = (session) -> Rembus.VERSION
+
+ for msg in self.inbox
+ # process control messages
+ !isshutdown(msg) || break
+
+ @debug "[broker] recv $(typeof(msg)): $msg"
+ if isa(msg, Msg)
+ if msg.ptype == TYPE_PUB
+ if !caronte_embedded_method(router, msg.twchannel, msg.content)
+ # publish to interested twins
+ broadcast!(router, msg)
+ end
+ elseif msg.ptype == TYPE_RPC
+ topic = msg.content.topic
+ if caronte_embedded_method(router, msg.twchannel, msg.content)
+ else
+ # find an implementor
+ if haskey(router.topic_impls, topic)
+ # request a method exec
+ @debug "[broker] finding an target implementor for $topic"
+ implementors = router.topic_impls[topic]
+ target = select_twin(router, topic, implementors)
+ @debug "[broker] target implementor: [$target]"
+ if target === nothing
+ msg.content = ResMsg(msg.content, STS_METHOD_UNAVAILABLE)
+ put!(msg.twchannel.process.inbox, msg)
+ elseif target.process.inbox === msg.twchannel.process.inbox
+ @warn "[$(target.id)]: loopback detected"
+ msg.content = ResMsg(msg.content, STS_METHOD_LOOPBACK)
+ put!(msg.twchannel.process.inbox, msg)
+ elseif target !== nothing
+ @debug "implementor target: $(target.id)"
+ put!(target.process.inbox, msg)
+ end
+
+ else
+ # method implementor not found
+ msg.content = ResMsg(msg.content, STS_METHOD_NOT_FOUND)
+ put!(msg.twchannel.process.inbox, msg)
+ end
+ end
+ elseif msg.ptype == TYPE_RESPONSE
+ # it is a result from an implementor
+ # reply toward the client that has made the request
+ respond(router, msg)
+ end
+ # @debug "waiting next msg ..."
+ else
+ @warn "unknow $(typeof(msg)) message $msg "
+ end
+ end
+ catch e
+ @error "[broker] error: $e"
+ @showerror e
+ rethrow()
+ finally
+ save_configuration(router)
+ end
+ @debug "[broker] done"
+end
+
+function watch_extfile(router, extfile, time_window=4)
+ changed = false
+ while true
+ try
+ event = watch_folder(extfile, time_window)
+ if event.second.timedout
+ if changed
+ @debug "rembus $extfile change detected: reloading"
+ app_topics(router)
+ changed = false
+ else
+ # @info "watching ..."
+ end
+ else
+ changed = true
+ end
+ catch e
+ @error "watch extfile: $e"
+ end
+ end
+end
+
+function caronte_context(fn, ctx)
+ if ctx === nothing
+ return data -> fn(data...)
+ else
+ return data -> fn(ctx, data...)
+ end
+end
+
+function eval_optional(router, modname, fname)
+ sts = eval(Meta.parse("isdefined(Main.$modname, :$fname)"))
+ if sts
+ return Base.invokelatest(Base.eval(Main, Meta.parse("$modname.$fname")), router)
+ else
+ return nothing
+ end
+end
+
+#=
+ boot(router)
+
+Setup the router.
+=#
+function boot(router)
+ appdir = joinpath(CONFIG.db, "apps")
+ if !isdir(appdir)
+ mkpath(appdir)
+ end
+
+ load_configuration(router)
+ return nothing
+end
+
+function init(router)
+ if isinteractive()
+ Logging.disable_logging(Logging.Info)
+ else
+ logging(debug=[])
+ end
+
+ boot(router)
+ @debug "broker datadir: $(CONFIG.db)"
+
+ if CONFIG.broker_plugin !== nothing
+ if isdefined(CONFIG.broker_plugin, :park) &&
+ isdefined(CONFIG.broker_plugin, :unpark)
+ router.park = getfield(CONFIG.broker_plugin, :park)
+ router.unpark = getfield(CONFIG.broker_plugin, :unpark)
+ end
+
+ if isdefined(CONFIG.broker_plugin, :twin_initialize)
+ router.twin_initialize = getfield(CONFIG.broker_plugin, :twin_initialize)
+ end
+ if isdefined(CONFIG.broker_plugin, :twin_finalize)
+ router.twin_finalize = getfield(CONFIG.broker_plugin, :twin_finalize)
+ end
+
+ topics = names(CONFIG.broker_plugin)
+ exposed = filter(sym -> sym !== Symbol(CONFIG.broker_plugin), topics)
+ @debug "plugin exposed methods: $exposed"
+ for topic in exposed
+ router.topic_function[String(topic)] = getfield(CONFIG.broker_plugin, topic)
+ end
+ end
+
+ return nothing
+end
diff --git a/src/cbor.jl b/src/cbor.jl
new file mode 100644
index 0000000..71aeb82
--- /dev/null
+++ b/src/cbor.jl
@@ -0,0 +1,60 @@
+#=
+Copyright (c) 2016 Saurav Sachidanand
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+num2hex(n) = string(n, base=16, pad=sizeof(n) * 2)
+num2hex(n::AbstractFloat) = num2hex(reinterpret(Unsigned, n))
+hex2num(s) = reinterpret(Float64, parse(UInt64, s, base=16))
+hex(n) = string(n, base=16)
+
+struct Tag{T}
+ id::Int
+ data::T
+end
+Base.:(==)(a::Tag, b::Tag) = a.id == b.id && a.data == b.data
+Tag(id::Integer, data) = Tag(Int(id), data)
+
+### include("constants.jl")
+### include("encode.jl")
+### include("decode.jl")
+
+export encode
+export decode, decode_with_iana
+export Undefined
+
+function decode(data::Array{UInt8,1})
+ return decode_internal(IOBuffer(data))
+end
+
+function decode(data::IO)
+ return decode(read(data))
+end
+
+function encode(data)
+ io = IOBuffer()
+ encode(io, data)
+ return take!(io)
+end
diff --git a/src/configuration.jl b/src/configuration.jl
new file mode 100755
index 0000000..f03e709
--- /dev/null
+++ b/src/configuration.jl
@@ -0,0 +1,108 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+const DEFAULT_APP_NAME = "rembus"
+
+macro showerror(e)
+ quote
+ if CONFIG.stacktrace
+ showerror(stdout, $(esc(e)), catch_backtrace())
+ end
+ end
+end
+
+function component_id(cfg)
+ url = get(cfg, "cid", get(ENV, "REMBUS_CID", DEFAULT_APP_NAME))
+ uri = URI(url)
+ proto = uri.scheme
+ if proto == ""
+ name = uri.path
+ else
+ name = uri.host
+ end
+ name
+end
+
+mutable struct Settings
+ zmq_ping_interval::Float32
+ ws_ping_interval::Float32
+ balancer::String
+ db::String
+ log::String
+ debug_modules::Vector{Module}
+ overwrite_connection::Bool
+ stacktrace # log stacktrace on error
+ metering # log in and out messages
+ rawdump # log in and out raw bytes
+ cid::String # rembus default component cid
+ connection_retry_period::Float32 # seconds between reconnection attempts
+ broker_plugin::Union{Nothing,Module}
+ broker_ctx::Any
+ Settings() = begin
+ zmq_ping_interval = 0
+ ws_ping_interval = 0
+ balancer = "first_up"
+ db = joinpath(get(ENV, "HOME", "."), "caronte")
+ log = "stdout"
+ overwrite_connection = true
+ stacktrace = false
+ metering = false
+ rawdump = false
+ cid = DEFAULT_APP_NAME
+ connection_retry_period = 2.0
+ debug_modules = []
+
+ new(zmq_ping_interval, ws_ping_interval, balancer, db, log, debug_modules,
+ overwrite_connection, stacktrace, metering, rawdump, cid,
+ connection_retry_period, nothing, nothing)
+ end
+end
+
+set_balancer(policy::AbstractString) = set_balancer(CONFIG, policy)
+
+function set_balancer(setting, policy)
+ #balancer = get(cfg, "balancer", get(ENV, "BROKER_BALANCER", "first_up"))
+ if !(policy in ["first_up", "less_busy", "round_robin"])
+ error("wrong balancer, must be one of first_up, less_busy, round_robin")
+ end
+ setting.balancer = policy
+
+ return nothing
+end
+
+function setup(setting)
+ home = get(ENV, "HOME", ".")
+ cfg = get(Base.get_preferences(), "Rembus", Dict())
+
+ setting.zmq_ping_interval = get(cfg, "zmq_ping_interval",
+ parse(Float32, get(ENV, "REMBUS_ZMQ_PING_INTERVAL", "10")))
+
+ setting.ws_ping_interval = get(cfg, "ws_ping_interval",
+ parse(Float32, get(ENV, "REMBUS_WS_PING_INTERVAL", "120")))
+
+ setting.db = get(cfg, "db", get(ENV, "REMBUS_DB", joinpath(home, "caronte")))
+ setting.log = get(cfg, "log", get(ENV, "BROKER_LOG", "stdout"))
+ setting.overwrite_connection = get(cfg, "overwrite_connection", true)
+ setting.stacktrace = get(cfg, "stacktrace", false)
+ setting.metering = get(cfg, "metering", false)
+ setting.rawdump = get(cfg, "rawdump", false)
+ setting.cid = component_id(cfg)
+ setting.connection_retry_period = get(cfg, "connection_retry_period", 2.0)
+
+ if get(ENV, "REMBUS_DEBUG", "0") == "1"
+ setting.debug_modules = [Rembus, Visor]
+ else
+ setting.debug_modules = []
+ end
+
+ balancer = get(cfg, "balancer", get(ENV, "BROKER_BALANCER", "first_up"))
+ set_balancer(setting, balancer)
+
+ return nothing
+end
+
+context() = CONFIG.broker_ctx
diff --git a/src/constants.jl b/src/constants.jl
new file mode 100644
index 0000000..c083e15
--- /dev/null
+++ b/src/constants.jl
@@ -0,0 +1,144 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+const VERSION = "0.1.0"
+
+const Rembus.CONFIG = Rembus.Settings()
+
+const DATAFRAME_TAG = 80
+
+const APP_CONFIG_DIR = ".config"
+
+const PING_INTERVAL = 10
+const PONG_STRING = "*_pong_*"
+
+const BROKER_CONFIG = "__config__"
+const CID = "cid"
+const COMMAND = "cmd"
+const DATA = :data
+const RETROACTIVE = "retroactive"
+const STATUS = "status"
+
+const REACTIVE_CMD = "reactive"
+const ENABLE_ACK_CMD = "enable_ack"
+const DISABLE_ACK_CMD = "disable_ack"
+const RESET_ROUTER_CMD = "reset_router"
+const SHUTDOWN_CMD = "shutdown"
+const ENABLE_DEBUG_CMD = "enable_debug"
+const DISABLE_DEBUG_CMD = "disable_debug"
+const UPTIME_CMD = "uptime"
+const ADD_INTEREST_CMD = "add_interest"
+const REMOVE_INTEREST_CMD = "remove_interest"
+const ADD_IMPL_CMD = "add_impl"
+const REMOVE_IMPL_CMD = "remove_impl"
+const AUTHORIZE_CMD = "authorize"
+const UNAUTHORIZE_CMD = "unauthorize"
+const PRIVATE_TOPIC_CMD = "private_topic"
+const PUBLIC_TOPIC_CMD = "public_topic"
+const TOPICS_CONFIG_CMD = "topics_config"
+const BROKER_CONFIG_CMD = "broker_config"
+const LOAD_CONFIG_CMD = "load_config"
+const SAVE_CONFIG_CMD = "save_config"
+
+const TYPE_IDENTITY::UInt8 = 0
+const TYPE_PUB::UInt8 = 1
+const TYPE_RPC::UInt8 = 2
+const TYPE_ADMIN::UInt8 = 3
+const TYPE_RESPONSE::UInt8 = 4
+const TYPE_ACK::UInt8 = 5
+const TYPE_UNREGISTER::UInt8 = 9
+const TYPE_REGISTER::UInt8 = 10
+const TYPE_ATTESTATION::UInt8 = 11
+
+# ZeroMQ periodic ping
+const TYPE_PING::UInt8 = 12
+const TYPE_PONG::UInt8 = 13
+
+const TYPE_REMOVE::UInt8 = 14
+const TYPE_CLOSE::UInt8 = 15
+
+const REACTIVE_DISABLE::Bool = false
+const REACTIVE_ENABLE::Bool = true
+
+const ACK_WAIT_TIME = 6
+
+const STS_SUCCESS::UInt8 = 0
+const STS_GENERIC_ERROR::UInt8 = 10
+const STS_CHALLENGE::UInt8 = 11
+const STS_IDENTIFICATION_ERROR::UInt8 = 20
+const STS_METHOD_EXCEPTION::UInt8 = 40
+const STS_METHOD_NOT_FOUND::UInt8 = 42
+const STS_METHOD_UNAVAILABLE::UInt8 = 43
+const STS_METHOD_LOOPBACK::UInt8 = 44
+const STS_TARGET_NOT_FOUND::UInt8 = 45
+const STS_TARGET_DOWN::UInt8 = 46
+const STS_UNKNOWN_ADMIN_CMD::UInt8 = 47
+const STS_NAME_ALREADY_TAKEN::UInt8 = 60
+const STS_SHUTDOWN::UInt8 = 100
+
+# Rembus timeout
+const STS_TIMEOUT::UInt8 = 70
+
+const TYPE_0 = zero(UInt8)
+const TYPE_1 = one(UInt8) << 5
+const TYPE_2 = UInt8(2) << 5
+const TYPE_3 = UInt8(3) << 5
+const TYPE_4 = UInt8(4) << 5
+const TYPE_5 = UInt8(5) << 5
+const TYPE_6 = UInt8(6) << 5
+const TYPE_7 = UInt8(7) << 5
+
+const BITS_PER_BYTE = UInt8(8)
+const HEX_BASE = Int(16)
+const LOWEST_ORDER_BYTE_MASK = 0xFF
+
+const TYPE_BITS_MASK = UInt8(0b1110_0000)
+const ADDNTL_INFO_MASK = UInt8(0b0001_1111)
+
+const ADDNTL_INFO_UINT8 = UInt8(24)
+const ADDNTL_INFO_UINT16 = UInt8(25)
+const ADDNTL_INFO_UINT32 = UInt8(26)
+const ADDNTL_INFO_UINT64 = UInt8(27)
+
+const SINGLE_BYTE_SIMPLE_PLUS_ONE = UInt8(24)
+const SIMPLE_FALSE = UInt8(20)
+const SIMPLE_TRUE = UInt8(21)
+const SIMPLE_NULL = UInt8(22)
+const SIMPLE_UNDEF = UInt8(23)
+
+const ADDNTL_INFO_FLOAT16 = UInt8(25)
+const ADDNTL_INFO_FLOAT32 = UInt8(26)
+const ADDNTL_INFO_FLOAT64 = UInt8(27)
+
+const ADDNTL_INFO_INDEF = UInt8(31)
+const BREAK_INDEF = TYPE_7 | UInt8(31)
+
+const SINGLE_BYTE_UINT_PLUS_ONE = 24
+const UINT8_MAX_PLUS_ONE = 0x100
+const UINT16_MAX_PLUS_ONE = 0x10000
+const UINT32_MAX_PLUS_ONE = 0x100000000
+const UINT64_MAX_PLUS_ONE = 0x10000000000000000
+
+const INT8_MAX_POSITIVE = 0x7f
+const INT16_MAX_POSITIVE = 0x7fff
+const INT32_MAX_POSITIVE = 0x7fffffff
+const INT64_MAX_POSITIVE = 0x7fffffffffffffff
+
+const SIZE_OF_FLOAT64 = sizeof(Float64)
+const SIZE_OF_FLOAT32 = sizeof(Float32)
+const SIZE_OF_FLOAT16 = sizeof(Float16)
+
+const POS_BIG_INT_TAG = UInt8(2)
+const NEG_BIG_INT_TAG = UInt8(3)
+
+const CBOR_FALSE_BYTE = UInt8(TYPE_7 | 20)
+const CBOR_TRUE_BYTE = UInt8(TYPE_7 | 21)
+const CBOR_NULL_BYTE = UInt8(TYPE_7 | 22)
+const CBOR_UNDEF_BYTE = UInt8(TYPE_7 | 23)
+
+
+const CUSTOM_LANGUAGE_TYPE = 27
diff --git a/src/decode.jl b/src/decode.jl
new file mode 100644
index 0000000..0874559
--- /dev/null
+++ b/src/decode.jl
@@ -0,0 +1,228 @@
+#=
+Copyright (c) 2016 Saurav Sachidanand
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+function type_from_fields(::Type{T}, fields) where {T}
+ ccall(:jl_new_structv, Any, (Any, Ptr{Cvoid}, UInt32), T, fields, length(fields))
+end
+
+function peekbyte(io::IO)
+ mark(io)
+ byte = read(io, UInt8)
+ reset(io)
+ return byte
+end
+
+struct UndefIter{IO,F}
+ f::F
+ io::IO
+end
+Base.IteratorSize(::Type{<:UndefIter}) = Base.SizeUnknown()
+
+function Base.iterate(x::UndefIter, state=nothing)
+ peekbyte(x.io) == BREAK_INDEF && return nothing
+ return x.f(x.io), nothing
+end
+
+function decode_ntimes(f, io::IO)
+ first_byte = peekbyte(io)
+ if (first_byte & ADDNTL_INFO_MASK) == ADDNTL_INFO_INDEF
+ skip(io, 1) # skip first byte
+ return UndefIter(f, io)
+ else
+ return (f(io) for i in 1:decode_unsigned(io))
+ end
+end
+
+function decode_unsigned(io::IO)
+ addntl_info = read(io, UInt8) & ADDNTL_INFO_MASK
+ if addntl_info < SINGLE_BYTE_UINT_PLUS_ONE
+ return addntl_info
+ elseif addntl_info == ADDNTL_INFO_UINT8
+ return bswap(read(io, UInt8))
+ elseif addntl_info == ADDNTL_INFO_UINT16
+ return bswap(read(io, UInt16))
+ elseif addntl_info == ADDNTL_INFO_UINT32
+ return bswap(read(io, UInt32))
+ elseif addntl_info == ADDNTL_INFO_UINT64
+ return bswap(read(io, UInt64))
+ else
+ error("Unknown Int type")
+ end
+end
+
+
+
+decode_internal(io::IO, ::Val{TYPE_0}) = decode_unsigned(io)
+
+function decode_internal(io::IO, ::Val{TYPE_1})
+ addntl_info = read(io, UInt8) & ADDNTL_INFO_MASK
+ if addntl_info < SINGLE_BYTE_UINT_PLUS_ONE
+ return -signed(addntl_info + Int8(1))
+ elseif addntl_info == ADDNTL_INFO_UINT8
+ data = bswap(read(io, UInt8))
+ if data > INT8_MAX_POSITIVE
+ return -Int16(data + one(data))
+ else
+ return -signed(data + one(data))
+ end
+ elseif addntl_info == ADDNTL_INFO_UINT16
+ data = bswap(read(io, UInt16))
+ if data > INT16_MAX_POSITIVE
+ return -Int32(data + one(data))
+ else
+ return -signed(data + one(data))
+ end
+ elseif addntl_info == ADDNTL_INFO_UINT32
+ data = bswap(read(io, UInt32))
+ if data > INT32_MAX_POSITIVE
+ return -Int64(data + one(data))
+ else
+ return -signed(data + one(data))
+ end
+ elseif addntl_info == ADDNTL_INFO_UINT64
+ data = bswap(read(io, UInt64))
+ if data > INT64_MAX_POSITIVE
+ return -Int128(data + one(data))
+ else
+ return -signed(data + one(data))
+ end
+ else
+ error("Unknown Int type")
+ end
+end
+
+# Decode Byte Array
+function decode_internal(io::IO, ::Val{TYPE_2})
+ if (peekbyte(io) & ADDNTL_INFO_MASK) == ADDNTL_INFO_INDEF
+ skip(io, 1)
+ result = IOBuffer()
+ while peekbyte(io) !== BREAK_INDEF
+ write(result, decode_internal(io))
+ end
+ return take!(result)
+ else
+ return read(io, decode_unsigned(io))
+ end
+end
+
+# Decode String
+function decode_internal(io::IO, ::Val{TYPE_3})
+ if (peekbyte(io) & ADDNTL_INFO_MASK) == ADDNTL_INFO_INDEF
+ skip(io, 1)
+ result = IOBuffer()
+ while peekbyte(io) !== BREAK_INDEF
+ write(result, decode_internal(io))
+ end
+ return String(take!(result))
+ else
+ return String(read(io, decode_unsigned(io)))
+ end
+end
+
+# Decode Vector of arbitrary elements
+function decode_internal(io::IO, ::Val{TYPE_4})
+ return map(identity, decode_ntimes(decode_internal, io))
+end
+
+# Decode Dict
+function decode_internal(io::IO, ::Val{TYPE_5})
+ return Dict(decode_ntimes(io) do io
+ decode_internal(io) => decode_internal(io)
+ end)
+end
+
+# Decode Tagged type
+function decode_internal(io::IO, ::Val{TYPE_6})
+ tag = decode_unsigned(io)
+ data = decode_internal(io)
+ if tag in (POS_BIG_INT_TAG, NEG_BIG_INT_TAG)
+ big_int = parse(
+ BigInt, bytes2hex(data), base=HEX_BASE
+ )
+ if tag == NEG_BIG_INT_TAG
+ big_int = -(big_int + 1)
+ end
+ return big_int
+ end
+
+ if tag == CUSTOM_LANGUAGE_TYPE # Type Tag
+ name = data[1]
+ object_serialized = data[2]
+ if startswith(name, "Julia/") # Julia Type
+ return deserialize(IOBuffer(object_serialized))
+ end
+ end
+ # TODO implement other common tags!
+ return Tag(tag, data)
+end
+
+function decode_internal(io::IO, ::Val{TYPE_7})
+ first_byte = read(io, UInt8)
+ addntl_info = first_byte & ADDNTL_INFO_MASK
+ if addntl_info < SINGLE_BYTE_SIMPLE_PLUS_ONE + 1
+ simple_val = if addntl_info < SINGLE_BYTE_SIMPLE_PLUS_ONE
+ addntl_info
+ else
+ read(io, UInt8)
+ end
+ if simple_val == SIMPLE_FALSE
+ return false
+ elseif simple_val == SIMPLE_TRUE
+ return true
+ elseif simple_val == SIMPLE_NULL
+ return nothing
+ elseif simple_val == SIMPLE_UNDEF
+ return Undefined()
+ else
+ return simple_val
+ end
+ else
+ if addntl_info == ADDNTL_INFO_FLOAT64
+ return reinterpret(Float64, ntoh(read(io, UInt64)))
+ elseif addntl_info == ADDNTL_INFO_FLOAT32
+ return reinterpret(Float32, ntoh(read(io, UInt32)))
+ elseif addntl_info == ADDNTL_INFO_FLOAT16
+ return reinterpret(Float16, ntoh(read(io, UInt16)))
+ else
+ error("Unsupported Float Type!")
+ end
+ end
+end
+
+function decode_internal(io::IO)
+ # leave startbyte in io
+ first_byte = peekbyte(io)
+ typ = first_byte & TYPE_BITS_MASK
+ typ == TYPE_0 && return decode_internal(io, Val(TYPE_0))
+ typ == TYPE_1 && return decode_internal(io, Val(TYPE_1))
+ typ == TYPE_2 && return decode_internal(io, Val(TYPE_2))
+ typ == TYPE_3 && return decode_internal(io, Val(TYPE_3))
+ typ == TYPE_4 && return decode_internal(io, Val(TYPE_4))
+ typ == TYPE_5 && return decode_internal(io, Val(TYPE_5))
+ typ == TYPE_6 && return decode_internal(io, Val(TYPE_6))
+ typ == TYPE_7 && return decode_internal(io, Val(TYPE_7))
+end
diff --git a/src/encode.jl b/src/encode.jl
new file mode 100644
index 0000000..28945ed
--- /dev/null
+++ b/src/encode.jl
@@ -0,0 +1,248 @@
+#=
+Copyright (c) 2016 Saurav Sachidanand
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+cbor_tag(::UInt8) = ADDNTL_INFO_UINT8
+cbor_tag(::UInt16) = ADDNTL_INFO_UINT16
+cbor_tag(::UInt32) = ADDNTL_INFO_UINT32
+cbor_tag(::UInt64) = ADDNTL_INFO_UINT64
+
+cbor_tag(::Float64) = ADDNTL_INFO_FLOAT64
+cbor_tag(::Float32) = ADDNTL_INFO_FLOAT32
+cbor_tag(::Float16) = ADDNTL_INFO_FLOAT16
+
+function encode_unsigned_with_type(
+ io::IO, typ::UInt8, num::Unsigned
+)
+ if isa(num, UInt8) && num < SINGLE_BYTE_UINT_PLUS_ONE
+ write(io, typ | UInt8(num)) # smaller 24 gets directly stored in type tag
+ else
+ write(io, typ | cbor_tag(num))
+ write(io, bswap(num))
+ end
+end
+
+function encode_length(io::IO, typ::UInt8, x)
+ encode_smallest_int(io, typ, x isa String ? sizeof(x) : length(x))
+end
+
+# Array lengths and other integers (e.g. tags) in CBOR are encoded with smallest integer
+# type, which we do with this method!
+function encode_smallest_int(io::IO, typ::UInt8, num::Integer)
+ @assert num >= 0 "array lengths must be greater 0. Found: $num"
+ if num < SINGLE_BYTE_UINT_PLUS_ONE
+ write(io, typ | UInt8(num)) # smaller 24 gets directly stored in type tag
+ elseif num < UINT8_MAX_PLUS_ONE
+ encode_unsigned_with_type(io, typ, UInt8(num))
+ elseif num < UINT16_MAX_PLUS_ONE
+ encode_unsigned_with_type(io, typ, UInt16(num))
+ elseif num < UINT32_MAX_PLUS_ONE
+ encode_unsigned_with_type(io, typ, UInt32(num))
+ elseif num < UINT64_MAX_PLUS_ONE
+ encode_unsigned_with_type(io, typ, UInt64(num))
+ else
+ error("128-bits ints can't be encoded in the CBOR format.")
+ end
+end
+
+
+function encode(io::IO, float::Union{Float64,Float32,Float16})
+ write(io, TYPE_7 | cbor_tag(float))
+ # hton only works for 32 + 64, while bswap works for all
+ write(io, Base.bswap_int(float))
+end
+
+
+# ------- straightforward encoding for a few Julia types
+function encode(io::IO, bool::Bool)
+ write(io, CBOR_FALSE_BYTE + bool)
+end
+
+function encode(io::IO, num::Unsigned)
+ encode_unsigned_with_type(io, TYPE_0, num)
+end
+
+function encode(io::IO, num::T) where {T<:Signed}
+ if num >= 0
+ ##encode_smallest_int(io, TYPE_0, unsigned(num))
+ encode_unsigned_with_type(io, TYPE_0, unsigned(num))
+ else
+ ##encode_smallest_int(io, TYPE_1, unsigned(-num - one(T)))
+ encode_unsigned_with_type(io, TYPE_1, unsigned(-num - one(T)))
+ end
+end
+
+function encode(io::IO, byte_string::Vector{UInt8})
+ encode_length(io, TYPE_2, byte_string)
+ write(io, byte_string)
+end
+
+function encode(io::IO, string::String)
+ encode_length(io, TYPE_3, string)
+ write(io, string)
+end
+
+function encode(io::IO, list::Vector)
+ encode_length(io, TYPE_4, list)
+ for e in list
+ encode(io, e)
+ end
+end
+
+function encode(io::IO, map::Dict)
+ encode_length(io, TYPE_5, map)
+ for (key, value) in map
+ encode(io, key)
+ encode(io, value)
+ end
+end
+
+function encode(io::IO, big_int::BigInt)
+ tag = if big_int < 0
+ big_int = -big_int - 1
+ NEG_BIG_INT_TAG
+ else
+ POS_BIG_INT_TAG
+ end
+ hex_str = hex(big_int)
+ if isodd(length(hex_str))
+ hex_str = "0" * hex_str
+ end
+ encode(io, Tag(tag, hex2bytes(hex_str)))
+end
+
+function encode(io::IO, tag::Tag)
+ tag.id >= 0 || error("Tag needs to be a positive integer")
+ encode_with_tag(io, Unsigned(tag.id), tag.data)
+end
+
+
+# Wrapper for collections with undefined length, that will then get encoded
+# in the cbor format. Underlying is just
+struct UndefLength{ET,A}
+ iter::A
+end
+
+function UndefLength(iter::T) where {T}
+ UndefLength{eltype(iter),T}(iter)
+end
+
+function UndefLength{T}(iter::A) where {T,A}
+ UndefLength{T,A}(iter)
+end
+
+Base.iterate(x::UndefLength) = iterate(x.iter)
+Base.iterate(x::UndefLength, state) = iterate(x.iter, state)
+
+# ------- encoding for indefinite length collections
+function encode(
+ io::IO, iter::UndefLength{ET}
+) where {ET}
+ if ET in (Vector{UInt8}, String)
+ typ = ET == Vector{UInt8} ? TYPE_2 : TYPE_3
+ write(io, typ | ADDNTL_INFO_INDEF)
+ foreach(x -> encode(io, x), iter)
+ else
+ typ = ET <: Pair ? TYPE_5 : TYPE_4 # Dict or any array
+ write(io, typ | ADDNTL_INFO_INDEF)
+ for e in iter
+ if e isa Pair
+ encode(io, e[1])
+ encode(io, e[2])
+ else
+ encode(io, e)
+ end
+ end
+ end
+ write(io, BREAK_INDEF)
+end
+
+# ------- encoding with tags
+
+function encode_with_tag(io::IO, tag::Unsigned, data)
+ encode_smallest_int(io, TYPE_6, tag)
+ encode(io, data)
+end
+
+
+struct Undefined
+end
+
+function encode(io::IO, null::Nothing)
+ write(io, CBOR_NULL_BYTE)
+end
+
+function encode(io::IO, undef::Undefined)
+ write(io, CBOR_UNDEF_BYTE)
+end
+
+struct SmallInteger{T}
+ num::T
+end
+Base.convert(::Type{<:SmallInteger}, x) = SmallInteger(x)
+Base.convert(::Type{<:SmallInteger}, x::SmallInteger) = x
+Base.:(==)(a::SmallInteger, b::SmallInteger) = a.num == b.num
+Base.:(==)(a::Number, b::SmallInteger) = a == b.num
+Base.:(==)(a::SmallInteger, b::Number) = a.num == b
+
+function encode(io::IO, small::SmallInteger{<:Unsigned})
+ encode_smallest_int(io, TYPE_0, small.num)
+end
+function encode(io::IO, small::SmallInteger{<:Signed})
+ if small.num >= 0
+ encode_smallest_int(io, TYPE_0, unsigned(small.num))
+ else
+ encode_smallest_int(io, TYPE_1, unsigned(-small.num - one(small.num)))
+ end
+end
+
+
+function fields2array(typ::T) where {T}
+ fnames = fieldnames(T)
+ getfield.((typ,), [fnames...])
+end
+
+# Any Julia type get's serialized as Tag 27
+# Tag 27
+# Data Item array [typename, constructargs...]
+# Semantics Serialised language-independent object with type name and constructor arguments
+# Reference http://cbor.schmorp.de/generic-object
+# Contact Marc A. Lehmann
+function encode(io::IO, struct_type::T) where {T}
+ # TODO don't use Serialization for the whole struct!
+ # It almost works to deserialize from just the fields and type,
+ # but that ends up being problematic for
+ # anonymous functions (the type changes between serialization & deserialization)
+ tio = IOBuffer()
+ serialize(tio, struct_type)
+ encode(
+ io,
+ Tag(
+ CUSTOM_LANGUAGE_TYPE,
+ [string("Julia/", T), take!(tio), fields2array(struct_type)]
+ )
+ )
+end
diff --git a/src/logger.jl b/src/logger.jl
new file mode 100755
index 0000000..5efd3ff
--- /dev/null
+++ b/src/logger.jl
@@ -0,0 +1,72 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+struct RembusLogger <: AbstractLogger
+ io::IO
+ groups::Vector{Symbol}
+end
+
+function repl_log()
+ ConsoleLogger(stdout, Info, meta_formatter=repl_metafmt) |> global_logger
+end
+
+function repl_metafmt(level::LogLevel, _module, group, id, file, line)
+ @nospecialize
+ color = Logging.default_logcolor(level)
+ prefix = string(level == Warn ? "Warning" : string(level), ':')
+ suffix::String = ""
+ return color, prefix, suffix
+end
+
+function logging(; debug=[])
+ groups = []
+ for item in debug
+ isa(item, Module) && push!(CONFIG.debug_modules, item)
+ isa(item, Symbol) && push!(groups, item)
+ end
+
+ if CONFIG.log === "stdout"
+ RembusLogger(stdout, groups) |> global_logger
+ else
+ RembusLogger(open(CONFIG.log, "a+"), groups) |> global_logger
+ end
+
+ return nothing
+end
+
+function Logging.min_enabled_level(logger::RembusLogger)
+ Logging.Debug
+end
+
+function Logging.shouldlog(
+ logger::RembusLogger,
+ level,
+ _module,
+ group,
+ id
+)
+ level >= Logging.Info || group in logger.groups || _module in CONFIG.debug_modules
+end
+
+function Logging.handle_message(
+ logger::RembusLogger,
+ level,
+ message,
+ _module,
+ group,
+ id,
+ file,
+ line;
+ kwargs...
+)
+ println(logger.io, "[$(now())][$_module][$(Threads.threadid())][$level] $message")
+ if haskey(kwargs, :exception)
+ e, bt = kwargs[:exception]
+ showerror(logger.io, e, bt)
+ end
+ flush(logger.io)
+end
diff --git a/src/precompile.jl b/src/precompile.jl
new file mode 100644
index 0000000..b11e956
--- /dev/null
+++ b/src/precompile.jl
@@ -0,0 +1,205 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+# When compiling responses are delayed a litte bit
+ENV["REMBUS_TIMEOUT"] = "60"
+
+df = DataFrame(name=["trento", "belluno"], score=[10, 50])
+bdf = DataFrame(x=1:10, y=1:10)
+
+mutable struct TestBag
+ noarg_message_received::Bool
+ msg_received::Int
+end
+
+function noarg()
+end
+
+function mytopic(bag::TestBag, data::Number)
+ @debug "mytopic recv: $data"
+end
+
+function mytopic(bag::TestBag, data)
+ @debug "df:\n$(view(data, 1:2, :))"
+end
+
+function publish_macroapi(publisher, sub1; waittime=1)
+ testbag = TestBag(false, 0)
+
+ @component sub1
+ @shared sub1 testbag
+ @subscribe sub1 mytopic from_now
+ @reactive sub1
+
+ sleep(waittime / 3)
+
+ @component publisher
+ @publish publisher mytopic(2)
+ @publish publisher mytopic(df)
+
+ @publish publisher noarg()
+
+ sleep(waittime / 2)
+
+ for cli in [publisher, sub1]
+ @terminate cli
+ end
+end
+
+function publish_api(pub, sub1; waittime=1)
+ mytopic_topic = "mytopic_topic"
+ noarg_topic = "noarg"
+
+ testbag = TestBag(false, 0)
+
+ publisher = connect(pub)
+
+ sub1 = connect(sub1)
+ shared(sub1, testbag)
+
+ subscribe(sub1, mytopic_topic, mytopic)
+ reactive(sub1)
+
+ publish(publisher, mytopic_topic, 2)
+ publish(publisher, mytopic_topic, df)
+ publish(publisher, mytopic_topic, bdf)
+
+ publish(publisher, noarg_topic)
+
+ sleep(waittime)
+ unsubscribe(sub1, mytopic_topic)
+
+ sleep(waittime / 4)
+
+ for cli in [publisher, sub1]
+ close(cli)
+ end
+end
+
+function add_one(add_one_arg)
+ add_one_arg + 1
+end
+
+function do_method_error(data)
+ reason = "this is an error"
+ error(reason)
+end
+
+function do_args_error(data)
+ args_error_msg = "expected a float32"
+ if !isa(data, Float32)
+ throw(ErrorException(args_error_msg))
+ end
+end
+
+function rpc_method(rpc_method_arg)
+end
+
+function request_api(request_url, exposer_url)
+ rpc_topic = "rpc_method"
+ request_arg = 1
+
+ client = connect(request_url)
+
+ try
+ rpc(client, rpc_topic, exceptionerror=true)
+ catch e
+ end
+
+ implementor = connect(exposer_url)
+ expose(implementor, rpc_topic, add_one)
+
+ res = rpc(client, rpc_topic, request_arg)
+
+ expose(implementor, rpc_topic, do_method_error)
+ try
+ res = rpc(client, rpc_topic)
+ catch e
+ end
+
+ for cli in [implementor, client]
+ close(cli)
+ end
+end
+
+mutable struct Holder
+ valuemap::Vector
+ Holder() = new([ # type of value received => value sent
+ UInt8 => Int(1),
+ UInt8 => 255,
+ UInt16 => 256,
+ UInt32 => 65536,
+ Int8 => -1,
+ Int16 => -129,
+ Int32 => -40000,
+ Float32 => Float32(1.2),
+ Float64 => Float64(1.2),
+ DataFrame => DataFrame(:a => [1, 2]),
+ String => "foo",
+ Dict => Dict(1 => 2),
+ Vector => [1, 2]
+ ])
+end
+
+function type_consumer(bag, n)
+ @debug "[type_consumer]: recv $n ($(typeof(n)))"
+end
+
+function types()
+ bag = Holder()
+ REQUESTOR = "tcp://:8001/type_publisher"
+ TYPE_LISTENER = "type_listener"
+
+ @component REQUESTOR
+ @component TYPE_LISTENER
+
+ sleep(0.1)
+
+ @subscribe TYPE_LISTENER type_consumer from_now
+ @shared TYPE_LISTENER bag
+ @reactive TYPE_LISTENER
+
+ sleep(0.1)
+ for (typ, msg) in bag.valuemap
+ sleep(0.05)
+ @publish REQUESTOR type_consumer(msg)
+ end
+ sleep(2)
+ @terminate REQUESTOR
+ @terminate TYPE_LISTENER
+end
+
+@rpc version()
+@rpc uptime()
+@terminate
+
+types()
+
+##try
+## for exposer_url in ["zmq://:8002/test_request_impl", "test_request_impl"]
+## for request_url in ["zmq://:8002/test_request", "test_request"]
+## @debug "rpc endpoints: $exposer_url, $subscriber_url, $request_url"
+## request_api(request_url, exposer_url)
+## end
+## end
+##catch e
+## @error e
+## showerror(stdout, e, catch_backtrace())
+##end
+##
+try
+ waittime = 0.5
+ for sub1 in ["tcp://:8001/sub_tcp", "zmq://:8002/sub_zmq"]
+ for publisher in ["tcp://:8001/pub", "zmq://:8002/pub"]
+ publish_macroapi(publisher, sub1, waittime=waittime)
+ publish_api(publisher, sub1, waittime=waittime)
+ end
+ end
+catch e
+ @error "precompile: $e"
+ showerror(stdout, e, catch_backtrace())
+end
diff --git a/src/protocol.jl b/src/protocol.jl
new file mode 100644
index 0000000..b23d1de
--- /dev/null
+++ b/src/protocol.jl
@@ -0,0 +1,134 @@
+abstract type RembusMsg end
+
+struct PingMsg <: RembusMsg
+ id::UInt128
+ cid::String
+ PingMsg(id::UInt128, cid::String) = new(id, cid)
+ PingMsg(cid::String) = new(id(), cid)
+end
+
+struct IdentityMsg <: RembusMsg
+ id::UInt128
+ cid::String
+ IdentityMsg(userid::AbstractString) = new(id(), userid)
+ IdentityMsg(msgid::UInt128, userid::AbstractString) = new(msgid, userid)
+end
+
+mutable struct PubSubMsg{T} <: RembusMsg
+ topic::String
+ data::T
+ flags::UInt8
+ hash::UInt128
+
+ function PubSubMsg(topic, data=nothing, flags=0x0, hash=0)
+ return new{typeof(data)}(topic, data, flags, hash)
+ end
+end
+
+Base.show(io::IO, message::PubSubMsg) = show(io, message.topic)
+
+struct RpcReqMsg{T} <: RembusMsg
+ id::UInt128
+ topic::String
+ data::T
+ target::Union{Nothing,String}
+ flags::UInt8
+
+ function RpcReqMsg(topic::AbstractString, data, target=nothing, flags=0x0)
+ return new{typeof(data)}(id(), topic, data, target, flags)
+ end
+
+ function RpcReqMsg(
+ msgid::UInt128,
+ topic::AbstractString,
+ data,
+ target=nothing,
+ flags=0x0
+ )
+ return new{typeof(data)}(msgid, topic, data, target, flags)
+ end
+end
+
+Base.show(io::IO, message::RpcReqMsg) = show(io, message.topic)
+
+struct AdminReqMsg{T} <: RembusMsg
+ id::UInt128
+ topic::String
+ data::T
+ flags::UInt8
+
+ function AdminReqMsg(msgid::UInt128, topic, data, flags=0x0)
+ return new{typeof(data)}(msgid, topic, data, flags)
+ end
+
+ function AdminReqMsg(topic::String, data, flags=0x0)
+ return new{typeof(data)}(id(), topic, data, flags)
+ end
+end
+
+struct AckMsg <: RembusMsg
+ hash::UInt128
+end
+
+struct ResMsg{T} <: RembusMsg
+ id::UInt128
+ status::UInt8
+ data::T
+ flags::UInt8
+
+ ResMsg(id, status, data, flags=0x0) = new{typeof(data)}(id, status, data, flags)
+
+ function ResMsg(req::RpcReqMsg, status::UInt8, data=nothing, flags=0x0)
+ return new{typeof(data)}(req.id, status, data, flags)
+ end
+
+ function ResMsg(exec::RpcReqMsg, flags=0x0)
+ return new{typeof(exec.data)}(exec.id, exec.status, exec.data, flags)
+ end
+end
+
+struct Register <: RembusMsg
+ id::UInt128
+ cid::String # client name
+ userid::String
+ pubkey::Vector{UInt8}
+ Register(
+ msgid::UInt128,
+ cid::AbstractString,
+ userid::AbstractString,
+ pubkey::Vector{UInt8}) = new(msgid, cid, userid, pubkey)
+end
+
+struct Unregister <: RembusMsg
+ id::UInt128
+ cid::String # client name
+ Unregister(cid::String) = new(id(), cid)
+ Unregister(msgid::UInt128, cid::String) = new(msgid, cid)
+end
+
+struct Attestation <: RembusMsg
+ id::UInt128
+ cid::String # client name
+ signature::Vector{UInt8}
+
+ Attestation(cid::AbstractString, signature::Vector{UInt8}) = new(id(), cid, signature)
+
+ function Attestation(msgid::UInt128, cid::AbstractString, signature::Vector{UInt8})
+ return new(msgid, cid, signature)
+ end
+end
+
+# Message for notifying the broker that the component is closing the socket.
+# Apply to ZeroMQ protocol.
+struct Close <: RembusMsg
+end
+
+# Remove the twin of a component from the broker.
+# Apply to ZeroMQ protocol.
+struct Remove <: RembusMsg
+end
+
+id() = uuid4().value # unique message identifier
+
+isresponse(msg::RembusMsg) = false
+isresponse(msg::ResMsg) = true
diff --git a/src/register.jl b/src/register.jl
new file mode 100644
index 0000000..f8a0c88
--- /dev/null
+++ b/src/register.jl
@@ -0,0 +1,93 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+"""
+ create_private_key(cid::AbstractString)
+
+Create a private key for `cid` component and return its public key.
+"""
+function create_private_key(cid::AbstractString)
+ file = "$(pkfile(cid)).tmp"
+ cmd = `openssl genrsa -out $file 2048 `
+ Base.run(cmd)
+
+ Vector{UInt8}(read(`openssl rsa -in $file -outform der -pubout`, String))
+end
+
+"""
+ register(cid::AbstractString)
+
+Register the client identified by `cid`.
+"""
+function register(cid::AbstractString, userid::AbstractString, pin::AbstractString)
+ cmp = Component(cid)
+
+ kfile = pkfile(cmp.id)
+
+ if isfile(kfile)
+ error("$cid component: found private key $kfile")
+ end
+
+ @debug "connecting register"
+ process = NullProcess(cmp.id)
+ rb = RBConnection(cmp)
+ _connect(rb, process)
+
+ try
+ @debug "registering $cid"
+ pubkey = create_private_key(cmp.id)
+
+ value = parse(Int, pin, base=16)
+ msgid = id() & 0xffffffffffffffffffffffff00000000 + value
+
+ msg = Register(msgid, cmp.id, userid, pubkey)
+ response = wait_response(rb, msg, request_timeout())
+ if (response.status != STS_SUCCESS)
+ rembuserror(code=response.status, reason=response.data)
+ end
+ # finally save the key
+ mv("$(kfile).tmp", kfile)
+
+ catch e
+ rm("$(kfile).tmp", force=true)
+ rethrow()
+ finally
+ close(rb)
+ end
+end
+
+
+"""
+ unregister(cid::AbstractString)
+
+Unregister the client identified by `cid`.
+
+The secret pin is not needed because only an already connected and authtenticated
+component may execute the unregister command.
+"""
+function unregister(rb, cid::AbstractString)
+ @debug "unregistering $cid"
+
+ msg = Unregister(cid)
+ response = wait_response(rb, msg, request_timeout())
+ if (response.status != STS_SUCCESS)
+ rembuserror(code=response.status)
+ end
+
+ # remove the private key
+ rm(pkfile(cid), force=true)
+end
+
+function transport_send(router, ws, msg::Register)
+ pkt = [TYPE_REGISTER, id2bytes(msg.id), msg.cid, msg.userid, msg.pubkey]
+ transport_write(ws, pkt)
+end
+
+function transport_send(router, ws, msg::Unregister)
+ pkt = [TYPE_UNREGISTER, id2bytes(msg.id), msg.cid]
+ transport_write(ws, pkt)
+end
diff --git a/src/store.jl b/src/store.jl
new file mode 100755
index 0000000..016285e
--- /dev/null
+++ b/src/store.jl
@@ -0,0 +1,417 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+"""
+ load_owners()
+
+Return the owners dataframe
+"""
+function load_owners()
+ fn = joinpath(CONFIG.db, "owners.csv")
+ if isfile(fn)
+ DataFrame(CSV.File(fn, types=[String, String, String, Bool]))
+ else
+ @debug "owners.csv not found, only unauthenticated users allowed"
+ DataFrame(pin=String[], uid=String[], name=[], enabled=Bool[])
+ end
+end
+
+"""
+ save_owners(owners_df)
+
+Save the owners table.
+"""
+function save_owners(owners_df)
+ fn = joinpath(CONFIG.db, "owners.csv")
+ CSV.write(fn, owners_df)
+end
+
+"""
+ load_token_app()
+
+Return the token_app dataframe
+"""
+function load_token_app()
+ fn = joinpath(CONFIG.db, "token_app.csv")
+ if isfile(fn)
+ df = DataFrame(CSV.File(fn, types=Dict(1 => String, 2 => String)))
+ return df
+ else
+ @debug "token_app.csv not found"
+ DataFrame(uid=String[], app=String[])
+ end
+end
+
+"""
+ save_token_app(df)
+
+Save the owners table.
+"""
+function save_token_app(df)
+ fn = joinpath(CONFIG.db, "token_app.csv")
+ CSV.write(fn, df)
+end
+
+function twindir()
+ if !isdir(CONFIG.db)
+ mkdir(CONFIG.db)
+ end
+ twin_dir = joinpath(CONFIG.db, "twins")
+ if !isdir(twin_dir)
+ mkdir(twin_dir)
+ end
+ twin_dir
+end
+
+function save_table(router_tbl, filename)
+ if !isdir(CONFIG.db)
+ mkdir(CONFIG.db)
+ end
+ table = Dict()
+ for (topic, twins) in router_tbl
+ twin_ids = [tw.id for tw in twins if tw.hasname]
+ table[topic] = twin_ids
+ end
+ fn = joinpath(CONFIG.db, filename)
+ open(fn, "w") do io
+ write(io, JSON3.write(table))
+ end
+end
+
+function save_impl_table(router)
+ @debug "saving impls table"
+ save_table(router.topic_impls, "impls.json")
+end
+
+function save_topic_auth_table(router)
+ @debug "saving topic_auth table"
+ fn = joinpath(CONFIG.db, "topic_auth.json")
+
+ d = Dict()
+ for (topic, cids) in router.topic_auth
+ d[topic] = keys(cids)
+ end
+
+ open(fn, "w") do io
+ write(io, JSON3.write(d))
+ end
+end
+
+function save_admins(router)
+ @debug "saving admins"
+ fn = joinpath(CONFIG.db, "admins.json")
+ open(fn, "w") do io
+ write(io, JSON3.write(router.admins))
+ end
+end
+
+function save_pubkey(cid::AbstractString, pubkey)
+ fn = joinpath(CONFIG.db, "apps", cid)
+ open(fn, "w") do io
+ write(io, pubkey)
+ end
+end
+
+function remove_pubkey(cid::AbstractString)
+ fn = joinpath(CONFIG.db, "apps", cid)
+ if isfile(fn)
+ rm(fn)
+ end
+end
+
+function pubkey_file(cid::AbstractString)
+ fn = joinpath(CONFIG.db, "apps", cid)
+
+ if isfile(fn)
+ return fn
+ else
+ error("auth failed: unknown $cid")
+ end
+end
+
+isregistered(cid::AbstractString) = isfile(joinpath(CONFIG.db, "apps", cid))
+
+function load_impl_table(router)
+ try
+ @debug "loading impls table"
+ ## load_table(router, "impls_table", router.topic_impls, "impls.json")
+ fn = joinpath(CONFIG.db, "impls.json")
+ if isfile(fn)
+ content = read(fn, String)
+ table = JSON3.read(content, Dict)
+ for (topic, twin_ids) in table
+ twins = Set{Twin}()
+ for tid in twin_ids
+ twin = create_twin(tid, router)
+ push!(twins, twin)
+ end
+ if !isempty(twins)
+ router.topic_impls[topic] = twins
+ end
+ end
+ end
+ catch e
+ @error "[impls.json] load failed: $e"
+ end
+end
+
+function load_topic_auth_table(router)
+ @debug "loading topic_auth table"
+ fn = joinpath(CONFIG.db, "topic_auth.json")
+ if isfile(fn)
+ content = read(fn, String)
+ topics = Dict()
+ for (private_topic, cids) in JSON3.read(content, Dict)
+ topics[private_topic] = Dict(cids .=> true)
+ end
+ router.topic_auth = topics
+ end
+end
+
+function load_admins(router)
+ @debug "loading admins"
+ fn = joinpath(CONFIG.db, "admins.json")
+ if isfile(fn)
+ content = read(fn, String)
+ router.admins = JSON3.read(content, Set)
+ end
+end
+
+"""
+ load_twins(router)
+
+Instantiates twins that have one or more interests.
+"""
+function load_twins(router)
+ fn = joinpath(CONFIG.db, "twins.json")
+ if isfile(fn)
+ content = read(fn, String)
+ twin_topicsdict = JSON3.read(content, Dict)
+ else
+ twin_topicsdict = Dict()
+ end
+
+ twins = Dict()
+ for (cid, topicsdict) in twin_topicsdict
+ twin = create_twin(cid, router)
+ twin.hasname = true
+ twin.retroactive = topicsdict
+
+ for topic in keys(topicsdict)
+ if haskey(twins, topic)
+ push!(twins[topic], twin)
+ else
+ twins[topic] = Set([twin])
+ end
+ end
+ end
+
+ for (topic, twins) in twins
+ router.topic_interests[topic] = twins
+ end
+
+ twins_dir = joinpath(CONFIG.db, "twins")
+ if isdir(twins_dir)
+ # each twin has a file with status and setting data
+ files = readdir(twins_dir, join=true)
+ for twin_id in files
+ try
+ @debug "loading twin [$twin_id]"
+ if isfile(twin_id)
+ tid = basename(twin_id)
+
+ # delete the unknown twin
+ if !haskey(router.id_twin, tid)
+ @info "deleting [$twin_id]: unknown twin [$tid]"
+ rm(twin_id)
+ continue
+ end
+ end
+ catch e
+ @error "[$twin_id] load failed: $e"
+ end
+ end
+ end
+
+end
+
+"""
+ save_twins(router)
+
+Persist twins to storage.
+
+Save twins configuration only if twin has a name.
+
+Persist undelivered messages if they are queued in memory.
+"""
+function save_twins(router)
+ if !isdir(CONFIG.db)
+ mkdir(CONFIG.db)
+ end
+ twin_dir = joinpath(CONFIG.db, "twins")
+ if !isdir(twin_dir)
+ mkdir(twin_dir)
+ end
+
+ twin_cfg = Dict{String,Dict{String,Bool}}()
+ for (twin_id, twin) in router.id_twin
+ if twin.hasname
+ twin_finalize(CONFIG.broker_ctx, twin)
+ delete!(router.id_twin, twin_id)
+ twin_cfg[twin_id] = twin.retroactive
+ end
+ end
+ fn = joinpath(CONFIG.db, "twins.json")
+ open(fn, "w") do io
+ write(io, JSON3.write(twin_cfg))
+ end
+end
+
+function park(ctx::Any, twin::Twin, msg::RembusMsg)
+ if !twin.hasname
+ # do not persist messages addressed to anonymous components
+ return
+ end
+
+ try
+ if twin.mfile === nothing
+ tdir = twindir()
+ fn = joinpath(tdir, twin.id)
+ twin.mfile = open(fn, "a")
+ end
+ io = transport_file_io(msg)
+ write(twin.mfile, io.data)
+ flush(twin.mfile)
+ catch e
+ @error "[$twin] park_message: $e"
+ showerror(stdout, e, catch_backtrace())
+ @showerror e
+ end
+end
+
+function test_file(file::String)
+ count = 0
+ tdir = twindir()
+ fn = joinpath(tdir, file)
+ if isfile(fn)
+ open(fn, "r") do f
+ while !eof(f)
+ msg = getmsg(f)
+ count += 1
+ end
+ end
+ end
+ @info "messages: $count"
+end
+
+
+function getmsg(f)
+ lens = read(f, 4)
+ len::UInt32 = lens[1] + Int(lens[2]) << 8 + Int(lens[3]) << 16 + Int(lens[4]) << 24
+ content = read(f, len)
+
+ io = IOBuffer(maxsize=len)
+ write(io, content)
+ seekstart(io)
+ llmsg = decode(io)
+ msg = PubSubMsg(llmsg[1], llmsg[2])
+ return msg
+end
+
+function upload_to_queue(twin)
+ try
+ tdir = twindir()
+ fn = joinpath(tdir, twin.id)
+ if isfile(fn)
+ open(fn, "r") do f
+ while !eof(f)
+ msg = getmsg(f)
+ @mlog("[$(twin.id)] <- $(prettystr(msg))")
+ enqueue!(twin.mq, msg)
+ end
+ end
+ if twin.mfile !== nothing
+ close(twin.mfile)
+ twin.mfile = nothing
+ end
+ # finally delete the file
+ @debug "deleting enqueued messages file [$fn]"
+ rm(fn)
+ end
+ catch e
+ @error "upload_to_queue: $e"
+ @showerror e
+ end
+end
+
+function unpark(ctx::Any, twin::Twin)
+ count = 0
+ try
+ tdir = twindir()
+ fn = joinpath(tdir, twin.id)
+ if isfile(fn)
+ open(fn, "r") do f
+ while !eof(f)
+ msg = getmsg(f)
+ count += 1
+ @mlog("[$(twin.id)] <- $(prettystr(msg))")
+ retro = get(twin.retroactive, msg.topic, true)
+ if retro
+ if (count % 5000) == 0
+ # pause a little to give time at the receiver to
+ # get the ack messages
+ sleep(0.1)
+ end
+ transport_send(twin, twin.sock, msg)
+ else
+ @debug "[$twin] retroactive=$(retro): skipping msg $msg"
+ end
+ end
+ end
+ @debug "[$twin] sent $count cached msgs"
+ if twin.mfile !== nothing
+ close(twin.mfile)
+ twin.mfile = nothing
+ end
+ # finally delete the file
+ @debug "deleting enqueued messages file [$fn]"
+ rm(fn)
+ #mv(fn, "$fn.sav", force=true)
+ end
+ catch e
+ @error "[$twin] unpark_messages: $e"
+ @showerror e
+ end
+end
+
+"""
+ save_configuration(router::Router)
+
+Persist router configuration on disk.
+"""
+function save_configuration(router::Router)
+ callback_or(router, :save_configuration) do
+ @debug "saving configuration on disk"
+ save_impl_table(router)
+ save_topic_auth_table(router)
+ save_admins(router)
+ save_twins(router)
+ end
+end
+
+function load_configuration(router)
+ callback_or(router, :load_configuration) do
+ @debug "loading configuration from disk"
+ load_twins(router)
+ load_impl_table(router)
+ load_topic_auth_table(router)
+ load_admins(router)
+
+ router.owners = load_owners()
+ router.token_app = load_token_app()
+ end
+end
diff --git a/src/transport.jl b/src/transport.jl
new file mode 100755
index 0000000..6182182
--- /dev/null
+++ b/src/transport.jl
@@ -0,0 +1,788 @@
+#=
+SPDX-License-Identifier: AGPL-3.0-only
+
+Copyright (C) 2024 Attilio Donà attilio.dona@gmail.com
+Copyright (C) 2024 Claudio Carraro carraro.claudio@gmail.com
+=#
+
+const DATA_EMPTY = UInt8[0xf6]
+
+const HEADER_LEN1 = 0x81
+const HEADER_LEN2 = 0x8D
+const HEADER_LEN4 = 0x8F
+
+"""
+ zmq_load(socket::ZMQ.Socket)
+
+Get a Rembus message from a ZeroMQ multipart message.
+
+The decoding is performed at the client side.
+"""
+function zmq_load(socket::ZMQ.Socket)
+
+ pkt = zmq_message(socket)
+
+ header = pkt.header
+ data::Vector{UInt8} = pkt.data
+
+ type = header[1]
+ ptype = type & 0x3f
+ flags = type & 0xc0
+ if ptype == TYPE_PUB
+ topic = header[2]
+ h = UInt128(hash(topic)) + UInt128(hash(data)) << 64
+ msg = PubSubMsg(topic, dataframe_if_tagvalue(decode(data)), flags, h)
+ elseif ptype == TYPE_RPC
+ id = bytes2id(header[2])
+ topic = header[3]
+ target = header[4]
+ msg = RpcReqMsg(id, topic, dataframe_if_tagvalue(decode(data)), target, flags)
+ elseif ptype == TYPE_RESPONSE
+ id = bytes2id(header[2])
+ status = header[3]
+ # NOTE: for very large dataframes decode is slow, needs investigation.
+ try
+ val = decode(data)
+ return ResMsg(id, status, dataframe_if_tagvalue(val), flags)
+ catch e
+ return ResMsg(id, STS_GENERIC_ERROR, "$e", flags)
+ end
+ end
+ msg
+end
+
+"""
+ connected_socket_load(pkt)
+
+Get a Rembus message from a CBOR encoded packet.
+
+The decoding is performed at the client side.
+"""
+function connected_socket_load(pkt)
+ payload = decode(pkt)
+
+ ptype = payload[1] & 0x3f
+ flags = payload[1] & 0xc0
+ if ptype == TYPE_PUB
+ h = UInt128(hash(payload[2])) + UInt128(hash(payload[3])) << 64
+ data = dataframe_if_tagvalue(payload[3])
+ # topic data
+ return PubSubMsg(payload[2], data, flags, h)
+ elseif ptype == TYPE_RPC
+ if length(payload) == 5
+ data = dataframe_if_tagvalue(payload[5])
+ else
+ data = dataframe_if_tagvalue(payload[4])
+ end
+ # id topic data
+ return RpcReqMsg(bytes2id(payload[2]), payload[3], data)
+ elseif ptype == TYPE_RESPONSE
+ data = dataframe_if_tagvalue(payload[4])
+ # id status data
+ return ResMsg(bytes2id(payload[2]), payload[3], data, flags)
+ end
+end
+
+"""
+ broker_parse(pkt)
+
+Get a Rembus message from a CBOR encoded packet.
+
+The decoding is performed at the broker side.
+"""
+function broker_parse(pkt)
+ payload = decode(pkt)
+
+ ptype = payload[1] & 0x3f
+ flags = payload[1] & 0xc0
+ if ptype == TYPE_IDENTITY
+ cid = payload[3]
+ @debug "< handle_ack_timeout(
+ tim, twin, msg, msgid
+ ), ACK_WAIT_TIME)
+ end
+
+ transport_send(ws, msg)
+end
+
+function transport_send(ws, msg::PubSubMsg)
+ content = tagvalue_if_dataframe(msg.data)
+ pkt = [TYPE_PUB | msg.flags, msg.topic, message2data(content)]
+ transport_write(ws, pkt)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::PubSubMsg)
+ content = tagvalue_if_dataframe(msg.data)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_PUB | msg.flags, msg.topic]), more=true)
+ send(socket, encode(content), more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+function transport_send(twin::Twin, socket::ZMQ.Socket, msg::PubSubMsg)
+ address = twin.router.twin2address[twin.id]
+ data = data2message(msg.data)
+
+ if twin.qos === with_ack
+ msg.flags |= 0x80
+ msgid = UInt128(hash(msg.topic)) + UInt128(hash(data)) << 64
+ twin.acktimer[msgid] = Timer((tim) -> handle_ack_timeout(
+ tim, twin, msg, msgid
+ ), ACK_WAIT_TIME)
+ end
+
+ send(socket, address, more=true)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_PUB | msg.flags, msg.topic]), more=true)
+ send(socket, data, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+transport_send(::Twin, ws, msg::RpcReqMsg) = transport_send(ws, msg::RpcReqMsg)
+
+function transport_send(ws, msg::RpcReqMsg)
+ content = tagvalue_if_dataframe(msg.data)
+ if msg.target === nothing
+ pkt = [
+ TYPE_RPC | msg.flags,
+ id2bytes(msg.id),
+ msg.topic,
+ nothing,
+ message2data(content)
+ ]
+ else
+ pkt = [
+ TYPE_RPC | msg.flags,
+ id2bytes(msg.id),
+ msg.topic,
+ msg.target,
+ message2data(content)
+ ]
+ end
+ transport_write(ws, pkt)
+end
+
+function transport_send(twin::Twin, socket::ZMQ.Socket, msg::RpcReqMsg)
+ address = twin.router.twin2address[twin.id]
+ send(socket, address, more=true)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_RPC, id2bytes(msg.id), msg.topic, nothing]), more=true)
+ send(socket, data2message(msg.data), more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::RpcReqMsg)
+ content = tagvalue_if_dataframe(msg.data)
+ send(socket, Message(), more=true)
+
+ if msg.target === nothing
+ target = nothing
+ else
+ target = msg.target
+ end
+
+ send(socket, encode([TYPE_RPC, id2bytes(msg.id), msg.topic, target]), more=true)
+ send(socket, encode(msg.data), more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+function transport_send(twin::Twin, socket::ZMQ.Socket, msg::ResMsg, enc=false)
+ address = twin.router.mid2address[msg.id]
+ send(socket, address, more=true)
+
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_RESPONSE, id2bytes(msg.id), msg.status]), more=true)
+ if enc
+ data = encode(msg.data)
+ else
+ data = data2message(msg.data)
+ end
+ send(socket, data, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::ResMsg)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_RESPONSE, id2bytes(msg.id), msg.status]), more=true)
+ send(socket, encode(msg.data), more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+transport_send(::Twin, ws, msg::ResMsg, enc=false) = transport_send(ws, msg::ResMsg, enc)
+
+function transport_send(ws, msg::ResMsg, ::Bool=false)
+ content = tagvalue_if_dataframe(msg.data)
+ pkt = [TYPE_RESPONSE | msg.flags, id2bytes(msg.id), msg.status, message2data(content)]
+ transport_write(ws, pkt)
+end
+
+transport_send(::Twin, ws, msg::AdminReqMsg) = transport_send(ws, msg::AdminReqMsg)
+
+function transport_send(ws, msg::AdminReqMsg)
+ content = tagvalue_if_dataframe(msg.data)
+ pkt = [TYPE_ADMIN | msg.flags, id2bytes(msg.id), msg.topic, content]
+ transport_write(ws, pkt)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::AdminReqMsg)
+ if isa(msg.data, DataFrame)
+ content = tagvalue_if_dataframe(msg.data)
+ else
+ content = msg.data
+ end
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_ADMIN, id2bytes(msg.id), msg.topic]), more=true)
+ send(socket, encode(content), more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+transport_send(::Twin, ws, msg::AckMsg) = transport_send(ws, msg::AckMsg)
+
+function transport_send(ws, msg::AckMsg)
+ pkt = [TYPE_ACK, id2bytes(msg.hash)]
+ transport_write(ws, pkt)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::AckMsg)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_ACK, id2bytes(msg.hash)]), more=true)
+ send(socket, DATA_EMPTY, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+transport_send(::Twin, ws, msg::Attestation) = transport_send(ws, msg::Attestation)
+
+function transport_send(ws, msg::Attestation)
+ pkt = [TYPE_ATTESTATION, id2bytes(msg.id), msg.cid, msg.signature]
+ transport_write(ws, pkt)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::Attestation)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_ATTESTATION, id2bytes(msg.id), msg.cid]), more=true)
+ send(socket, msg.signature, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+transport_send(::Twin, ws, msg::Register) = transport_send(ws, msg::Register)
+
+function transport_send(ws, msg::Register)
+ pkt = [TYPE_REGISTER, id2bytes(msg.id), msg.cid, msg.userid, msg.pubkey]
+ transport_write(ws, pkt)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::Register)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_REGISTER, id2bytes(msg.id), msg.cid, msg.userid]), more=true)
+ send(socket, msg.pubkey, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+transport_send(::Twin, ws, msg::Unregister) = transport_send(ws, msg::Unregister)
+
+function transport_send(ws, msg::Unregister)
+ pkt = [TYPE_UNREGISTER, id2bytes(msg.id), msg.cid]
+ transport_write(ws, pkt)
+end
+
+function transport_send(socket::ZMQ.Socket, msg::Unregister)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_UNREGISTER, id2bytes(msg.id), msg.cid]), more=true)
+ send(socket, DATA_EMPTY, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+function transport_send(socket::ZMQ.Socket, ::Close)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_CLOSE]), more=true)
+ send(socket, DATA_EMPTY, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+function transport_send(socket::ZMQ.Socket, ::Remove)
+ send(socket, Message(), more=true)
+ send(socket, encode([TYPE_REMOVE]), more=true)
+ send(socket, DATA_EMPTY, more=true)
+ send(socket, MESSAGE_END, more=false)
+end
+
+function tagvalue_if_dataframe(data)
+ if isa(data, Vector)
+ result = []
+
+ for el in data
+ if isa(el, DataFrame)
+ aio = IOBuffer()
+ Arrow.write(aio, el)
+ push!(result, Tag(DATAFRAME_TAG, aio.data))
+ else
+ push!(result, el)
+ end
+ end
+ return result
+ elseif isa(data, DataFrame)
+ aio = IOBuffer()
+ Arrow.write(aio, data)
+
+ return Tag(DATAFRAME_TAG, aio.data)
+ end
+
+ return data
+end
+
+function dataframe_if_tagvalue(buffer)
+ if isa(buffer, Vector)
+ result = []
+
+ for el in buffer
+ if isa(el, Tag) && el.id == DATAFRAME_TAG
+ @debug "assuming that element is a DataFrame"
+ push!(result, DataFrame(Arrow.Table(IOBuffer(el.data))))
+ else
+ push!(result, el)
+ end
+ end
+ return result
+ else
+ if isa(buffer, Tag) && buffer.id == DATAFRAME_TAG
+ @debug "assuming that data is a DataFrame"
+ return DataFrame(Arrow.Table(IOBuffer(buffer.data)))
+ end
+ end
+
+ return buffer
+end
+
+function transport_write(ws::WebSockets.WebSocket, pkt)
+ payload = encode(pkt)
+ @rawlog("out: $payload")
+ try
+ lock(websocketlock) do
+ HTTP.WebSockets.send(ws, payload)
+ end
+ catch e
+ throw(RembusDisconnect())
+ end
+end
+
+function transport_write(sock, llmsg)
+ payload = encode(llmsg)
+ len = length(payload)
+ if len < 256
+ header = vcat(HEADER_LEN1, UInt8(len))
+ elseif len < 65536
+ header = vcat(HEADER_LEN2, UInt8((len >> 8) & 0xFF), UInt8(len & 0xFF))
+ else
+ header = vcat(
+ HEADER_LEN4,
+ UInt8((len >> 24) & 0xFF),
+ UInt8((len >> 16) & 0xFF),
+ UInt8((len >> 8) & 0xFF),
+ UInt8(len & 0xFF)
+ )
+ end
+
+ io = IOBuffer(maxsize=length(header) + len)
+ write(io, header)
+ write(io, payload)
+ @rawlog("out: $(io.data)")
+ write(sock, io.data)
+ flush(sock)
+end
+
+function transport_read(sock::MbedTLS.SSLContext)
+ while true
+ headers = read(sock, 1)
+ if isempty(headers)
+ MbedTLS.ssl_session_reset(sock)
+ throw(ConnectionClosed())
+ end
+ type = headers[1]
+ if type === HEADER_LEN1
+ len = read(sock, 1)[1]
+ elseif type === HEADER_LEN2
+ lb = read(sock, 2)
+ len = Int(lb[1]) << 8 + lb[2]
+ elseif type === HEADER_LEN4
+ lb = read(sock, 4)
+ len = Int(lb[1]) << 24 + Int(lb[2]) << 16 + Int(lb[3]) << 8 + lb[4]
+ else
+ @error "tcp channel invalid header format"
+ throw(ConnectionClosed())
+ end
+ payload = read(sock, len)
+ @rawlog("in: $payload")
+ return payload
+ end
+end
+
+function transport_read(sock::TCPSocket)
+ while true
+ headers = read(sock, 1)
+ if isempty(headers)
+ throw(ConnectionClosed())
+ end
+ type = headers[1]
+ if type === HEADER_LEN1
+ len = read(sock, 1)[1]
+ elseif type === HEADER_LEN2
+ lb = read(sock, 2)
+ len = Int(lb[1]) << 8 + lb[2]
+ elseif type === HEADER_LEN4
+ lb = read(sock, 4)
+ len = Int(lb[1]) << 24 + Int(lb[2]) << 16 + Int(lb[3]) << 8 + lb[4]
+ else
+ @error "tcp channel invalid header value [$type]"
+ throw(ConnectionClosed())
+ end
+ payload = read(sock, len)
+ @rawlog("in: $payload")
+ return payload
+ end
+end
+
+function transport_read(socket::WebSockets.WebSocket)
+ d = HTTP.WebSockets.receive(socket)
+ @rawlog("in: $d ($(typeof(d)))")
+ d
+end
+
+function isconnectionerror(ws::WebSockets.WebSocket, e)
+ isa(e, EOFError) || isa(e, Base.IOError) || !WebSockets.isok(ws)
+end
+
+function isconnectionerror(ws, e)
+ return isa(e, EOFError) || isa(e, Base.IOError) || isa(e, ConnectionClosed)
+end
+
+Base.isopen(ws::WebSockets.WebSocket) = Base.isopen(ws.io)
diff --git a/test/ack/ack_common.jl b/test/ack/ack_common.jl
new file mode 100755
index 0000000..1782011
--- /dev/null
+++ b/test/ack/ack_common.jl
@@ -0,0 +1,73 @@
+include("../utils.jl")
+
+test_topic = "acktopic"
+
+function consume(data)
+ global count
+ global ts
+
+ count += 1
+ #if (count % 10000) == 0
+ if (count % 10000) == 0
+ delta = time() - ts
+ @info "*** $count records received in $delta secs"
+ end
+ #print(".")
+end
+
+function storm(pub)
+ global ts
+ ts = time()
+ @info "sending"
+ for i in 1:num_msg
+ if (i % 5000) == 0
+ sleep(0.01)
+ end
+ publish(pub, test_topic, i)
+ end
+ @info "done"
+end
+
+function run(publisher, consumer)
+ global count
+ count = 0
+
+ #sleep(2)
+ pub = connect(publisher)
+ sub = connect(consumer)
+ enable_ack(sub)
+
+ sleep(0.1)
+ reactive(sub)
+
+ subscribe(sub, test_topic, consume, true)
+
+ @async storm(pub)
+
+ sleep(2)
+
+ # close and connect again
+ close(sub)
+ sleep(2)
+
+ @debug "reopening $consumer" _group = :test
+ sub = connect(consumer)
+ enable_ack(sub)
+ #sub = connect("zmq://10.220.18.228:8002/test_ack_sub")
+
+ subscribe(sub, test_topic, consume, true)
+ reactive(sub)
+
+ @info "sleeping"
+ sleep(15)
+ for cli in [pub, sub]
+ @info "closing $cli"
+ close(cli)
+ end
+ #sleep(5)
+ @info "end"
+end
+
+## # for jit
+## comp = connect("compile_component")
+## close(comp)
diff --git a/test/ack/test_ws_ack.jl b/test/ack/test_ws_ack.jl
new file mode 100755
index 0000000..525abc4
--- /dev/null
+++ b/test/ack/test_ws_ack.jl
@@ -0,0 +1,12 @@
+include("ack_common.jl")
+
+publisher = "test_ack_pub"
+consumer = "test_ack_sub"
+num_msg = 100000
+
+count = 0
+
+execute_caronte_process(() -> run(publisher, consumer), "test_ws_ack")
+
+@info "[test_ws_ack] received $count messages"
+@test num_msg <= count < num_msg + 100
\ No newline at end of file
diff --git a/test/ack/test_zmq_ack.jl b/test/ack/test_zmq_ack.jl
new file mode 100755
index 0000000..c96f503
--- /dev/null
+++ b/test/ack/test_zmq_ack.jl
@@ -0,0 +1,12 @@
+include("ack_common.jl")
+
+publisher = "zmq://:8002/test_ack_pub"
+consumer = "zmq://:8002/test_ack_sub"
+num_msg = 100000
+
+count = 0
+
+execute(() -> run(publisher, consumer), "test_zmq_ack")
+
+@info "[test_zmq_ack] received $count messages"
+@test num_msg <= count < num_msg + 20000
\ No newline at end of file
diff --git a/test/api/test_mixed.jl b/test/api/test_mixed.jl
new file mode 100644
index 0000000..a4f64b6
--- /dev/null
+++ b/test/api/test_mixed.jl
@@ -0,0 +1,48 @@
+include("../utils.jl")
+
+using DataFrames
+
+mutable struct TestHolder
+ request_arg::String
+ TestHolder() = new("")
+end
+
+df = DataFrame("col" => ["a", "b"], "val" => [1, 2])
+
+function mymethod(arg)
+ @debug "[exposer] arg: $arg" _group = :test
+ df
+end
+
+function mymethod(bag, arg)
+ @debug "[subscriber]: received $arg" _group = :test
+ bag.request_arg = arg
+end
+
+function run()
+ @debug "starting ..." _group = :test
+ bag = TestHolder()
+
+ @component "exposer"
+ @component "zmq://:8002/client"
+ @component "zmq://:8002/subscriber"
+
+ @expose "exposer" mymethod
+
+ @subscribe "subscriber" mymethod
+ @shared "subscriber" bag
+ @reactive "subscriber"
+
+ invalue = "pippo"
+ result = @rpc "client" mymethod(invalue)
+ @debug "rpc result = $result" _group = :test
+ @test result == df
+ @test bag.request_arg == invalue
+
+ @terminate "client"
+ @terminate "subscriber"
+ @terminate "exposer"
+
+end
+
+execute(run, "test_mixed")
diff --git a/test/api/test_publish.jl b/test/api/test_publish.jl
new file mode 100644
index 0000000..7d4ed28
--- /dev/null
+++ b/test/api/test_publish.jl
@@ -0,0 +1,124 @@
+using DataFrames
+
+include("../utils.jl")
+
+df = DataFrame(name=["trento", "belluno"], score=[10, 50])
+bdf = DataFrame(x=1:10, y=1:10)
+
+mutable struct TestBag
+ noarg_message_received::Bool
+ msg_received::Int
+end
+
+function inspect(bag::TestBag)
+ bag.noarg_message_received = true
+end
+
+function consume(bag::TestBag, data::Number)
+ bag.msg_received += 1
+
+ @debug "consume recv: $data" _group = :test
+ @atest data == 2 "consume(data=$data): expected data == 2"
+end
+
+function consume(bag::TestBag, data)
+ bag.msg_received += 1
+
+ @debug "df:\n$(view(data, 1:2, :))" _group = :test
+ if names(data) == ["x", "y"]
+ @atest size(data) == size(bdf) "data:$(size(data)) == bdf:$(size(bdf))"
+ else
+ @atest size(data) == size(df) "data:$(size(data)) == df:$(size(df))"
+ end
+end
+
+
+function publish_workflow(pub, sub1, sub2, sub3, isfirst=false)
+ waittime = 0.3
+ my_topic = "my_topic"
+ noarg_topic = "noarg_topic"
+
+ testbag = TestBag(false, 0)
+
+ publisher = connect(pub)
+
+ sub1 = connect(sub1)
+
+ shared(sub1, testbag)
+
+ subscribe(sub1, my_topic, consume)
+
+ sub2 = connect(sub2)
+ shared(sub2, testbag)
+
+ subscribe(sub2, my_topic, consume)
+ subscribe(sub2, noarg_topic, inspect)
+
+ # sub3 is not reactive: no messages delivered to sub3
+ sub3 = connect(sub3)
+ shared(sub3, testbag)
+ subscribe(sub3, my_topic, consume, true)
+
+ publish(publisher, my_topic, 2)
+ publish(publisher, my_topic, df)
+ publish(publisher, my_topic, bdf)
+ # publish with no args
+ try
+ publish(publisher, noarg_topic)
+ catch e
+ @test false
+ end
+
+ sleep(waittime)
+ unsubscribe(sub1, my_topic)
+
+ # removing a not registerd interest throws an error
+ try
+ unsubscribe(sub1, my_topic)
+ @test 0 == 1
+ catch e
+ @debug "[remove interest]: $e" _group = :test
+ @test isa(e, Rembus.RembusError)
+ @test e.code === Rembus.STS_GENERIC_ERROR
+ end
+
+ #if isfirst
+ # sleep(1)
+ #else
+ sleep(waittime / 4)
+ #end
+
+ if isfirst
+ @info "testbag.msg_received $(testbag.msg_received) === 9"
+ else
+ @test testbag.msg_received === 9
+ end
+
+ for cli in [publisher, sub1, sub2, sub3]
+ close(cli)
+ end
+
+ if isfirst
+ @info "testbag.noarg_message_received $(testbag.noarg_message_received) === true"
+ else
+ @test testbag.noarg_message_received === true
+ end
+end
+
+function run()
+ publish_workflow("pub", "tcp://:8001/sub1", "tcp://:8001/sub2", "sub3", true)
+
+ for sub1 in ["tcp://:8001/sub1", "zmq://:8002/sub1"]
+ for sub2 in ["tcp://:8001/sub2", "zmq://:8002/sub2"]
+ for sub3 in ["sub3", "zmq://:8002/sub3"]
+ for publisher in ["pub", "zmq://:8002/pub"]
+ @debug "test_publish endpoints: $sub1, $sub2, $sub3, $publisher, " _group = :test
+ publish_workflow(publisher, sub1, sub2, sub3)
+ testsummary()
+ end
+ end
+ end
+ end
+end
+
+execute(run, "test_publish")
diff --git a/test/api/test_publish_macros.jl b/test/api/test_publish_macros.jl
new file mode 100755
index 0000000..47004f8
--- /dev/null
+++ b/test/api/test_publish_macros.jl
@@ -0,0 +1,115 @@
+using DataFrames
+
+include("../utils.jl")
+
+df = DataFrame(name=["trento", "belluno"], score=[10, 50])
+bdf = DataFrame(x=1:10)
+bdf.y = bdf.x .^ 2
+
+function noarg(ctx)
+ ctx.noarg_message_received = true
+end
+
+function mytopic(ctx, data::Number)
+ ctx.msg_received += 1
+ @atest data == 2 "mytopic(data=$data): expected data == 2"
+end
+
+function mytopic(ctx, data)
+ ctx.msg_received += 1
+ if names(data) == ["x", "y"]
+ @atest size(data) == size(bdf) "data:$(size(data)) == bdf:$(size(bdf))"
+ else
+ @atest size(data) == size(df) "data:$(size(data)) == df:$(size(df))"
+ end
+end
+
+function publish_workflow(publisher, sub1, sub2, sub3; waittime=1, testholder=missing)
+ @component sub1
+
+ @subscribe sub1 mytopic from_now
+
+ if testholder !== missing
+ @shared sub1 testholder
+ end
+
+ @reactive sub1
+
+ @component sub2
+ @reactive sub2
+
+ @subscribe sub2 mytopic from_now
+ if testholder !== missing
+ @shared sub2 testholder
+ end
+
+ @subscribe sub2 noarg from_now
+
+ # sub3 is not reactive: no messages delivered to sub3
+ @component sub3
+
+ @subscribe sub3 mytopic before_now
+ if testholder !== missing
+ @shared sub3 testholder
+ end
+
+ sleep(waittime / 3)
+
+ @component publisher
+ @publish publisher mytopic(2)
+ @publish publisher mytopic(df)
+ @publish publisher mytopic(bdf)
+
+ # publish with no args
+ try
+ @publish publisher noarg()
+ catch e
+ testholder !== missing && @test false
+ end
+
+ sleep(waittime)
+
+ @unsubscribe sub1 mytopic
+
+ #removing a not registered interest throws an error
+ try
+ @unsubscribe sub1 mytopic
+ catch e
+ @test isa(e, Rembus.RembusError)
+ @test e.code === Rembus.STS_GENERIC_ERROR
+ end
+
+ sleep(waittime / 2)
+ testholder !== missing && @test testholder.msg_received === 9
+
+ for cli in [publisher, sub1, sub2, sub3]
+ @terminate cli
+ end
+end
+
+Rembus.CONFIG.zmq_ping_interval = 0
+
+mutable struct TestHolder
+ noarg_message_received::Bool
+ msg_received::Int
+end
+
+function run()
+ waittime = 0.8
+ for sub1 in ["tcp://:8001/sub1", "zmq://:8002/sub1"]
+ for sub2 in ["tcp://:8001/sub2", "zmq://:8002/sub2"]
+ for sub3 in ["sub3", "zmq://:8002/sub3"]
+ for publisher in ["pub", "zmq://:8002/pub"]
+ @debug "test_publish endpoints: $sub1, $sub2, $sub3, $publisher" _group = :test
+ testholder = TestHolder(false, 0)
+ publish_workflow(publisher, sub1, sub2, sub3, waittime=waittime, testholder=testholder)
+ testsummary()
+ @test testholder.noarg_message_received === true
+ waittime = 0.4
+ end
+ end
+ end
+ end
+end
+
+execute(run, "test_publish_macros")
diff --git a/test/api/test_request.jl b/test/api/test_request.jl
new file mode 100755
index 0000000..ef90b27
--- /dev/null
+++ b/test/api/test_request.jl
@@ -0,0 +1,127 @@
+include("../utils.jl")
+
+rpc_topic = "rpc_method"
+request_arg = 1
+reason = "this is an error"
+args_error_msg = "expected a float32"
+
+mutable struct TestBag
+ rpc_method_invoked::Bool
+end
+
+function add_one(add_one_arg)
+ @atest add_one_arg == request_arg
+ add_one_arg + 1
+end
+
+function do_method_error(data)
+ error(reason)
+end
+
+function do_args_error(data)
+ if !isa(data, Float32)
+ throw(ErrorException(args_error_msg))
+ end
+end
+
+function rpc_method(bag, rpc_method_arg)
+ bag.rpc_method_invoked = true
+
+ # expect rpc_method_arg equals to request arg
+ @atest rpc_method_arg == request_arg
+end
+
+function run(request_url, subscriber_url, exposer_url)
+ bag = TestBag(false)
+ client = tryconnect(request_url)
+
+ try
+ rpc(client, rpc_topic, exceptionerror=true)
+ @test 0 == 1
+ catch e
+ @test isa(e, Rembus.RpcMethodNotFound)
+ @test e.cid === client.client.id
+ @test e.topic === rpc_topic
+ end
+
+ implementor = tryconnect(exposer_url)
+ expose(implementor, rpc_topic, add_one)
+
+ subscriber = tryconnect(subscriber_url)
+ shared(subscriber, bag)
+ reactive(subscriber)
+ subscribe(subscriber, rpc_topic, rpc_method)
+
+ res = rpc(client, rpc_topic, request_arg)
+ @test res == 2
+
+ try
+ res = rpc(implementor, rpc_topic, exceptionerror=true)
+ @test 0 == 1
+ catch e
+ @test isa(e, Rembus.RpcMethodLoopback)
+ @test e.cid === implementor.client.id
+ @test e.topic === rpc_topic
+ end
+
+ expose(implementor, rpc_topic, do_method_error)
+ try
+ res = rpc(client, rpc_topic)
+ @test 0 == 1
+ catch e
+ @test isa(e, Rembus.RpcMethodException)
+ @test e.cid === client.client.id
+ @test e.topic === rpc_topic
+ end
+
+ expose(implementor, rpc_topic, do_args_error)
+ try
+ res = rpc(client, rpc_topic)
+ @test 0 == 1
+ catch e
+ @test isa(e, Rembus.RpcMethodException)
+ @test e.cid === client.client.id
+ @test e.topic === rpc_topic
+ end
+
+ close(implementor)
+ try
+ res = rpc(client, rpc_topic, timeout=2)
+ @test 0 == 1
+ catch e
+ if isa(e, Rembus.RpcMethodUnavailable)
+ @test e.cid === client.client.id
+ @test e.topic === rpc_topic
+ else
+ @warn "unexpected exception: $e"
+ @test false
+ end
+ end
+
+ @test bag.rpc_method_invoked === true
+
+ implementor = tryconnect(exposer_url)
+ unexpose(implementor, rpc_topic)
+
+ for cli in [implementor, client, subscriber]
+ close(cli)
+ end
+ sleep(0.000001)
+end
+
+Rembus.CONFIG.zmq_ping_interval = 0
+Rembus.CONFIG.ws_ping_interval = 0
+
+function run()
+ for exposer_url in ["zmq://:8002/test_request_impl", "test_request_impl"]
+ for subscriber_url in ["zmq://:8002/test_request_sub", "test_request_sub"]
+ for request_url in ["zmq://:8002/test_request", "test_request"]
+ @debug "rpc endpoints: $exposer_url, $subscriber_url, $request_url" _group = :test
+ run(request_url, subscriber_url, exposer_url)
+ testsummary()
+ end
+ end
+ end
+end
+
+execute(run, "test_request_api")
diff --git a/test/api/test_types.jl b/test/api/test_types.jl
new file mode 100644
index 0000000..5a3b679
--- /dev/null
+++ b/test/api/test_types.jl
@@ -0,0 +1,78 @@
+include("../utils.jl")
+
+using DataFrames
+
+mutable struct TestHolder
+ handler_called::Int
+ noarg_message_received::Bool
+ valuemap::Vector
+ TestHolder() = new(0, false, [ # type of value received => value sent
+ UInt8 => Int(1),
+ UInt8 => 255,
+ UInt16 => 256,
+ UInt32 => 65536,
+ Int8 => -1,
+ Int16 => -129,
+ Int32 => -40000,
+ Float32 => Float32(1.2),
+ Float64 => Float64(1.2),
+ DataFrame => DataFrame(:a => [1, 2]),
+ String => "foo",
+ Dict => Dict(1 => 2),
+ Vector => [1, 2]
+ ])
+end
+
+function foo(n)
+ n + 1
+end
+
+function foo(bag, n)
+ @debug "[foo]: recv $n" _group = :test
+end
+
+function bar(bag, n)
+ @debug "[bar]: recv $n ($(typeof(n)))" _group = :test
+ bag.handler_called += 1
+ @atest isa(n, bag.valuemap[bag.handler_called].first) "$n isa $(bag.valuemap[bag.handler_called].first)"
+end
+
+function run()
+ exposer = "test_types_exposer"
+ requestor = "test_types_cli"
+ listener = "test_types_listener"
+
+ bag = TestHolder()
+
+ @component exposer
+ @component requestor
+ @component listener
+
+ sleep(0.1)
+
+ @subscribe listener foo from_now
+ @shared listener bag
+ @reactive listener
+
+ @expose exposer foo
+ sleep(0.1)
+
+ result = @rpc requestor foo(1)
+ @debug "[$requestor] result=$result" _group = :test
+ @test result == 2
+
+ @subscribe exposer bar from_now
+ @shared exposer bag
+ @reactive exposer
+
+ sleep(0.1)
+ for (typ, msg) in bag.valuemap
+ sleep(0.05)
+ @publish requestor bar(msg)
+ end
+
+ sleep(1)
+ @test bag.handler_called == length(bag.valuemap)
+end
+
+execute(run, "test_types")
diff --git a/test/api/test_zmq.jl b/test/api/test_zmq.jl
new file mode 100644
index 0000000..ee4b1e8
--- /dev/null
+++ b/test/api/test_zmq.jl
@@ -0,0 +1,38 @@
+include("../utils.jl")
+
+using DataFrames
+
+df = DataFrame("col" => ["a", "b"], "val" => [1, 2])
+function mymethod(arg)
+ @debug "[mymethod] arg: $arg" _group = :test
+ df
+end
+
+function consume(arg)
+ @debug "[sub]: received $arg" _group = :test
+end
+
+function run()
+ @component "zmq://:8002/exposer"
+ @component "zmq://:8002/subscriber"
+ @component "zmq://:8002/client"
+
+ @expose "exposer" mymethod
+ sleep(0.5)
+
+ result = @rpc "client" mymethod(DataFrame("x" => 1:3))
+ @debug "rpc result = $result" _group = :test
+ @test result == df
+
+ @subscribe "subscriber" consume
+ @reactive "subscriber"
+
+ @publish "client" consume("hello")
+ @publish "client" consume(df)
+
+ @terminate "client"
+ @terminate "subscriber"
+ @terminate "exposer"
+end
+
+execute(run, "test_zmq")
\ No newline at end of file
diff --git a/test/auth/test_register.jl b/test/auth/test_register.jl
new file mode 100644
index 0000000..e998e43
--- /dev/null
+++ b/test/auth/test_register.jl
@@ -0,0 +1,60 @@
+include("../utils.jl")
+
+using DataFrames
+
+function init(uid, pin)
+ df = DataFrame(pin=String[pin], uid=String[uid], name=["Test"], enabled=Bool[true])
+ if !isdir(Rembus.CONFIG.db)
+ mkdir(Rembus.CONFIG.db)
+ end
+ Rembus.save_owners(df)
+end
+
+function run()
+ cmp = Rembus.Component(url)
+
+ Rembus.register(url, uid, pin)
+
+ # check configuration
+ # token_app file contains the app component
+ df = Rembus.load_token_app()
+ @debug "token_app: $df" _group = :test
+ @test df[df.app.==cmp.id, :app][1] === cmp.id
+ @test df[df.app.==cmp.id, :uid][1] === uid
+
+ # private key was created
+ @test isfile(Rembus.pkfile(cmp.id))
+
+ # public key was provisioned
+ fname = Rembus.pubkey_file(cmp.id)
+ @test basename(fname) === cmp.id
+
+ client = tryconnect(url)
+
+ try
+ Rembus.unregister(client, cmp.id)
+
+ df = Rembus.load_token_app()
+
+ # the app component was removed from token_app file
+ @test isempty(df[df.app.==cmp.id, :])
+
+ # the public key was removed
+ @test_throws ErrorException Rembus.pubkey_file(cmp.id)
+
+ # the private key was removed
+ @test isfile(Rembus.pkfile(cmp.id)) === false
+ catch
+ @test 0 == 1
+ rethrow()
+ finally
+ close(client)
+ end
+end
+
+uid = "rembus_user"
+url = "zmq://:8002/regcomp"
+pin = "11223344"
+
+setup() = init(uid, pin)
+execute(run, "test_register", setup=setup)
diff --git a/test/connect/test_connect.jl b/test/connect/test_connect.jl
new file mode 100755
index 0000000..67e9fde
--- /dev/null
+++ b/test/connect/test_connect.jl
@@ -0,0 +1,13 @@
+include("../utils.jl")
+
+function run()
+ for cid in ["tcp://:8001/aaa", "ws://:8000/bbb", "zmq://:8002/ccc"]
+ rb = connect(cid)
+ @test isconnected(rb) === true
+
+ close(rb)
+ @test !isconnected(rb) === true
+ end
+end
+
+execute(run, "test_connect")
diff --git a/test/connect/test_tls_connect.jl b/test/connect/test_tls_connect.jl
new file mode 100755
index 0000000..f3c1386
--- /dev/null
+++ b/test/connect/test_tls_connect.jl
@@ -0,0 +1,20 @@
+include("../utils.jl")
+
+function run()
+ for cid in ["tls://:8001/aaa", "wss://:8000/bbb"]
+ rb = connect(cid)
+
+ @test isconnected(rb) === true
+
+ close(rb)
+ @test !isconnected(rb) === true
+ end
+end
+
+# client env
+ENV["HTTP_CA_BUNDLE"] = joinpath(Rembus.keystore_dir(), "rembus-ca.crt")
+
+args = Dict("secure" => true)
+execute(run, "test_tls_connect", args=args)
+
+delete!(ENV, "HTTP_CA_BUNDLE")
diff --git a/test/coverage.jl b/test/coverage.jl
new file mode 100755
index 0000000..6b0bde7
--- /dev/null
+++ b/test/coverage.jl
@@ -0,0 +1,10 @@
+using Pkg
+using Coverage
+
+Pkg.test("Rembus", coverage=true)
+coverage = process_folder()
+LCOV.writefile("lcov.info", coverage)
+
+for dir in ["src", "test"]
+ foreach(rm, filter(endswith(".cov"), readdir(dir, join=true)))
+end
\ No newline at end of file
diff --git a/test/embedded/test_embedded.jl b/test/embedded/test_embedded.jl
new file mode 100644
index 0000000..dcbaeb3
--- /dev/null
+++ b/test/embedded/test_embedded.jl
@@ -0,0 +1,84 @@
+include("../utils.jl")
+
+using DataFrames
+
+function df_service(session)
+ DataFrame(x=1:10)
+end
+
+function rpc_service(session, x, y)
+ return x + y
+end
+
+function rpc_fault(session, x::Integer, y::Integer)
+ return x // y
+end
+
+smoke_message = "ola"
+received = false
+
+function signal(session, name)
+ global received
+ @atest name == smoke_message "expected name == $smoke_message"
+ received = true
+end
+
+
+function start_server()
+ emb = Rembus.Embedded()
+ provide(emb, df_service)
+ provide(emb, rpc_service)
+ provide(emb, rpc_fault)
+ provide(emb, signal)
+ Rembus.serve(emb, wait=false, exit_when_done=false)
+end
+
+
+function run()
+ try
+ start_server()
+ sleep(2)
+ result = @rpc rpc_service(1, 2)
+ @test result == 3
+
+ df = @rpc df_service()
+ @test isa(df, DataFrame)
+
+ try
+ @rpc rpc_service(1)
+ @test false
+ catch e
+ @info "[test_embedded] rpc_service error: $e"
+ @test isa(e, Rembus.RpcMethodException)
+ end
+
+ try
+ result = @rpc rpc_fault(0, 0)
+ @test false
+ catch e
+ @info "[test_embedded] rpc_fault error: $e"
+ @test isa(e, Rembus.RpcMethodException)
+ end
+
+ @publish signal(smoke_message)
+ @terminate
+
+ rb = connect()
+ publish(rb, "signal", smoke_message)
+
+ # if an error on the embedded side occurred the connection is closed
+ @test Rembus.isconnected(rb)
+
+ close(rb)
+ catch e
+ @error "[test_embedded] error: $e"
+ @test false
+ finally
+ shutdown()
+ end
+
+end
+
+run()
+@test received
+testsummary()
diff --git a/test/integration/test_process_fault.jl b/test/integration/test_process_fault.jl
new file mode 100644
index 0000000..a9f4421
--- /dev/null
+++ b/test/integration/test_process_fault.jl
@@ -0,0 +1,43 @@
+include("../utils.jl")
+
+using Distributed
+using Visor
+
+restarts = 0
+
+function trace(supervisor, msg)
+ global restarts
+ if isa(msg, Visor.ProcessError)
+ restarts += 1
+ end
+end
+
+function run()
+ # Component Under Test
+ cid = "test_process"
+
+ caronte_reset()
+
+ Visor.trace_event = trace
+
+ @async supervise([rembus(cid)], intensity=3)
+ sleep(3)
+
+ remotecall(Rembus.caronte, 2, exit_when_done=false)
+ sleep(2)
+end
+
+try
+ @info "[test_process_fault] start"
+ run()
+ @debug "task restarts: $restarts" _group = :test
+ @test 1 <= restarts <= 2
+catch e
+ @error "[test_process_fault]: $e"
+ showerror(stdout, e, catch_backtrace())
+finally
+ shutdown()
+ remotecall(Visor.shutdown, 2)
+ sleep(3)
+ @info "[test_process_fault] stop"
+end
\ No newline at end of file
diff --git a/test/integration/test_retroactive.jl b/test/integration/test_retroactive.jl
new file mode 100755
index 0000000..e3460ce
--- /dev/null
+++ b/test/integration/test_retroactive.jl
@@ -0,0 +1,54 @@
+include("../utils.jl")
+
+
+#RECEIVED = false
+
+mutable struct TestBag
+ broadcast_received::Bool
+end
+
+function consume(bag, data)
+ @debug "[test retroactive] received: $data" _group = :test
+ bag.broadcast_received = true
+end
+
+function run()
+ topic = "mytopic"
+ bag = TestBag(false)
+
+ client = connect("test_retroactive_pub")
+ subscriber = connect("test_retroactive_sub")
+ shared(subscriber, bag)
+ reactive(subscriber)
+ subscribe(subscriber, topic, consume, true)
+
+ close(subscriber)
+
+ publish(client, topic, "lost in dev/null")
+
+ subscriber = connect("test_retroactive_sub")
+ shared(subscriber, bag)
+ subscribe(subscriber, topic, consume, false)
+ reactive(subscriber)
+
+ sleep(0.2)
+ @test bag.broadcast_received === false
+
+ close(subscriber)
+ publish(client, topic, UInt8(1))
+
+ subscriber = connect("test_retroactive_sub")
+ shared(subscriber, bag)
+ subscribe(subscriber, topic, consume, true)
+ reactive(subscriber)
+
+ sleep(0.2)
+ @test bag.broadcast_received === true
+
+ for cli in [client, subscriber]
+ close(cli)
+ end
+ shutdown()
+end
+
+execute(run, "test_retroactive")
diff --git a/test/integration/test_round_robin.jl b/test/integration/test_round_robin.jl
new file mode 100755
index 0000000..1fb085f
--- /dev/null
+++ b/test/integration/test_round_robin.jl
@@ -0,0 +1,85 @@
+include("../utils.jl")
+
+rpc_topic = "rpc_method"
+request_arg = 1
+
+function rpc_server1(rpc_method_arg)
+ return rpc_method_arg + 1
+end
+
+function rpc_server2(rpc_method_arg)
+ return rpc_method_arg + 2
+
+end
+
+function rpc_server3(rpc_method_arg)
+ return rpc_method_arg + 3
+end
+
+function run(client_url, server1_url, server2_url, server3_url)
+ client = connect(client_url)
+
+ server1 = connect(server1_url)
+ expose(server1, rpc_topic, rpc_server1)
+
+ server2 = connect(server2_url)
+ expose(server2, rpc_topic, rpc_server2)
+
+ server3 = connect(server3_url)
+ expose(server3, rpc_topic, rpc_server3)
+
+ res = rpc(client, rpc_topic, request_arg)
+ @test res == 2
+
+ res = rpc(client, rpc_topic, request_arg)
+ @info "[round_robin]: result=$res"
+ @test res == 3
+
+ res = rpc(client, rpc_topic, request_arg)
+ @info "[round_robin]: result=$res"
+ @test res == 4
+
+ close(server1)
+
+ res = rpc(client, rpc_topic, request_arg)
+ # server1 down, expect a response from server2
+ @info "[round_robin]: result=$res"
+ @test res == 3
+
+ close(server2)
+ for i in 1:2
+ res = rpc(client, rpc_topic, request_arg)
+ # server1 down, expect a response from server2
+ @info "[round_robin]: result=$res"
+ @test res == 4
+ end
+
+ close(server3)
+ try
+ res = rpc(client, rpc_topic, request_arg)
+ catch e
+ @info "[round_robin]: $e"
+ end
+
+ server1 = connect(server1_url)
+ expose(server1, rpc_topic, rpc_server1)
+
+ res = rpc(client, rpc_topic, request_arg)
+ @info "[round_robin]: result=$res"
+ @test res == 2
+
+ for cli in [client, server1]
+ close(cli)
+ end
+ sleep(0.000001)
+end
+
+ENV["BROKER_BALANCER"] = "round_robin"
+
+function run()
+ run("rr_client", "rr_server_1", "rr_server_2", "rr_server_3")
+ testsummary()
+end
+
+execute(run, "test_round_robin")
+ENV["BROKER_BALANCER"] = "first_up"
\ No newline at end of file
diff --git a/test/integration/test_wrong_balancer.jl b/test/integration/test_wrong_balancer.jl
new file mode 100644
index 0000000..835c889
--- /dev/null
+++ b/test/integration/test_wrong_balancer.jl
@@ -0,0 +1,15 @@
+include("../utils.jl")
+
+ENV["BROKER_BALANCER"] = "wrong_balancer"
+
+tmr = Timer((tmr) -> shutdown(), 5)
+try
+ Rembus.caronte(wait=true, exit_when_done=false)
+ @test false
+catch e
+ @test e.msg === "wrong balancer, must be one of first_up, less_busy, round_robin"
+finally
+ close(tmr)
+end
+
+ENV["BROKER_BALANCER"] = "first_up"
diff --git a/test/integration/test_zmq_protocol_errors.jl b/test/integration/test_zmq_protocol_errors.jl
new file mode 100644
index 0000000..edc2acb
--- /dev/null
+++ b/test/integration/test_zmq_protocol_errors.jl
@@ -0,0 +1,60 @@
+include("../utils.jl")
+
+using ZMQ
+
+struct InvalidMsg <: Rembus.RembusMsg
+ id::UInt128
+ InvalidMsg() = new(Rembus.id())
+end
+
+struct PartialMsg <: Rembus.RembusMsg
+ id::UInt128
+ PartialMsg() = new(Rembus.id())
+end
+
+function Rembus.transport_send(socket::ZMQ.Socket, msg::InvalidMsg)
+ send(socket, Message(), more=true)
+ send(socket, encode([63]), more=true)
+ send(socket, "aaaa", more=true)
+ send(socket, Rembus.MESSAGE_END, more=false)
+end
+
+function Rembus.transport_send(socket::ZMQ.Socket, msg::PartialMsg)
+ send(socket, Message(), more=true)
+ send(socket, encode([Rembus.TYPE_PUB, "mytopic"]), more=true)
+ send(socket, encode("my_value"), more=false)
+end
+
+function mymethod(arg)
+ @debug "[mymethod] arg: $arg" _group = :test
+ 1
+end
+
+function consume(arg)
+ @debug "[sub]: received $arg" _group = :test
+end
+
+function run()
+ rb = Rembus.connect("zmq://:8002/test_zmq_error")
+
+ try
+ @debug "sending a corrupted packet" _group = :test
+ Rembus.rpcreq(rb, InvalidMsg(), exceptionerror=true, timeout=2)
+ @test false
+ catch e
+ @debug "expected error: $(e.msg)" _group = :test
+ @test isa(e, Rembus.RembusTimeout)
+ end
+
+ version = Rembus.rpc(rb, "version")
+ @test version === Rembus.VERSION
+
+ Rembus.rembus_write(rb, PartialMsg())
+
+ version = Rembus.rpc(rb, "version")
+ @test version === Rembus.VERSION
+
+ close(rb)
+end
+
+execute(run, "test_zmq_error")
\ No newline at end of file
diff --git a/test/private/test_private_topic.jl b/test/private/test_private_topic.jl
new file mode 100644
index 0000000..425f527
--- /dev/null
+++ b/test/private/test_private_topic.jl
@@ -0,0 +1,79 @@
+using JSON3
+
+include("../utils.jl")
+
+mutable struct TestHolder
+ msg_received::Int
+ TestHolder() = new(0)
+end
+
+function consume(bag, data)
+ bag.msg_received += 1
+end
+
+function setup(name)
+ # add admin privilege to client with name equals to test_private
+ fn = joinpath(Rembus.CONFIG.db, "admins.json")
+ open(fn, "w") do io
+ write(io, JSON3.write(Set([name])))
+ end
+end
+
+function run(authorized_component)
+ bag = TestHolder()
+
+ priv_topic = "foo"
+ myproducer = "myproducer"
+ myconsumer = "myconsumer"
+ myunauth = "myunauth"
+
+ rb = tryconnect(authorized_component)
+
+ private_topic(rb, priv_topic)
+ authorize(rb, myproducer, priv_topic)
+ authorize(rb, myconsumer, priv_topic)
+
+ producer = connect(myproducer)
+
+ unauth_consumer = connect(myunauth)
+ consumer = connect(myconsumer)
+ for c in [unauth_consumer, consumer]
+ shared(c, bag)
+ end
+
+ try
+ subscribe(unauth_consumer, priv_topic, consume)
+ reactive(unauth_consumer)
+ catch e
+ @debug "expected error: $e" _group = :test
+ @test isa(e, Rembus.RembusError)
+ @test e.code === Rembus.STS_GENERIC_ERROR
+ end
+
+ subscribe(consumer, priv_topic, consume)
+ reactive(consumer)
+
+ publish(producer, priv_topic, "some_data")
+
+ sleep(0.2)
+ for c in [rb, producer, consumer, unauth_consumer]
+ close(c)
+ end
+ @test bag.msg_received === 1
+end
+
+authorized_component = "test_private"
+
+setup() = setup(authorized_component)
+
+execute(() -> run(authorized_component), "test_private_topic", setup=setup)
+
+#@info "[test_private_topic] start"
+#try
+# run()
+# @test msg_received === 1
+# testsummary()
+#finally
+# shutdown()
+#end
+#@info "[test_private_topic] stop"
diff --git a/test/runtests.jl b/test/runtests.jl
new file mode 100644
index 0000000..dac282b
--- /dev/null
+++ b/test/runtests.jl
@@ -0,0 +1,107 @@
+using Distributed
+addprocs(2)
+
+@everywhere using Rembus
+@everywhere using Test
+using SafeTestsets
+using Visor
+
+const GROUP = get(ENV, "GROUP", "unit")
+
+Rembus.CONFIG = Rembus.Settings()
+Rembus.CONFIG.db = "/tmp/caronte_test"
+
+mkpath(joinpath(Rembus.CONFIG.db, "apps"))
+
+@testset "Rembus" begin
+ @testset "unit" begin
+ if GROUP == "all" || GROUP == "unit"
+ @time @safetestset "twin" begin
+ include("unit/test_twin.jl")
+ end
+ @time @safetestset "cbor" begin
+ include("unit/test_cbor.jl")
+ end
+ @time @safetestset "component" begin
+ include("unit/test_component.jl")
+ end
+ @time @safetestset "signature" begin
+ include("unit/test_signature.jl")
+ end
+ @time @safetestset "balancer_round_robin" begin
+ include("unit/test_round_robin.jl")
+ end
+ @time @safetestset "balancer_less_busy" begin
+ include("unit/test_less_busy.jl")
+ end
+ end
+ if GROUP == "all" || GROUP == "private"
+ @time @safetestset "private_topic" begin
+ include("private/test_private_topic.jl")
+ end
+ end
+ if GROUP == "all" || GROUP == "embedded"
+ @time @safetestset "embedded" begin
+ include("embedded/test_embedded.jl")
+ end
+ end
+ if GROUP == "ack"
+ @time @safetestset "ws_ack" begin
+ include("ack/test_ws_ack.jl")
+ end
+ @time @safetestset "zmq_ack" begin
+ include("ack/test_zmq_ack.jl")
+ end
+ end
+ if GROUP == "all" || GROUP == "connect"
+ @time @safetestset "tls_connect" begin
+ include("connect/test_tls_connect.jl")
+ end
+ @time @safetestset "connect" begin
+ include("connect/test_connect.jl")
+ end
+ end
+ if GROUP == "all" || GROUP == "integration"
+ @time @safetestset "retroactive" begin
+ include("integration/test_retroactive.jl")
+ end
+ @time @safetestset "process_fault" begin
+ include("integration/test_process_fault.jl")
+ end
+ @time @safetestset "zmq_protocol_errors" begin
+ include("integration/test_zmq_protocol_errors.jl")
+ end
+ @time @safetestset "round_robin" begin
+ include("integration/test_round_robin.jl")
+ end
+ @time @safetestset "wrong_balancer" begin
+ include("integration/test_wrong_balancer.jl")
+ end
+ end
+ if GROUP == "all" || GROUP == "api"
+ @time @safetestset "publish_api" begin
+ include("api/test_publish.jl")
+ end
+ @time @safetestset "publish_macros" begin
+ include("api/test_publish_macros.jl")
+ end
+ @time @safetestset "request_api" begin
+ include("api/test_request.jl")
+ end
+ @time @safetestset "types" begin
+ include("api/test_types.jl")
+ end
+ @time @safetestset "zmq" begin
+ include("api/test_zmq.jl")
+ end
+ @time @safetestset "mixed" begin
+ include("api/test_mixed.jl")
+ end
+ end
+ if GROUP == "all" || GROUP == "auth"
+ @time @safetestset "register" begin
+ include("auth/test_register.jl")
+ end
+ end
+ end
+end
diff --git a/test/unit/test_cbor.jl b/test/unit/test_cbor.jl
new file mode 100644
index 0000000..028bb6b
--- /dev/null
+++ b/test/unit/test_cbor.jl
@@ -0,0 +1,22 @@
+using Rembus
+using Test
+
+# decode a simple value
+buff = UInt8[0xe1]
+@test decode(buff) == 1
+
+n = Int32(1)
+e = encode(n)
+@test e == UInt8[0x1a, 0, 0, 0, 0x1]
+
+n = Int(1)
+e = encode(n)
+@test e == UInt8[0x1b, 0, 0, 0, 0, 0, 0, 0, 0x1]
+
+n = Int(-1)
+e = encode(n)
+@test e == UInt8[0x3b, 0, 0, 0, 0, 0, 0, 0, 0]
+
+n = Int8(-1)
+e = encode(n)
+@test e == UInt8[0x20]
diff --git a/test/unit/test_component.jl b/test/unit/test_component.jl
new file mode 100644
index 0000000..17725c6
--- /dev/null
+++ b/test/unit/test_component.jl
@@ -0,0 +1,39 @@
+using Rembus
+using Test
+
+ENV["REMBUS_BASE_URL"] = "tcp://caronte.org:8001"
+
+c = Rembus.Component("myc")
+@test c.port == 8001
+@test c.host == "caronte.org"
+@test c.protocol == :tcp
+@test Rembus.brokerurl(c) == "tcp://caronte.org:8001"
+
+c = Rembus.Component("zmq://caronte.org:8002/myc")
+@test c.id == "myc"
+@test c.protocol == :zmq
+@test c.host == "caronte.org"
+@test Rembus.brokerurl(c) == "tcp://caronte.org:8002"
+
+c = Rembus.Component("zmq://:8002/myc")
+@test c.id == "myc"
+@test c.protocol == :zmq
+@test c.host == "caronte.org"
+@test Rembus.brokerurl(c) == "tcp://caronte.org:8002"
+
+delete!(ENV, "REMBUS_BASE_URL")
+
+c = Rembus.Component("myc")
+@test c.port == 8000
+@test c.host == "127.0.0.1"
+@test c.protocol == :ws
+@test Rembus.brokerurl(c) == "ws://127.0.0.1:8000"
+
+c = Rembus.Component("ws://127.0.0.1:8000")
+@test c.id == "rembus"
+@test c.port == 8000
+@test c.host == "127.0.0.1"
+@test c.protocol == :ws
+@test Rembus.brokerurl(c) == "ws://127.0.0.1:8000"
+
+@test_throws ErrorException("wrong url xyz://host: unknown protocol xyz") Rembus.Component("xyz://host")
\ No newline at end of file
diff --git a/test/unit/test_less_busy.jl b/test/unit/test_less_busy.jl
new file mode 100644
index 0000000..6d17a7b
--- /dev/null
+++ b/test/unit/test_less_busy.jl
@@ -0,0 +1,44 @@
+using Rembus
+using Test
+
+ts = 10000;
+msgid = 1
+topic = "mytopic"
+
+struct FakeSocket
+end
+
+sentdata(t) = Rembus.SentData(
+ ts, Rembus.Msg(Rembus.TYPE_PUB, Rembus.PubSubMsg(topic), t))
+
+Base.isopen(socket::FakeSocket) = true
+
+router = Rembus.Router()
+
+twin1 = Rembus.Twin(router, "twin1", Channel())
+twin2 = Rembus.Twin(router, "twin2", Channel())
+
+twin1.sock = FakeSocket()
+twin2.sock = FakeSocket()
+
+implementors = [twin1, twin2]
+
+target = Rembus.less_busy(router, topic, implementors)
+@info "target: $target"
+@test target === twin1
+
+twin1.sent[msgid] = sentdata(twin1)
+
+target = Rembus.less_busy(router, topic, implementors)
+
+@info "target: $target"
+@test target === twin2
+
+twin2.sent[msgid] = sentdata(twin2)
+
+twin2.sent[msgid+1] = sentdata(twin2)
+
+target = Rembus.less_busy(router, topic, implementors)
+
+@info "target: $target"
+@test target === twin1
diff --git a/test/unit/test_round_robin.jl b/test/unit/test_round_robin.jl
new file mode 100644
index 0000000..5f4b2e1
--- /dev/null
+++ b/test/unit/test_round_robin.jl
@@ -0,0 +1,74 @@
+using Rembus
+using Test
+
+ts = 10000;
+msgid = 1
+topic = "mytopic"
+
+struct FakeSocket
+end
+
+sentdata(t) = Rembus.SentData(
+ ts, Rembus.Msg(Rembus.TYPE_PUB, Rembus.PubSubMsg(topic), t))
+
+Base.isopen(socket::FakeSocket) = true
+
+router = Rembus.Router()
+
+twin1 = Rembus.Twin(router, "twin1", Channel())
+twin2 = Rembus.Twin(router, "twin2", Channel())
+twin3 = Rembus.Twin(router, "twin3", Channel())
+
+twin1.sock = FakeSocket()
+twin2.sock = FakeSocket()
+
+implementors = [twin1, twin2, twin3]
+@debug "implementors: $implementors"
+
+target = Rembus.round_robin(router, topic, implementors)
+@debug "1. target: $target"
+@test target === twin1
+
+target = Rembus.round_robin(router, topic, implementors)
+@debug "2. target: $target"
+@test target === twin2
+
+target = Rembus.round_robin(router, topic, implementors)
+@debug "3. target: $target"
+@test target === twin1
+
+target = Rembus.round_robin(router, topic, implementors)
+@debug "4. target: $target"
+@test target === twin2
+
+twin3.sock = FakeSocket()
+target = Rembus.round_robin(router, topic, implementors)
+@debug "5. target: $target"
+@test target === twin3
+
+implementors = [twin2, twin3]
+target = Rembus.round_robin(router, topic, implementors)
+@debug "6. target: $target"
+@test target === twin2
+
+target = Rembus.round_robin(router, topic, implementors)
+@debug "7. target: $target"
+@test target === twin3
+
+implementors = [twin3]
+target = Rembus.round_robin(router, topic, implementors)
+@debug "8. target: $target"
+@test target === twin3
+
+implementors = []
+target = Rembus.round_robin(router, topic, implementors)
+@debug "9. target: $target"
+@test target === nothing
+
+twin1.sock = nothing
+twin2.sock = nothing
+implementors = [twin1, twin2]
+target = Rembus.round_robin(router, topic, implementors)
+@debug "10. target: $target"
+@test target === nothing
+
diff --git a/test/unit/test_signature.jl b/test/unit/test_signature.jl
new file mode 100644
index 0000000..91ed292
--- /dev/null
+++ b/test/unit/test_signature.jl
@@ -0,0 +1,76 @@
+using Rembus
+using Test
+
+struct FakeTwin
+ challenge::Vector{UInt8}
+ session::Dict
+ FakeTwin(dare) = new(dare, Dict("challenge" => dare))
+end
+
+function verify(cid, wrong_challenge=nothing)
+ rb = Rembus.RBConnection(cid)
+
+ challenge = [0x1, 0x2, 0x3, 0x4]
+ Response = Rembus.ResMsg(1, Rembus.STS_SUCCESS, challenge)
+
+ att = Rembus.attestate(rb, Response)
+
+ if wrong_challenge === nothing
+ server_challenge = challenge
+ else
+ server_challenge = wrong_challenge
+ end
+ Rembus.verify_signature(FakeTwin(server_challenge), att)
+end
+
+#cid = "test_cid"
+cid = "plain_test"
+secret = "pippo"
+
+client_fn = Rembus.pkfile(cid)
+server_fn = joinpath(Rembus.CONFIG.db, "apps", cid)
+@info "secret file: $client_fn"
+
+# create client and server files
+for fn in [client_fn, server_fn]
+ open(fn, "w") do f
+ write(f, secret)
+ end
+end
+
+isvalid = verify(cid)
+@test isvalid
+
+## challenge = [0x1, 0x2, 0x3, 0x4]
+## Response = Rembus.ResMsg(1, Rembus.STS_SUCCESS, challenge)
+##
+## att = Rembus.attestate(rb, Response)
+## @info att
+## isvalid = Rembus.verify_signature(FakeTwin(challenge), att)
+## @info "isvalid: $isvalid"
+## @test isvalid
+
+try
+ verify(cid, [0x01])
+ @test false
+catch e
+ @test e.msg == "authentication failed"
+end
+
+cid = "private_test"
+
+# create private secret
+client_fn = Rembus.pkfile(cid)
+pubkey = Rembus.create_private_key(cid)
+mv("$(client_fn).tmp", client_fn, force=true)
+
+# create public secret
+server_fn = joinpath(Rembus.CONFIG.db, "apps", cid)
+open(server_fn, "w") do f
+ write(f, pubkey)
+end
+
+isvalid = verify(cid)
+@test isvalid
+#pubf = Rembus.pubkey_file(cid)
+#@info "pubfile: $pubf"
diff --git a/test/unit/test_twin.jl b/test/unit/test_twin.jl
new file mode 100644
index 0000000..068b670
--- /dev/null
+++ b/test/unit/test_twin.jl
@@ -0,0 +1,27 @@
+using Rembus
+using Test
+
+function task(pd, router)
+ router.process = pd
+ for msg in pd.inbox
+ @isshutdown(msg)
+ end
+end
+
+identity = UInt8[0, 1, 2, 3, 4]
+router = Rembus.Router()
+proc = process("router", task, args=(router,))
+
+twin = Rembus.Twin(router, "twin", Channel())
+#startup(process(Rembus.twin_task, args=(twin,)))
+supervise(process(Rembus.twin_task, args=(twin,)), wait=false)
+
+router.address2twin[identity] = twin
+router.topic_impls["topic"] = Set([twin])
+
+yield()
+Rembus.destroy_twin(twin, router)
+
+@test isempty(router.topic_impls)
+@test !haskey(router.address2twin, identity)
+
diff --git a/test/utils.jl b/test/utils.jl
new file mode 100755
index 0000000..fed8dae
--- /dev/null
+++ b/test/utils.jl
@@ -0,0 +1,97 @@
+using HTTP
+using Logging
+using Rembus
+using Visor
+using Test
+
+results = []
+
+macro start_caronte(init, args)
+ quote
+ running = get(ENV, "CARONTE_RUNNING", "0") !== "0"
+ if !running
+ caronte_reset()
+ fn = $(esc(init))
+ if fn !== nothing
+ fn()
+ end
+
+ Rembus.caronte(wait=false, exit_when_done=false, args=$(esc(args)))
+ end
+ end
+end
+
+macro atest(expr, descr=nothing)
+ if descr === nothing
+ descr = string(expr)
+ end
+ :(push!(results, $(esc(descr)) => $(esc(expr))))
+end
+
+function execute_caronte_process(fn, testname; setup=nothing)
+ running = get(ENV, "CARONTE_RUNNING", "0") !== "0"
+
+ if !running
+ p = Base.run(Cmd(`$(@__DIR__)/../bin/caronte`, detach=true), wait=false)
+ end
+ sleep(10)
+
+ Rembus.logging(debug=[:test])
+ @info "[$testname] start"
+ try
+ fn()
+ finally
+ shutdown()
+ sleep(3)
+ if !running
+ Base.kill(p, Base.SIGINT)
+ end
+ sleep(3)
+ end
+ @info "[$testname] stop"
+end
+
+function execute(fn, testname; setup=nothing, args=Dict())
+ @start_caronte setup args
+ sleep(0.5)
+ Rembus.logging(debug=[:test])
+ @info "[$testname] start"
+ try
+ fn()
+ finally
+ shutdown()
+ end
+ @info "[$testname] stop"
+end
+
+function testsummary()
+ global results
+ for (descr, t) in results
+ @debug "$descr: $(t ? "pass" : "fail")"
+ @test t
+ end
+ empty!(results)
+end
+
+function tryconnect(id)
+ maxretries = 10
+ count = 0
+ while true
+ try
+ return connect(id)
+ catch e
+ if !isa(e, HTTP.Exceptions.ConnectError)
+ @warn "error: $e"
+ end
+ count === maxretries && rethrow(e)
+ count += 1
+ end
+ sleep(1)
+ end
+end
+
+function caronte_reset()
+ Rembus.CONFIG = Rembus.Settings()
+ foreach(rm, readdir(Rembus.twindir(), join=true))
+ foreach(rm, filter(isfile, readdir(Rembus.CONFIG.db, join=true)))
+end