commit d16d7a128f7375c17680242e46127a5468a0180e Author: Danny Morabito Date: Sun Oct 12 13:10:06 2025 -0500 initial version (alpha) diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..8331507 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,21 @@ +#!/bin/sh + +clear + +echo "Running Biome format..." +bunx @biomejs/biome format --write + +if [ $? -ne 0 ]; then + echo "❌ Biome format failed. Commit rejected." + exit 1 +fi + +echo "Running Biome check on staged files..." +bunx @biomejs/biome check --staged --error-on-warnings --no-errors-on-unmatched + +if [ $? -ne 0 ]; then + exit 1 +fi + +echo "✅ All checks passed. Proceeding with commit." +exit 0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..8cbcf3c --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,7 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "format_on_save": "on" +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..28a020b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,650 @@ +# GNU Affero General Public License + +_Version 3, 19 November 2007_ +_Copyright © 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/README.md b/README.md new file mode 100644 index 0000000..b112d8a --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# 🕊️ Eve Lite + +> A lightweight, encrypted Nostr relay that puts your community's privacy first + +Eve Lite is your personal gateway to secure, decentralized communities It creates encrypted "Closed Community Networks" (CCNs) where your messages stay truly private, from everyone. + +## ✨ What makes Eve Lite special? + +**Privacy by Design**: Unlike traditional Nostr relays where your messages are visible to everyone, Eve Lite encrypts everything. If someone gains access to the data, they'll only see encrypted gibberish. + +**Extensible with Arxlets**: Think of Arxlets as mini-apps that run within your CCN. Want custom functionality? Write an Arxlet. Need to share functionality? Publish it for others to use. + +**Dead Simple Setup**: No complex configurations or database management. Eve Lite handles the heavy lifting so you can focus on communicating. + +**Invite-Only Networks**: Create closed communities with your friends, family, or team. Share invite codes to bring people into your private space. + +## 🏗️ How it works + +### Closed Community Networks (CCNs) + +A CCN is like your own private island in the Nostr ocean. Each CCN has: + +- **Unique encryption keys** - Messages are encrypted with network-specific keys +- **Local relay instance** - Each member's own strfry relay runs locally on port 6942 +- **Persistent storage** - All messages are stored locally on each member's device and encrypted + +### The Magic Behind the Scenes + +1. **Encrypted Message Flow**: When you send a message, Eve Lite encrypts it before sending to remote relays +2. **Smart Decryption**: Incoming encrypted messages are automatically decrypted and stored locally +3. **Dual Relay System**: Remote encrypted storage + local decrypted access = best of both worlds +4. **Event Deduplication**: Smart caching prevents processing the same event twice + +### Arxlets: Your Extensibility Layer + +Arxlets are JavaScript-based apps that add functionality to your CCN: + +- Stored as Nostr events (kind 30420) +- Compressed with Brotli for efficiency +- Can be shared across networks +- Perfect for custom UI components, utilities, or integrations + +## 🔧 API Reference + +Eve Lite exposes a RESTful API for building applications: + +### CCN Management + +- `GET /api/ccns` - List all CCNs +- `POST /api/ccns/new` - Create a new CCN +- `POST /api/ccns/join` - Join an existing CCN +- `GET /api/ccns/active` - Get the active CCN +- `POST /api/ccns/active` - Switch active CCN + +### Events & Messages + +- `POST /api/events` - Query events with filters +- `PUT /api/events` - Publish a new event +- `GET /api/events/:id` - Get specific event +- `POST /api/sign` - Sign an event with your key + +### Profiles & Identity + +- `GET /api/avatars/:pubkey` - Get user avatar +- `GET /api/profile/:pubkey` - Get user profile +- `GET /api/pubkey` - Get your public key + +### Arxlets + +- `GET /api/arxlets` - List installed Arxlets +- `GET /api/arxlets/:id` - Get specific Arxlet +- `GET /api/arxlets-available` - Browse available Arxlets + +## 🛠️ Development + +### Running Tests + +```bash +bun test +bun test --watch # Watch mode +``` + +### Code Quality + +This project uses Biome for linting and formatting: + +```bash +bunx @biomejs/biome check +bunx @biomejs/biome format --write +``` + +## 🤝 Contributing + +Found a bug? Have a feature idea? Contributions are welcome! + +1. Fork the repository +2. Create a feature branch: `git checkout -b my-cool-feature` +3. Make your changes and add tests +4. Submit a pull request + +## 📝 License + +AGPLv3. See LICENSE.md for details. + +## 🙋‍♀️ Questions? + +- Check out the [Arxlet documentation](/docs/arxlets) for extending functionality +- Browse the source code - it's well-documented and approachable +- Open an issue if you find bugs or have suggestions + +--- + +_Built with ❤️ for privacy-conscious humans who believe communication should be truly private._ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..2e3b818 Binary files /dev/null and b/assets/logo.png differ diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d208547 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.2/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**", "!src/pages/home/home.css", "!src/pages/**/highlight"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "formatWithErrors": true, + "lineWidth": 120, + "lineEnding": "lf" + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + }, + "a11y": { + "noSvgWithoutTitle": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f48d9d4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,164 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "eve-lite", + "dependencies": { + "@dicebear/collection": "^9.2.4", + "@dicebear/core": "^9.2.4", + "@noble/ciphers": "^1.3.0", + "@scure/base": "^2.0.0", + "bun-plugin-tailwind": "^0.0.15", + "daisyui": "^5.1.10", + "nostr-tools": "^2.16.2", + }, + "devDependencies": { + "@biomejs/biome": "^2.2.4", + "@types/bun": "^1.2.21", + "preact": "^10.27.1", + "prism-svelte": "^0.5.0", + "prismjs": "^1.30.0", + }, + "peerDependencies": { + "typescript": "^5.9.2", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="], + + "@dicebear/adventurer": ["@dicebear/adventurer@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA=="], + + "@dicebear/adventurer-neutral": ["@dicebear/adventurer-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg=="], + + "@dicebear/avataaars": ["@dicebear/avataaars@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A=="], + + "@dicebear/avataaars-neutral": ["@dicebear/avataaars-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA=="], + + "@dicebear/big-ears": ["@dicebear/big-ears@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ=="], + + "@dicebear/big-ears-neutral": ["@dicebear/big-ears-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw=="], + + "@dicebear/big-smile": ["@dicebear/big-smile@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ=="], + + "@dicebear/bottts": ["@dicebear/bottts@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw=="], + + "@dicebear/bottts-neutral": ["@dicebear/bottts-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA=="], + + "@dicebear/collection": ["@dicebear/collection@9.2.4", "", { "dependencies": { "@dicebear/adventurer": "9.2.4", "@dicebear/adventurer-neutral": "9.2.4", "@dicebear/avataaars": "9.2.4", "@dicebear/avataaars-neutral": "9.2.4", "@dicebear/big-ears": "9.2.4", "@dicebear/big-ears-neutral": "9.2.4", "@dicebear/big-smile": "9.2.4", "@dicebear/bottts": "9.2.4", "@dicebear/bottts-neutral": "9.2.4", "@dicebear/croodles": "9.2.4", "@dicebear/croodles-neutral": "9.2.4", "@dicebear/dylan": "9.2.4", "@dicebear/fun-emoji": "9.2.4", "@dicebear/glass": "9.2.4", "@dicebear/icons": "9.2.4", "@dicebear/identicon": "9.2.4", "@dicebear/initials": "9.2.4", "@dicebear/lorelei": "9.2.4", "@dicebear/lorelei-neutral": "9.2.4", "@dicebear/micah": "9.2.4", "@dicebear/miniavs": "9.2.4", "@dicebear/notionists": "9.2.4", "@dicebear/notionists-neutral": "9.2.4", "@dicebear/open-peeps": "9.2.4", "@dicebear/personas": "9.2.4", "@dicebear/pixel-art": "9.2.4", "@dicebear/pixel-art-neutral": "9.2.4", "@dicebear/rings": "9.2.4", "@dicebear/shapes": "9.2.4", "@dicebear/thumbs": "9.2.4" }, "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA=="], + + "@dicebear/core": ["@dicebear/core@9.2.4", "", { "dependencies": { "@types/json-schema": "^7.0.11" } }, "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w=="], + + "@dicebear/croodles": ["@dicebear/croodles@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A=="], + + "@dicebear/croodles-neutral": ["@dicebear/croodles-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw=="], + + "@dicebear/dylan": ["@dicebear/dylan@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg=="], + + "@dicebear/fun-emoji": ["@dicebear/fun-emoji@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg=="], + + "@dicebear/glass": ["@dicebear/glass@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw=="], + + "@dicebear/icons": ["@dicebear/icons@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A=="], + + "@dicebear/identicon": ["@dicebear/identicon@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg=="], + + "@dicebear/initials": ["@dicebear/initials@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg=="], + + "@dicebear/lorelei": ["@dicebear/lorelei@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg=="], + + "@dicebear/lorelei-neutral": ["@dicebear/lorelei-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ=="], + + "@dicebear/micah": ["@dicebear/micah@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g=="], + + "@dicebear/miniavs": ["@dicebear/miniavs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q=="], + + "@dicebear/notionists": ["@dicebear/notionists@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw=="], + + "@dicebear/notionists-neutral": ["@dicebear/notionists-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ=="], + + "@dicebear/open-peeps": ["@dicebear/open-peeps@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ=="], + + "@dicebear/personas": ["@dicebear/personas@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA=="], + + "@dicebear/pixel-art": ["@dicebear/pixel-art@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ=="], + + "@dicebear/pixel-art-neutral": ["@dicebear/pixel-art-neutral@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A=="], + + "@dicebear/rings": ["@dicebear/rings@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA=="], + + "@dicebear/shapes": ["@dicebear/shapes@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA=="], + + "@dicebear/thumbs": ["@dicebear/thumbs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], + + "@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="], + + "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "@scure/bip32": ["@scure/bip32@1.3.1", "", { "dependencies": { "@noble/curves": "~1.1.0", "@noble/hashes": "~1.3.1", "@scure/base": "~1.1.0" } }, "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A=="], + + "@scure/bip39": ["@scure/bip39@1.2.1", "", { "dependencies": { "@noble/hashes": "~1.3.0", "@scure/base": "~1.1.0" } }, "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg=="], + + "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.3.2", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-6L8PkB+m1SSb2kaGGFk3iXENxl8lrs7cyVl7AXH6pgdMfulDfM6yUrVdjtxdnGrLrGzzuav8fFnZMY+rcscqcA=="], + + "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.15", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-qtAXMNGG4R0UGGI8zWrqm2B7BdXqx48vunJXBPzfDOHPA5WkRUZdTSbE7TFwO4jLhYqSE23YMWsM9NhE6ovobw=="], + + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "daisyui": ["daisyui@5.1.25", "", {}, "sha512-LYOGVIzTCCucEFkKmdj0fxbHHPZ83fpkYD7jXYF3/7UwrUu68TtXkIdGtEXadzeqUT361hCe6cj5tBB/7mvszw=="], + + "nostr-tools": ["nostr-tools@2.17.0", "", { "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", "@scure/bip39": "1.2.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-lrvHM7cSaGhz7F0YuBvgHMoU2s8/KuThihDoOYk8w5gpVHTy0DeUCAgCN8uLGeuSl5MAWekJr9Dkfo5HClqO9w=="], + + "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], + + "preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="], + + "prism-svelte": ["prism-svelte@0.5.0", "", {}, "sha512-db91Bf3pRGKDPz1lAqLFSJXeW13mulUJxhycysFpfXV5MIK7RgWWK2E5aPAa71s8TCzQUXxF5JOV42/iOs6QkA=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + + "@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], + + "@scure/bip32/@noble/curves": ["@noble/curves@1.1.0", "", { "dependencies": { "@noble/hashes": "1.3.1" } }, "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA=="], + + "@scure/bip32/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "@scure/bip39/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + + "nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], + + "nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..de249f9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[serve.static] +plugins = ["bun-plugin-tailwind"] diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..9f8e863 --- /dev/null +++ b/index.ts @@ -0,0 +1,418 @@ +import { adventurerNeutral } from "@dicebear/collection"; +import { createAvatar } from "@dicebear/core"; +import { $ } from "bun"; +import { finalizeEvent, getPublicKey, type NostrEvent } from "nostr-tools"; +import type { SubCloser } from "nostr-tools/abstract-pool"; +import { fetchRemoteArxlets } from "./src/arxlets"; +import { CCN } from "./src/ccns"; +import arxletDocs from "./src/pages/docs/arxlets/arxlet-docs.html"; +import homePage from "./src/pages/home/home.html"; +import { getColorFromPubkey } from "./src/utils/color"; +import { decryptEvent } from "./src/utils/encryption"; +import { loadSeenEvents, saveSeenEvent } from "./src/utils/files"; +import { + queryRemoteEvent, + queryRemoteRelays, + sendUnencryptedEventToLocalRelay, +} from "./src/utils/general"; +import { DEFAULT_PERIOD_MINUTES, RollingIndex } from "./src/rollingIndex"; + +let currentActiveSub: SubCloser | undefined; +let currentSubInterval: ReturnType | undefined; + +async function restartCCN() { + currentActiveSub?.close(); + let ccn = await CCN.getActive(); + if (!ccn) { + const allCCNs = await CCN.list(); + if (allCCNs.length > 0) { + await allCCNs[0]!.setActive(); + ccn = allCCNs[0]!; + } else return; + } + + async function handleNewEvent(original: NostrEvent) { + if (!ccn) return process.exit(1); + const seenEvents = await loadSeenEvents(); + if (seenEvents.includes(original.id)) return; + await saveSeenEvent(original); + const keyAtTime = ccn.getPrivateKeyAt( + RollingIndex.at(original.created_at * 1000), + ); + const decrypted = await decryptEvent(original, keyAtTime); + if (seenEvents.includes(decrypted.id)) return; + await saveSeenEvent(decrypted); + await sendUnencryptedEventToLocalRelay(decrypted); + } + + await $`killall -9 strfry`.nothrow().quiet(); + await ccn.writeStrfryConfig(); + const strfry = Bun.spawn([ + "strfry", + "--config", + ccn.strfryConfigPath, + "relay", + ]); + process.on("exit", () => strfry.kill()); + const allKeysForCCN = ccn.allPubkeys; + function resetActiveSub() { + console.log(`Setting new subscription for ${allKeysForCCN.join(", ")}`); + currentActiveSub?.close(); + currentActiveSub = queryRemoteRelays( + { kinds: [1060], "#p": allKeysForCCN }, + handleNewEvent, + ); + } + resetActiveSub(); + currentSubInterval = setInterval( + () => { + resetActiveSub(); + }, + DEFAULT_PERIOD_MINUTES * 60 * 1000, + ); +} + +restartCCN(); + +class CorsResponse extends Response { + constructor(body?: BodyInit, init?: ResponseInit) { + super(body, init); + this.headers.set("Access-Control-Allow-Origin", "*"); + this.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT"); + this.headers.set("Access-Control-Allow-Headers", "Content-Type"); + } + + static override json(json: unknown, init?: ResponseInit) { + const res = Response.json(json, init); + res.headers.set("Access-Control-Allow-Origin", "*"); + res.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT"); + res.headers.set("Access-Control-Allow-Headers", "Content-Type"); + return res; + } +} + +const invalidRequest = CorsResponse.json( + { error: "Invalid Request" }, + { status: 400 }, +); + +const httpServer = Bun.serve({ + routes: { + "/": homePage, + "/docs/arxlets": arxletDocs, + "/api/ccns": { + GET: async () => { + const ccns = await CCN.list(); + return CorsResponse.json(ccns.map((x) => x.toPublicJson())); + }, + }, + "/api/ccns/active": { + GET: async () => { + const ccn = await CCN.getActive(); + if (!ccn) + return CorsResponse.json({ error: "Not found" }, { status: 404 }); + return CorsResponse.json(ccn.toPublicJson()); + }, + POST: async (req) => { + if (!req.body) return invalidRequest; + const body = await req.body.json(); + if (!body.pubkey) return invalidRequest; + const ccn = await CCN.fromPublicKey(body.pubkey); + if (!ccn) return invalidRequest; + await ccn.setActive(); + restartCCN(); + return CorsResponse.json(ccn.toPublicJson()); + }, + }, + "/api/ccns/active/invite": { + GET: async () => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const invite = await ccn.generateInvite(); + return CorsResponse.json({ + invite, + }); + }, + }, + "/api/ccns/new": { + POST: async (req) => { + if (!req.body) return invalidRequest; + const body = await req.body.json(); + if (!body.name || !body.description) return invalidRequest; + const ccn = await CCN.create(body.name, body.description); + const activeCCN = await CCN.getActive(); + if (!activeCCN) { + await ccn.setActive(); + restartCCN(); + } + return CorsResponse.json(ccn.toPublicJson()); + }, + }, + "/api/ccns/join": { + POST: async (req) => { + if (!req.body) return invalidRequest; + const body = await req.body.json(); + if (!body.name || !body.description || !body.key) return invalidRequest; + const version = body.version ? body.version : 1; + const startIndex = body.startIndex + ? RollingIndex.fromHex(body.startIndex) + : RollingIndex.get(); + const ccn = await CCN.join( + version, + startIndex, + body.name, + body.description, + new Uint8Array(body.key), + ); + return CorsResponse.json(ccn.toPublicJson()); + }, + }, + "/api/ccns/:pubkey": async (req) => { + const ccns = await CCN.list(); + const ccnWithPubkey = ccns.find((x) => x.publicKey === req.params.pubkey); + if (!ccnWithPubkey) + return CorsResponse.json({ error: "Not Found" }, { status: 404 }); + return CorsResponse.json(ccnWithPubkey.toPublicJson()); + }, + "/api/ccns/icon/:pubkey": async (req) => { + const pubkey = req.params.pubkey; + if (!pubkey) return invalidRequest; + const ccn = await CCN.fromPublicKey(pubkey); + if (!ccn) return invalidRequest; + const avatar = ccn.getCommunityIcon(); + return new CorsResponse(avatar, { + headers: { "Content-Type": "image/svg+xml" }, + }); + }, + "/api/ccns/name/:pubkey": async (req) => { + const pubkey = req.params.pubkey; + if (!pubkey) return invalidRequest; + const ccn = await CCN.fromPublicKey(pubkey); + if (!ccn) return invalidRequest; + const profile = await ccn.getProfile(); + return new CorsResponse(profile.name || ccn.name); + }, + "/api/ccns/avatar/:pubkey": async (req) => { + const pubkey = req.params.pubkey; + if (!pubkey) return invalidRequest; + const ccn = await CCN.fromPublicKey(pubkey); + if (!ccn) return invalidRequest; + const profile = await ccn.getProfile(); + if (profile.picture) return CorsResponse.redirect(profile.picture); + const avatar = ccn.getCommunityIcon(); + return new CorsResponse(avatar, { + headers: { "Content-Type": "image/svg+xml" }, + }); + }, + "/api/profile/:pubkey": async (req) => { + const pubkey = req.params.pubkey; + if (!pubkey) return invalidRequest; + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + + const profileEvent = await ccn.getFirstEvent({ + kinds: [0], + authors: [pubkey], + }); + try { + if (!profileEvent) throw "No profile"; + return new CorsResponse(profileEvent.content, { + headers: { "Content-Type": "text/json" }, + }); + } catch { + return CorsResponse.json( + { error: "profile not found" }, + { headers: { "Content-Type": "text/json" }, status: 404 }, + ); + } + }, + "/api/avatars/:pubkey": async (req) => { + const pubkey = req.params.pubkey; + + if (!pubkey) return invalidRequest; + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const profileEvent = await ccn.getFirstEvent({ + kinds: [0], + authors: [pubkey], + }); + try { + if (!profileEvent) throw "No profile"; + const content = JSON.parse(profileEvent.content); + if (!content.picture) throw "No picture"; + return CorsResponse.redirect(content.picture); + } catch { + const avatar = createAvatar(adventurerNeutral, { + seed: pubkey, + backgroundColor: [getColorFromPubkey(pubkey)], + }); + return new CorsResponse(avatar.toString(), { + headers: { "Content-Type": "image/svg+xml" }, + }); + } + }, + "/api/events": { + POST: async (req) => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + if (!req.body) return invalidRequest; + const { search, ...query } = await req.body.json(); + let events = await ccn.getEvents(query); + if (search) + events = events.filter( + (e) => + e.content.includes(search) || + e.tags.some((t) => t[1]?.includes(search)), + ); + return CorsResponse.json(events); + }, + PUT: async (req) => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + if (!req.body) return invalidRequest; + const event = await req.body.json(); + try { + await ccn.publish(event); + return CorsResponse.json({ + success: true, + message: "Event published successfully", + }); + } catch { + return CorsResponse.json({ + success: false, + message: "Failed to publish event", + }); + } + }, + }, + "/api/events/:id": async (req) => { + const id = req.params.id; + if (!id) return invalidRequest; + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const event = await ccn.getFirstEvent({ ids: [id] }); + if (!event) + return CorsResponse.json({ error: "Event Not Found" }, { status: 404 }); + return CorsResponse.json(event); + }, + "/api/sign": { + POST: async (req) => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const userKey = await ccn.getUserKey(); + if (!req.body) return invalidRequest; + const event = await req.body.json(); + const signedEvent = finalizeEvent(event, userKey); + return CorsResponse.json(signedEvent); + }, + }, + "/api/pubkey": async () => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const userKey = await ccn.getUserKey(); + return CorsResponse.json({ pubkey: getPublicKey(userKey) }); + }, + "/api/arxlets": async () => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const arxlets = await ccn.getArxlets(); + return CorsResponse.json(arxlets); + }, + "/api/arxlets/:id": async (req) => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const arxlet = await ccn.getArxletById(req.params.id); + if (!arxlet) + return CorsResponse.json( + { error: "Arxlet not found" }, + { status: 404 }, + ); + return CorsResponse.json(arxlet); + }, + "/api/arxlets-available": async () => { + const remoteArxlets = await fetchRemoteArxlets(); + return CorsResponse.json(remoteArxlets); + }, + "/api/clone-remote-event/:id": async (req) => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const remoteEvent = await queryRemoteEvent(req.params.id); + if (!remoteEvent) + return CorsResponse.json({ error: "Event not found" }, { status: 404 }); + await ccn.publish(remoteEvent); + return CorsResponse.json(remoteEvent); + }, + "/api/reputation/:user": async (req) => { + const ccn = await CCN.getActive(); + if (!ccn) return invalidRequest; + const reputation = await ccn.getReputation(req.params.user); + return CorsResponse.json({ reputation }); + }, + "/systemapi/timezone": { + GET: async () => { + const timezone = ( + await $`timedatectl show --property=Timezone --value`.text() + ).trim(); + return CorsResponse.json({ timezone }); + }, + POST: async (req) => { + if (!req.body) return invalidRequest; + const { timezone } = await req.body.json(); + await $`sudo timedatectl set-timezone ${timezone}`; // this is fine, bun escapes it + return CorsResponse.json({ timezone }); + }, + }, + "/systemapi/wifi": { + GET: async () => { + const nmcliLines = ( + await $`nmcli -f ssid,bssid,mode,freq,chan,rate,signal,security,active -t dev wifi`.text() + ) + .trim() + .split("\n") + .map((l) => l.split(/(? ({ + ssid, + bssid: bssid!.replace(/\\:/g, ":"), + mode, + freq: parseInt(freq!, 10), + chan: parseInt(chan!, 10), + rate, + signal: parseInt(signal!, 10), + security, + active: active === "yes", + }), + ) + .filter((network) => network.ssid !== "") // filter out hidden networks + .filter((network) => network.security !== "") // filter out open networks + .sort((a, b) => (a.active ? -1 : b.active ? 1 : b.signal - a.signal)); + + return CorsResponse.json(wifi); + }, + POST: async (req) => { + if (!req.body) return invalidRequest; + const { ssid, password } = await req.body.json(); + await $`nmcli device wifi connect ${ssid} password ${password}`; // this is fine, bun escapes it + return CorsResponse.json({ ssid }); + }, + }, + }, + fetch() { + return new CorsResponse("Eve Lite v0.0.1"); + }, + port: 4269, +}); + +console.log(`Listening on ${httpServer.url.host}`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..daf2f4f --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "eve-lite", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "configure-hooks": "git config core.hooksPath .githooks", + "test": "bun test", + "test:watch": "bun test --watch" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.4", + "@types/bun": "^1.2.23", + "preact": "^10.27.2", + "prism-svelte": "^0.5.0", + "prismjs": "^1.30.0" + }, + "peerDependencies": { + "typescript": "^5.9.2" + }, + "dependencies": { + "@dicebear/collection": "^9.2.4", + "@dicebear/core": "^9.2.4", + "@noble/ciphers": "^1.3.0", + "@scure/base": "^2.0.0", + "bun-plugin-tailwind": "^0.0.15", + "daisyui": "^5.1.25", + "nostr-tools": "^2.17.0" + } +} diff --git a/src/arxlets.ts b/src/arxlets.ts new file mode 100644 index 0000000..18a259f --- /dev/null +++ b/src/arxlets.ts @@ -0,0 +1,59 @@ +import { brotliDecompressSync } from "node:zlib"; +import type { NostrEvent } from "nostr-tools"; +import { hexToBytes } from "nostr-tools/utils"; +import { CCN } from "./ccns"; +import { queryRemoteRelaysSync } from "./utils/general"; + +export interface Arxlet { + id: string; + name: string; + description?: string; + icon?: string; + iconColor?: string; + script: string; + version?: string; + eventId: string; +} + +export const parseArxletFromEvent = (ccn: CCN) => (event: NostrEvent) => { + if ( + [ + "d45d6ee247d6a323b2e081fdd5dd91c015b34284f3fef857afb28ce508844137", // old version of howl used during dev, now gets signed with different key + ].includes(event.id) + ) + return; // filter out known bad arxlets + if (event.pubkey === ccn.publicKey) return; + if (event.kind !== 30420) return; + if (!event.tags.some((t) => t[0] === "d")) return; + if (!event.tags.some((t) => t[0] === "name")) return; + if (!event.tags.some((t) => t[0] === "script")) return; + if (event.tags.some((t) => t[0] === "disabled" && t[1] === "true")) return; + return { + id: event.tags.find((t) => t[0] === "d")![1]!, + author: event.pubkey, + name: event.tags.find((t) => t[0] === "name")![1]!, + description: event.tags.find((t) => t[0] === "description")?.[1], + script: brotliDecompressSync( + hexToBytes(event.tags.find((t) => t[0] === "script")![1]!), + ).toString(), + icon: event.tags.find((t) => t[0] === "icon")?.[1], + iconColor: event.tags.find((t) => t[0] === "icon")?.[2], + iconForegroundColor: event.tags.find((t) => t[0] === "icon")?.[3], + iconStrokeColor: event.tags.find((t) => t[0] === "icon")?.[4], + version: event.tags.find((t) => t[0] === "version")?.[1], + versionDate: event.created_at, + eventId: event.id, + }; +}; + +export async function fetchRemoteArxlets() { + const activeCCN = await CCN.getActive(); + if (!activeCCN) throw new Error("No active CCN found"); + const events = await queryRemoteRelaysSync({ + kinds: [30420], + limit: 10_000, + }); + return events + .map((x) => parseArxletFromEvent(activeCCN)(x)) + .filter((x) => x !== undefined); +} diff --git a/src/ccns.ts b/src/ccns.ts new file mode 100644 index 0000000..671e1e0 --- /dev/null +++ b/src/ccns.ts @@ -0,0 +1,408 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { avataaars } from "@dicebear/collection"; +import { createAvatar } from "@dicebear/core"; +import { bech32m } from "@scure/base"; +import { + type Filter, + generateSecretKey, + getPublicKey, + type NostrEvent, + nip19, + SimplePool, +} from "nostr-tools"; +import { bytesToHex } from "nostr-tools/utils"; +import { type Arxlet, parseArxletFromEvent } from "./arxlets"; +import { getColorFromPubkey } from "./utils/color"; +import { getDataDir } from "./utils/files"; +import { getSvgGroup, pool, splitIntoParts } from "./utils/general"; +import { write_string, write_varint } from "./utils/Uint8Array"; +import { ReputationManager } from "./ccns/reputation"; +import { RollingIndex } from "./rollingIndex"; +import crypto from "node:crypto"; + +const LATEST_CCN_VERSION = 2; + +interface ConfigValue { + [key: string]: string | number | boolean | ConfigValue; +} + +function stringifyConfig(obj: ConfigValue, indent: number = 0): string { + let result = ""; + const spaces = " ".repeat(indent); + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "object") { + result += `${spaces}${key} {\n`; + result += stringifyConfig(value, indent + 1); + result += `${spaces}}\n`; + } else + result += `${spaces}${key} = ${typeof value === "string" ? `"${value}"` : value}\n`; + } + + return result; +} + +export class CCN { + private reputationManager: ReputationManager; + + constructor( + public version: number, + public startIndex: Uint8Array, + public name: string, + public description: string, + public key: Uint8Array, + ) { + this.reputationManager = new ReputationManager(this.getEvents.bind(this)); + } + + static fromJsonString(json: string) { + const data = JSON.parse(json); + return new CCN( + data.version || 1, + data.startIndex + ? RollingIndex.fromHex(data.startIndex) + : RollingIndex.get(), + data.name, + data.description, + new Uint8Array(data.privateKey), + ); + } + + static async getActive() { + const activeCCNPubKey = await Bun.secrets.get({ + name: "active-ccn", + service: "eve-lite", + }); + + if (activeCCNPubKey) { + const ccn = await CCN.fromPublicKey(activeCCNPubKey); + if (ccn) return ccn; + } + } + + static async list(): Promise { + const indexKey = { service: "eve-lite", name: "ccn-index" }; + const indexString = await Bun.secrets.get(indexKey); + if (!indexString) return []; + + const index: string[] = JSON.parse(indexString); + const ccns = await Promise.all( + index.map(async (pubkey: string) => { + const ccnString = await Bun.secrets.get({ + service: "eve-lite/ccn", + name: pubkey, + }); + if (!ccnString) return null; + return CCN.fromJsonString(ccnString); + }), + ); + return ccns.filter((ccn): ccn is CCN => ccn !== null); + } + + static async fromPublicKey(publicKey: string) { + const ccnString = await Bun.secrets.get({ + service: "eve-lite/ccn", + name: publicKey, + }); + if (!ccnString) return null; + return CCN.fromJsonString(ccnString); + } + + getCommunityIcon() { + const positions = [ + [30, 0], + [-60, 70], + [120, 70], + ]; + const avatars = splitIntoParts(this.publicKey, 3) + .map((part) => createAvatar(avataaars, { seed: part }).toString()) + .map((avatar, index) => + getSvgGroup( + avatar, + `translate(${positions[index]![0]}, ${positions[index]![1]}) scale(0.8)`, + ), + ) + .join(""); + return ` + + ${avatars} + `; + } + + getPrivateKeyAt(index: Uint8Array) { + if (this.version === 1) return this.key; + const hmac = crypto.createHmac("sha256", this.key); + hmac.update(index); + return hmac.digest(); + } + + getPublicKeyAt(index: Uint8Array) { + if (this.version === 1) return this.publicKey; + return getPublicKey(this.getPrivateKeyAt(index)); + } + + get publicKey() { + return getPublicKey(this.key); + } + + get currentPrivateKey() { + if (this.version === 1) return this.key; + return this.getPrivateKeyAt(RollingIndex.get()); + } + + get currentPublicKey() { + if (this.version === 1) return this.publicKey; + return getPublicKey(this.currentPrivateKey); + } + + get allPubkeys() { + if (this.version === 1) return [this.publicKey]; + const allPeriods = RollingIndex.diff(RollingIndex.get(), this.startIndex); + return allPeriods.map((index) => this.getPublicKeyAt(index)); + } + + private get json() { + return { + name: this.name, + description: this.description, + privateKey: [...this.key], + version: this.version, + startIndex: RollingIndex.toHex(this.startIndex), + }; + } + + toPublicJson() { + return { + name: this.name, + description: this.description, + publicKey: this.publicKey, + icon: this.icon, + version: this.version, + startIndex: this.startIndex, + }; + } + + async setActive() { + await Bun.secrets.set({ + name: "active-ccn", + service: "eve-lite", + value: this.publicKey, + }); + } + + static async create(name: string, description: string) { + return CCN.join( + LATEST_CCN_VERSION, + RollingIndex.get(), + name, + description, + generateSecretKey(), + ); + } + + static async join( + ccn_version: number, + startIndex: Uint8Array, + name: string, + description: string, + privateKey: Uint8Array, + ) { + const ccn = new CCN(ccn_version, startIndex, name, description, privateKey); + const indexKey = { service: "eve-lite", name: "ccn-index" }; + const indexString = await Bun.secrets.get(indexKey); + const index: string[] = indexString ? JSON.parse(indexString) : []; + + if (!index.includes(ccn.publicKey)) { + await Bun.secrets.set({ + service: "eve-lite/ccn", + name: ccn.publicKey, + value: JSON.stringify(ccn.json), + }); + index.push(ccn.publicKey); + await Bun.secrets.set({ ...indexKey, value: JSON.stringify(index) }); + } + + return ccn; + } + + get strfryConfigPath() { + return join(getDataDir(), "strfry", `${this.publicKey}.conf`); + } + + async writeStrfryConfig() { + const dbDir = join(getDataDir(), "dbs", this.publicKey); + mkdirSync(dbDir, { recursive: true }); + const config = stringifyConfig({ + db: dbDir, + dbParams: { + maxreaders: 256, + mapsize: 10995116277760, + noReadAhead: false, + }, + events: { + maxEventSize: 1048576, + rejectEventsNewerThanSeconds: 900, + rejectEventsOlderThanSeconds: 252460800, + rejectEphemeralEventsOlderThanSeconds: 86400, + ephemeralEventsLifetimeSeconds: 172800, + maxNumTags: 2000, + maxTagValSize: 10240, + }, + relay: { + bind: "127.0.0.1", + port: 6942, + nofiles: 0, + realIpHeader: "", + info: { + name: this.name, + description: this.description, + pubkey: this.publicKey, + contact: "", + icon: "", + nips: "", + }, + maxWebsocketPayloadSize: 5 * 1024 * 1024, + maxReqFilterSize: 200, + autoPingSeconds: 55, + enableTcpKeepalive: false, + queryTimesliceBudgetMicroseconds: 10000, + maxFilterLimit: 100_000, + maxSubsPerConnection: 100, + writePolicy: { + plugin: "/usr/bin/eve-lite-event-plugin", + }, + compression: { + enabled: true, + slidingWindow: true, + }, + logging: { + dumpInAll: false, + dumpInEvents: true, + dumpInReqs: true, + dbScanPerf: true, + invalidEvents: true, + }, + numThreads: { + ingester: 3, + reqWorker: 3, + reqMonitor: 3, + negentropy: 2, + }, + negentropy: { + enabled: false, + maxSyncEvents: 1, + }, + }, + }); + const configFile = Bun.file(this.strfryConfigPath); + await configFile.write(config); + } + + getEvents(filter: Filter) { + const pool = new SimplePool(); + return pool.querySync(["ws://localhost:6942"], filter); + } + + getFirstEvent(filter: Filter) { + const pool = new SimplePool(); + return pool.get(["ws://localhost:6942"], filter); + } + + async getArxlets(): Promise { + const events = await this.getEvents({ + kinds: [30420], + }); + return events + .map((event) => parseArxletFromEvent(this)(event)) + .filter((arxlet) => arxlet !== undefined); + } + + async getArxletById(id: string): Promise { + const query = { + kinds: [30420], + "#d": [id], + }; + if (id.includes(":")) { + const [dTag, author] = id.split(":"); + query["#d"] = [dTag]; + query["authors"] = [author]; + } + const event = await this.getFirstEvent(query); + if (!event) return undefined; + return parseArxletFromEvent(this)(event); + } + + async getUserKey() { + const service = "eve-lite-user-key"; + const name = this.publicKey; + const nsec = await Bun.secrets.get({ service, name }); + if (nsec) { + const decoded = nip19.decode(nsec); + if (decoded.type !== "nsec") throw "Invalid key"; + return decoded.data; + } + + const key = generateSecretKey(); + const newNsec = nip19.nsecEncode(key); + await Bun.secrets.set({ service, name, value: newNsec }); + return key; + } + + publish(event: NostrEvent): Promise { + return Promise.all(pool.publish(["ws://localhost:6942"], event)); + } + + async loadSeenEvents() { + const service = "eve-lite-seen"; + const name = this.publicKey; + const seen = await Bun.secrets.get({ + service, + name, + }); + if (!seen) return []; + return JSON.parse(seen) as string[]; + } + + async saveSeenEvent(event: NostrEvent) { + const service = "eve-lite-seen"; + const name = this.publicKey; + const seenEvents = await this.loadSeenEvents(); + seenEvents.push(event.id); + await Bun.secrets.set({ + service, + name, + value: JSON.stringify(seenEvents), + }); + } + + async getProfile(): Promise<{ name?: string; picture?: string }> { + const profileEvent = await this.getFirstEvent({ + kinds: [0], + authors: [this.publicKey], + }); + if (!profileEvent) return {}; + return JSON.parse(profileEvent.content); + } + + async generateInvite() { + const INVITE_VERSION = 2; + const data: number[] = []; + const userKey = await this.getUserKey(); + const userPubkey = getPublicKey(userKey); + const ccnKeyMaterial = bytesToHex(this.key); + write_varint(data, INVITE_VERSION); + write_string(data, this.name); + write_string(data, this.description); + write_string(data, userPubkey); + write_string(data, ccnKeyMaterial); + write_string(data, RollingIndex.toHex(this.startIndex)); + + const combinedBytes = bech32m.toWords(new Uint8Array(data)); + return bech32m.encode("evelite", combinedBytes, false); + } + + public async getReputation(userId: string): Promise { + return await this.reputationManager.getReputation(userId); + } +} diff --git a/src/ccns/reputation.ts b/src/ccns/reputation.ts new file mode 100644 index 0000000..f026873 --- /dev/null +++ b/src/ccns/reputation.ts @@ -0,0 +1,150 @@ +import { type Filter, type NostrEvent } from "nostr-tools"; + +interface ReputationInfo { + reputation: number; + voteCount: number; + lastEventTimestamp: number; + processedVotes: Set; +} + +export class ReputationManager { + private reputationCache = new Map(); + private reputationCalculationInProgress = new Map>(); + + constructor(private getEvents: (filter: Filter) => Promise) {} + + private _updateReputation( + targetReputation: number, + targetVoteCount: number, + voteScore: number, // 1 for up, 0 for down + voterReputation: number, + scaleFactor = 400, + ): number { + const ratingDiff = voterReputation - targetReputation; + + const exponent = ratingDiff / scaleFactor; + + const powerTerm = 10 ** exponent; + const denom = 1 + powerTerm; + const expectedScore = 1.0 / denom; + + const sqrtVotes = Math.sqrt(targetVoteCount); + const denomAdjust = 1 + sqrtVotes; + const adjustmentFactor = 32.0 / denomAdjust; + + const scoreDiff = voteScore - expectedScore; + return targetReputation + adjustmentFactor * scoreDiff; + } + + private async runReputationBatch(initialUser: string) { + const usersToFetch = new Set([initialUser]); + const allInvolved = new Set([initialUser]); + const allNewEvents: NostrEvent[] = []; + const processedEventIds = new Set(); + + // Phase 1: Discover users (BFS) and fetch new events. + while (usersToFetch.size > 0) { + const batch = Array.from(usersToFetch); + usersToFetch.clear(); + + // For any other users discovered in this batch, hook them to the main promise. + for (const u of batch) { + if (!this.reputationCalculationInProgress.has(u)) + this.reputationCalculationInProgress.set( + u, + this.reputationCalculationInProgress.get(initialUser)!, + ); + } + + const eventPromises = batch.map((u) => { + const since = this.reputationCache.get(u)?.lastEventTimestamp; + return this.getEvents({ + kinds: [7], + "#p": [u], + since: since ? since + 1 : 0, + }); + }); + + const eventSets = await Promise.all(eventPromises); + const newEvents = eventSets.flat(); + + for (const event of newEvents) { + if (processedEventIds.has(event.id)) continue; + processedEventIds.add(event.id); + allNewEvents.push(event); + + const voter = event.pubkey; + if (!allInvolved.has(voter)) { + allInvolved.add(voter); + usersToFetch.add(voter); + } + } + } + + // Phase 2: Initialize cache for new users. + for (const u of allInvolved) { + if (!this.reputationCache.has(u)) + this.reputationCache.set(u, { + reputation: 500, + voteCount: 0, + lastEventTimestamp: 0, + processedVotes: new Set(), + }); + } + + allNewEvents.sort((a, b) => a.created_at - b.created_at); + + for (const event of allNewEvents) { + const targetUserTag = event.tags.find((t) => t[0] === "p"); + if (!targetUserTag?.[1]) continue; + const targetUser = targetUserTag[1]; + + if (!allInvolved.has(targetUser)) continue; + + const targetUserInfo = this.reputationCache.get(targetUser)!; + const voter = event.pubkey; + + if (voter === targetUser) continue; + + const eTag = event.tags.find((t) => t[0] === "e"); + if (!eTag?.[1]) continue; + + const voteId = `${voter}:${eTag[1]}`; + if (targetUserInfo.processedVotes.has(voteId)) continue; + + const voteContent = event.content; + if (voteContent !== "+" && voteContent !== "-") continue; + + targetUserInfo.processedVotes.add(voteId); + const voteScore = voteContent === "+" ? 1 : 0; + targetUserInfo.voteCount++; + + const voterReputation = this.reputationCache.get(voter)!.reputation; + + targetUserInfo.reputation = this._updateReputation( + targetUserInfo.reputation, + targetUserInfo.voteCount, + voteScore, + voterReputation, + ); + + targetUserInfo.lastEventTimestamp = event.created_at; + } + + for (const u of allInvolved) { + this.reputationCalculationInProgress.delete(u); + } + } + + async getReputation(user: string): Promise { + if (this.reputationCalculationInProgress.has(user)) + await this.reputationCalculationInProgress.get(user); + else { + const batchPromise = this.runReputationBatch(user); + this.reputationCalculationInProgress.set(user, batchPromise); + await batchPromise; + } + + return this.reputationCache.get(user)?.reputation ?? 500; + } +} diff --git a/src/consts.ts b/src/consts.ts new file mode 100644 index 0000000..cd5d91c --- /dev/null +++ b/src/consts.ts @@ -0,0 +1,4 @@ +export const EVENT_PLUGIN_VERSION = "0.0.1"; +export const POW_TO_ACCEPT = 10; +export const POW_TO_MINE = 12; +export const CURRENT_VERSION = 0x01; diff --git a/src/eventPlugin.ts b/src/eventPlugin.ts new file mode 100644 index 0000000..f4b1a9f --- /dev/null +++ b/src/eventPlugin.ts @@ -0,0 +1,45 @@ +import { CCN } from "./ccns"; +import { EVENT_PLUGIN_VERSION } from "./consts"; +import { RollingIndex } from "./rollingIndex"; +import { createEncryptedEvent } from "./utils/encryption"; +import { loadSeenEvents, saveSeenEvent } from "./utils/files"; +import { sendEncryptedEventToRelays } from "./utils/general"; + +if (process.argv[process.argv.length - 1] === "--version") { + console.log(EVENT_PLUGIN_VERSION); + process.exit(0); +} + +for await (const line of console) { + const req = JSON.parse(line); + + const ccn = await CCN.getActive(); + if (!ccn) { + console.error("No CCN"); + continue; + } + + if (req.type !== "new") { + console.error("unexpected request type"); + continue; + } + + const seenEvents = await loadSeenEvents(); + if (!seenEvents.includes(req.event.id)) { + let index = RollingIndex.at(req.event.created_at * 1000); + if (RollingIndex.compare(index, ccn.startIndex) < 0) index = ccn.startIndex; + + const keyForEvent = ccn.getPrivateKeyAt(index); + const encryptedEvent = await createEncryptedEvent(req.event, keyForEvent); + await saveSeenEvent(req.event); + await saveSeenEvent(encryptedEvent); + await sendEncryptedEventToRelays(encryptedEvent); + } + + console.log( + JSON.stringify({ + id: req.event.id, + action: "accept", + }), + ); +} diff --git a/src/pages/docs/arxlets/arxlet-docs-out.html b/src/pages/docs/arxlets/arxlet-docs-out.html new file mode 100644 index 0000000..4589a03 --- /dev/null +++ b/src/pages/docs/arxlets/arxlet-docs-out.html @@ -0,0 +1,1825 @@ + + + + + + + + +Arxlets API Context + + + + + + + +
+
+

Overview

+
+
+

Arxlets are secure, sandboxed JavaScript applications that extend Eve’s functionality. They run in isolated iframes and are registered on your CCN (Closed Community Network) for member-only access. Arxlets provide a powerful way to build custom applications that interact with Nostr events and profiles through Eve.

+
+
+

Core Concepts

+
+

What are Arxlets? +- Sandboxed Applications: Run in isolated iframes for security +- JavaScript-based: Written in TypeScript/JavaScript with wasm support coming in the future +- CCN Integration: Registered on your Closed Community Network +- Nostr-native: Built-in access to Nostr protocol operations +- Real-time: Support for live event subscriptions and updates

+
+
+ + + + + +
+ + +WASM support will be added in future releases for even more powerful applications. +
+
+
+
+

CCN Local-First Architecture

+
+

CCNs (Closed Community Networks) are designed with a local-first approach that ensures data availability and functionality even when offline:

+
+
+
    +
  • +

    Local Data Storage: All Nostr events and profiles are stored locally on your device, providing instant access without network dependencies

    +
  • +
  • +

    Offline Functionality: Arxlets can read, display, and interact with locally cached data when disconnected from the internet

    +
  • +
  • +

    Sync When Connected: When network connectivity is restored, the CCN automatically synchronizes with remote relays to fetch new events and propagate local changes

    +
  • +
  • +

    Resilient Operation: Your applications continue to work seamlessly regardless of network conditions, making CCNs ideal for unreliable connectivity scenarios

    +
  • +
  • +

    Privacy by Design: Local-first storage means your data remains on your device, reducing exposure to external services and improving privacy

    +
  • +
+
+
+
+

Architecture

+
+
    +
  • +

    Frontend: TypeScript applications with render functions

    +
  • +
  • +

    Backend: Eve relay providing Nostr protocol access

    +
  • +
  • +

    Communication: window.eve API or direct WebSocket connections

    +
  • +
+
+
+
+
+
+

API Reference

+
+
+

The primary interface for Arxlets to interact with Eve’s Nostr relay. All methods return promises for async operations.

+
+
+

window.eve API

+
+
+
// Using window.eve API for Nostr operations
+import type { Filter, NostrEvent } from "./types";
+
+// Publish a new event
+const event: NostrEvent = {
+  kind: 1,
+  content: "Hello from my Arxlet!",
+  tags: [],
+  created_at: Math.floor(Date.now() / 1000),
+  pubkey: "your-pubkey-here",
+};
+
+await window.eve.publish(event);
+
+// Get a specific event by ID
+const eventId = "event-id-here";
+const event = await window.eve.getSingleEventById(eventId);
+
+// Query events with a filter
+const filter: Filter = {
+  kinds: [1],
+  authors: ["pubkey-here"],
+  limit: 10,
+};
+
+const singleEvent = await window.eve.getSingleEventWithFilter(filter);
+const allEvents = await window.eve.getAllEventsWithFilter(filter);
+
+// Real-time subscription with RxJS Observable
+const subscription = window.eve.subscribeToEvents(filter).subscribe({
+  next: (event) => {
+    console.log("New event received:", event);
+    // Update your UI with the new event
+  },
+  error: (err) => console.error("Subscription error:", err),
+  complete: () => console.log("Subscription completed"),
+});
+
+// Subscribe to profile updates for a specific user
+const profileSubscription = window.eve.subscribeToProfile(pubkey).subscribe({
+  next: (profile) => {
+    console.log("Profile updated:", profile);
+    // Update your UI with the new profile data
+  },
+  error: (err) => console.error("Profile subscription error:", err),
+});
+
+// Don't forget to unsubscribe when done
+// subscription.unsubscribe();
+// profileSubscription.unsubscribe();
+
+// Get user profile and avatar
+const pubkey = "user-pubkey-here";
+const profile = await window.eve.getProfile(pubkey);
+const avatarUrl = await window.eve.getAvatar(pubkey);
+
+
+
+
+

Real-time Subscriptions

+
+
+
// Real-time subscription examples
+import { filter, map, takeUntil } from "rxjs/operators";
+
+// Basic subscription
+const subscription = window.eve
+  .subscribeToEvents({
+    kinds: [1], // Text notes
+    limit: 50,
+  })
+  .subscribe((event) => {
+    console.log("New text note:", event.content);
+  });
+
+// Advanced filtering with RxJS operators
+const filteredSubscription = window.eve
+  .subscribeToEvents({
+    kinds: [1, 6, 7], // Notes, reposts, reactions
+    authors: ["pubkey1", "pubkey2"],
+  })
+  .pipe(
+    filter((event) => event.content.includes("#arxlet")), // Only events mentioning arxlets
+    map((event) => ({
+      id: event.id,
+      author: event.pubkey,
+      content: event.content,
+      timestamp: new Date(event.created_at * 1000),
+    })),
+  )
+  .subscribe({
+    next: (processedEvent) => {
+      // Update your UI
+      updateEventsList(processedEvent);
+    },
+    error: (err) => {
+      console.error("Subscription error:", err);
+      showErrorMessage("Failed to receive real-time updates");
+    },
+  });
+
+// Profile subscription example
+const profileSubscription = window.eve.subscribeToProfile("user-pubkey-here").subscribe({
+  next: (profile) => {
+    console.log("Profile updated:", profile);
+    updateUserProfile(profile);
+  },
+  error: (err) => {
+    console.error("Profile subscription error:", err);
+  },
+});
+
+// Clean up subscriptions when component unmounts
+// subscription.unsubscribe();
+// filteredSubscription.unsubscribe();
+// profileSubscription.unsubscribe();
+
+
+
+
+

WebSocket Alternative

+
+

For advanced use cases, connect directly to Eve’s WebSocket relay, or use any nostr library. This is not recommended:

+
+
+
+
// Alternative: Direct WebSocket connection
+const ws = new WebSocket("ws://localhost:6942");
+
+ws.onopen = () => {
+  // Subscribe to events
+  ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1], limit: 10 }]));
+};
+
+ws.onmessage = (event) => {
+  const [type, subId, data] = JSON.parse(event.data);
+  if (type === "EVENT") {
+    console.log("Received event:", data);
+  }
+};
+
+// Publish an event
+const signedEvent = await window.nostr.signEvent(unsignedEvent);
+ws.send(JSON.stringify(["EVENT", signedEvent]));
+
+
+
+
+
+
+

Type Definitions

+
+
+
+
import type { Observable } from "rxjs";
+
+export interface NostrEvent {
+  id?: string;
+  pubkey: string;
+  created_at: number;
+  kind: number;
+  tags: string[][];
+  content: string;
+  sig?: string;
+}
+
+export interface Filter {
+  ids?: string[];
+  authors?: string[];
+  kinds?: number[];
+  since?: number;
+  until?: number;
+  limit?: number;
+  [key: string]: any;
+}
+
+export interface Profile {
+  name?: string;
+  about?: string;
+  picture?: string;
+  nip05?: string;
+  [key: string]: any;
+}
+
+export interface WindowEve {
+  publish(event: NostrEvent): Promise<void>;
+  getSingleEventById(id: string): Promise<NostrEvent | null>;
+  getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>;
+  getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>;
+  subscribeToEvents(filter: Filter): Observable<NostrEvent>;
+  subscribeToProfile(pubkey: string): Observable<Profile>;
+  getProfile(pubkey: string): Promise<Profile | null>;
+  getAvatar(pubkey: string): Promise<string | null>;
+  signEvent(event: NostrEvent): Promise<NostrEvent>;
+  get publicKey(): Promise<string>;
+}
+
+declare global {
+  interface Window {
+    eve: WindowEve;
+  }
+}
+
+
+
+
+
+

Registration

+
+
+

Arxlets are registered using Nostr events with kind 30420:

+
+
+
+
{
+  "kind": 30420,
+  "tags": [
+    ["d", "my-calculator"],
+    ["name", "Simple Calculator"],
+    ["description", "A basic calculator for quick math"],
+    ["script", "export function render(el) { /* your code */ }"],
+    ["icon", "mdi:calculator", "#3b82f6"]
+  ],
+  "content": "",
+  "created_at": 1735171200
+}
+
+
+
+

Required Tags

+
+
    +
  • +

    d: Unique identifier (alphanumeric, hyphens, underscores)

    +
  • +
  • +

    name: Human-readable display name

    +
  • +
  • +

    script: Complete JavaScript code with render export function

    +
  • +
+
+
+
+

Optional Tags

+
+
    +
  • +

    description: Brief description of functionality

    +
  • +
  • +

    icon: Iconify icon name and hex color

    +
  • +
+
+
+
+
+
+

Development Patterns

+
+
+

Basic Arxlet Structure

+
+
+
/**
+ * Required export function - Entry point for your Arxlet
+ */
+export function render(container: HTMLElement): void {
+  // Initialize your application
+  container.innerHTML = `
+    <div class="p-6">
+      <h1 class="text-3xl font-bold mb-4">My Arxlet</h1>
+      <p class="text-lg">Hello from Eve!</p>
+      <button class="btn btn-primary mt-4" id="myButton">
+        Click me!
+      </button>
+    </div>
+  `;
+
+  // Add event listeners with proper typing
+  const button = container.querySelector<HTMLButtonElement>("#myButton");
+  button?.addEventListener("click", (): void => {
+    alert("Button clicked!");
+  });
+
+  // Your app logic here...
+}
+
+
+
+
+

Real-time Updates

+
+
+
export function render(container: HTMLElement): void {
+  let subscription: Subscription;
+
+  // Set up UI
+  container.innerHTML = `<div id="events"></div>`;
+  const eventsContainer = container.querySelector("#events");
+
+  // Subscribe to real-time events
+  subscription = window.eve
+    .subscribeToEvents({
+      kinds: [1],
+      limit: 50,
+    })
+    .subscribe({
+      next: (event) => {
+        // Update UI with new event
+        const eventElement = document.createElement("div");
+        eventElement.textContent = event.content;
+        eventsContainer?.prepend(eventElement);
+      },
+      error: (err) => console.error("Subscription error:", err),
+    });
+
+  // Cleanup when arxlet is destroyed
+  window.addEventListener("beforeunload", () => {
+    subscription?.unsubscribe();
+  });
+}
+
+
+
+
+

Publishing Events

+
+
+
import type { NostrEvent } from "./type-definitions.ts";
+
+export async function render(container: HTMLElement): Promise<void> {
+  container.innerHTML = `
+    <div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
+      <div class="card-body">
+        <h2 class="card-title">📝 Publish a Note</h2>
+
+        <div class="form-control">
+          <label class="label">
+            <span class="label-text">What's on your mind?</span>
+            <span class="label-text-alt" id="charCount">0/280</span>
+          </label>
+          <textarea
+            class="textarea textarea-bordered h-32"
+            id="noteContent"
+            placeholder="Share your thoughts with your CCN..."
+            maxlength="280">
+          </textarea>
+        </div>
+
+        <div class="card-actions justify-between items-center">
+          <div id="status" class="flex-1"></div>
+          <button class="btn btn-primary" id="publishBtn" disabled>
+            Publish Note
+          </button>
+        </div>
+      </div>
+    </div>
+  `;
+
+  const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
+  const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
+  const status = container.querySelector<HTMLDivElement>("#status")!;
+  const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
+
+  textarea.oninput = (): void => {
+    const length: number = textarea.value.length;
+    charCount.textContent = `${length}/280`;
+    publishBtn.disabled = length === 0 || length > 280;
+  };
+
+  publishBtn.onclick = async (e): Promise<void> => {
+    const content: string = textarea.value.trim();
+    if (!content) return;
+
+    publishBtn.disabled = true;
+    publishBtn.textContent = "Publishing...";
+    status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
+
+    try {
+      const unsignedEvent: NostrEvent = {
+        kind: 1, // Text note
+        content: content,
+        tags: [["client", "arxlet-publisher"]],
+        created_at: Math.floor(Date.now() / 1000),
+        pubkey: await window.eve.publicKey,
+      };
+
+      const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
+
+      await window.eve.publish(signedEvent);
+
+      status.innerHTML = `
+        <div class="alert alert-success">
+          <span>✅ Note published successfully!</span>
+        </div>
+      `;
+
+      textarea.value = "";
+      textarea.oninput?.(e);
+    } catch (error: unknown) {
+      const errorMessage = error instanceof Error ? error.message : "Unknown error";
+      console.error("Publishing failed:", error);
+      status.innerHTML = `
+        <div class="alert alert-error">
+          <span>❌ Failed to publish: ${errorMessage}</span>
+        </div>
+      `;
+    } finally {
+      publishBtn.disabled = false;
+      publishBtn.textContent = "Publish Note";
+    }
+  };
+}
+
+
+
+
+
+
+

Best Practices

+
+
+

Error Handling

+
+
    +
  • +

    Always wrap API calls in try-catch blocks

    +
  • +
  • +

    Check for null returns from query methods

    +
  • +
  • +

    Provide user feedback for failed operations

    +
  • +
+
+
+
+

Performance

+
+
    +
  • +

    Use specific filters to limit result sets

    +
  • +
  • +

    Cache profile data to avoid repeated lookups

    +
  • +
  • +

    Unsubscribe from observables when done

    +
  • +
  • +

    Debounce rapid API calls

    +
  • +
  • +

    Consider pagination for large datasets

    +
  • +
+
+
+
+

Security

+
+
    +
  • +

    Validate all user inputs

    +
  • +
  • +

    Sanitize content before displaying

    +
  • +
  • +

    Use proper event signing for authenticity

    +
  • +
  • +

    Follow principle of least privilege

    +
  • +
+
+
+
+

Memory Management

+
+
    +
  • +

    Always unsubscribe from RxJS observables

    +
  • +
  • +

    Clean up event listeners on component destruction

    +
  • +
  • +

    Avoid memory leaks in long-running subscriptions

    +
  • +
  • +

    Use weak references where appropriate

    +
  • +
+
+
+
+
+
+

Common Use Cases

+
+
+

Social Feed

+
+
    +
  • +

    Subscribe to events from followed users

    +
  • +
  • +

    Display real-time updates

    +
  • +
  • +

    Handle profile information and avatars

    +
  • +
  • +

    Implement engagement features

    +
  • +
+
+
+
+

Publishing Tools

+
+
    +
  • +

    Create and sign events

    +
  • +
  • +

    Validate content before publishing

    +
  • +
  • +

    Handle publishing errors gracefully

    +
  • +
  • +

    Provide user feedback

    +
  • +
+
+
+
+

Data Visualization

+
+
    +
  • +

    Query historical events

    +
  • +
  • +

    Process and aggregate data

    +
  • +
  • +

    Create interactive charts and graphs

    +
  • +
  • +

    Real-time data updates

    +
  • +
+
+
+
+

Communication Apps

+
+
    +
  • +

    Direct messaging interfaces

    +
  • +
  • +

    Group chat functionality

    +
  • +
  • +

    Notification systems

    +
  • +
  • +

    Presence indicators

    +
  • +
+
+
+
+
+
+

Framework Integration

+
+
+

Arxlets support various JavaScript frameworks. All frameworks must export a render function that accepts a container element:

+
+
+

Vanilla JavaScript

+
+
+
export function render(container: HTMLElement) {
+  let count: number = 0;
+
+  container.innerHTML = `
+    <div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
+      <div class="card-body text-center">
+        <h2 class="card-title justify-center">Counter App</h2>
+        <div class="text-6xl font-bold text-primary my-4" id="display">
+          ${count}
+        </div>
+        <div class="card-actions justify-center gap-4">
+          <button class="btn btn-error" id="decrement">−</button>
+          <button class="btn btn-success" id="increment">+</button>
+          <button class="btn btn-ghost" id="reset">Reset</button>
+        </div>
+      </div>
+    </div>
+  `;
+
+  const display = container.querySelector<HTMLDivElement>("#display")!;
+  const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
+  const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
+  const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
+
+  const updateDisplay = (): void => {
+    display.textContent = count.toString();
+    display.className = `text-6xl font-bold my-4 ${
+      count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
+    }`;
+  };
+
+  incrementBtn.onclick = (): void => {
+    count++;
+    updateDisplay();
+  };
+  decrementBtn.onclick = (): void => {
+    count--;
+    updateDisplay();
+  };
+  resetBtn.onclick = (): void => {
+    count = 0;
+    updateDisplay();
+  };
+}
+
+
+
+
+

Preact/React

+
+
+
// @jsx h
+// @jsxImportSource preact
+
+import { render as renderPreact } from "preact";
+import { useState } from "preact/hooks";
+
+const CounterApp = () => {
+  const [count, setCount] = useState(0);
+  const [message, setMessage] = useState("");
+
+  const increment = () => {
+    setCount((prev) => prev + 1);
+    setMessage(`Clicked ${count + 1} times!`);
+  };
+
+  const decrement = () => {
+    setCount((prev) => prev - 1);
+    setMessage(`Count decreased to ${count - 1}`);
+  };
+
+  const reset = () => {
+    setCount(0);
+    setMessage("Counter reset!");
+  };
+
+  return (
+    <div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
+      <div class="card-body text-center">
+        <h2 class="card-title justify-center"> Preact Counter </h2>
+
+        <div
+          class={`text-6xl font-bold my-4 ${count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}`}
+        >
+          {count}
+        </div>
+
+        <div class="card-actions justify-center gap-4">
+          <button class="btn btn-error" onClick={decrement}>
+            −
+          </button>
+          <button class="btn btn-success" onClick={increment}>
+            +
+          </button>
+          <button class="btn btn-ghost" onClick={reset}>
+            Reset
+          </button>
+        </div>
+
+        {message && (
+          <div class="alert alert-info mt-4">
+            <span>{message} </span>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export function render(container: HTMLElement): void {
+  renderPreact(<CounterApp />, container);
+}
+
+
+
+
+

Svelte

+
+
+
<script lang="ts">
+let count = $state(0);
+let message = $state("");
+
+function increment() {
+  count += 1;
+  message = `Clicked ${count} times!`;
+}
+
+function decrement() {
+  count -= 1;
+  message = `Count decreased to ${count}`;
+}
+
+function reset() {
+  count = 0;
+  message = "Counter reset!";
+}
+
+const countColor = $derived(count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary");
+</script>
+
+<div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
+  <div class="card-body text-center">
+    <h2 class="card-title justify-center">🔥 Svelte Counter</h2>
+
+    <div class="text-6xl font-bold my-4 {countColor}">
+      {count}
+    </div>
+
+    <div class="card-actions justify-center gap-4">
+      <button class="btn btn-error" onclick={decrement}> − </button>
+      <button class="btn btn-success" onclick={increment}> + </button>
+      <button class="btn btn-ghost" onclick={reset}> Reset </button>
+    </div>
+
+    {#if message}
+      <div class="alert alert-info mt-4">
+        <span>{message}</span>
+      </div>
+    {/if}
+  </div>
+</div>
+
+<style>
+  .card-title {
+    color: var(--primary);
+  }
+</style>
+
+
+
+
+

Build Process

+
+

All frameworks require bundling into a single JavaScript file:

+
+
+
+
# For TypeScript/JavaScript projects
+bun build index.ts --outfile=build.js --minify --target=browser --production
+
+# The resulting build.js content goes in your registration event's script tag
+
+
+
+

Svelte Build Requirements

+
+ + + + + +
+ + +The standard build command above will NOT work for Svelte projects. Svelte requires specific Vite configuration to compile properly. +
+
+
+

For Svelte arxlets:

+
+
+
    +
  1. +

    Use the arxlets-template which includes the correct Vite configuration

    +
  2. +
  3. +

    Run bun run build instead of the standard build command

    +
  4. +
  5. +

    Your compiled file will be available at dist/bundle.js

    +
  6. +
+
+
+

While the initial setup is more complex, Svelte provides an excellent development experience once configured, with features like:

+
+
+
    +
  • +

    Built-in reactivity with runes ($state(), $derived(), etc.)

    +
  • +
  • +

    Scoped CSS

    +
  • +
  • +

    Compile-time optimizations

    +
  • +
  • +

    No runtime overhead

    +
  • +
+
+
+
+
+
+
+

Debugging and Development

+
+
+

Console Logging

+
+
    +
  • +

    Use console.log() for debugging

    +
  • +
  • +

    Events and errors are logged to browser console

    +
  • +
+
+
+
+

Error Handling

+
+
    +
  • +

    Catch and log API errors

    +
  • +
  • +

    Display user-friendly error messages

    +
  • +
  • +

    Implement retry mechanisms for transient failures

    +
  • +
+
+
+
+

Testing

+
+
    +
  • +

    Test with various event types and filters

    +
  • +
  • +

    Verify subscription cleanup

    +
  • +
  • +

    Test error scenarios and edge cases

    +
  • +
  • +

    Validate event signing and publishing

    +
  • +
+
+
+
+
+
+

Limitations and Considerations

+
+
+

Sandbox Restrictions

+
+
    +
  • +

    Limited access to browser APIs

    +
  • +
  • +

    No direct file system access

    +
  • +
  • +

    Restricted network access (only to Eve relay)

    +
  • +
  • +

    No access to parent window context

    +
  • +
+
+
+
+

Performance Constraints

+
+
    +
  • +

    Iframe overhead for each arxlet

    +
  • +
  • +

    Memory usage for subscriptions

    +
  • +
  • +

    Event processing limitations

    +
  • +
+
+
+
+

Security Considerations

+
+
    +
  • +

    All events are public on Nostr

    +
  • +
  • +

    Private key management handled by Eve

    +
  • +
  • +

    Content sanitization required

    +
  • +
  • +

    XSS prevention necessary

    +
  • +
+
+
+
+
+
+

DaisyUI Components

+
+
+

Arxlets have access to DaisyUI 5, a comprehensive CSS component library. Use these pre-built components for consistent, accessible UI:

+
+
+

Essential Components

+
+
+
<!-- Cards for content containers -->
+<div class="card bg-base-100 shadow-xl">
+  <div class="card-body">
+    <h2 class="card-title">Card Title</h2>
+    <p>Card content goes here</p>
+    <div class="card-actions justify-end">
+      <button class="btn btn-primary">Action</button>
+    </div>
+  </div>
+</div>
+
+<!-- Buttons with various styles -->
+<button class="btn btn-primary">Primary</button>
+<button class="btn btn-secondary">Secondary</button>
+<button class="btn btn-success">Success</button>
+<button class="btn btn-error">Error</button>
+<button class="btn btn-ghost">Ghost</button>
+
+<!-- Form controls -->
+<div class="form-control">
+  <label class="label">
+    <span class="label-text">Input Label</span>
+  </label>
+  <input type="text" class="input input-bordered" placeholder="Enter text" />
+</div>
+
+<!-- Alerts for feedback -->
+<div class="alert alert-success">
+  <span>✅ Success message</span>
+</div>
+<div class="alert alert-error">
+  <span>❌ Error message</span>
+</div>
+
+<!-- Loading states -->
+<span class="loading loading-spinner loading-lg"></span>
+<button class="btn btn-primary">
+  <span class="loading loading-spinner loading-sm"></span>
+  Loading...
+</button>
+
+<!-- Modals for dialogs -->
+<dialog class="modal" id="my-modal">
+  <div class="modal-box">
+    <h3 class="font-bold text-lg">Modal Title</h3>
+    <p class="py-4">Modal content</p>
+    <div class="modal-action">
+      <button class="btn" onclick="document.getElementById('my-modal').close()">
+        Close
+      </button>
+    </div>
+  </div>
+</dialog>
+
+
+
+
+

Layout Utilities

+
+
+
<!-- Responsive grid -->
+<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+  <div class="card">Content 1</div>
+  <div class="card">Content 2</div>
+  <div class="card">Content 3</div>
+</div>
+
+<!-- Flexbox utilities -->
+<div class="flex justify-between items-center">
+  <span>Left content</span>
+  <button class="btn">Right button</button>
+</div>
+
+<!-- Spacing -->
+<div class="p-4 m-2 space-y-4">
+  <!-- p-4 = padding, m-2 = margin, space-y-4 = vertical spacing -->
+</div>
+
+
+
+
+

Color System

+
+
+
<!-- Background colors -->
+<div class="bg-base-100">Default background</div>
+<div class="bg-base-200">Slightly darker</div>
+<div class="bg-primary">Primary color</div>
+<div class="bg-secondary">Secondary color</div>
+
+<!-- Text colors -->
+<span class="text-primary">Primary text</span>
+<span class="text-success">Success text</span>
+<span class="text-error">Error text</span>
+<span class="text-base-content">Default text</span>
+
+
+
+
+
+
+

Complete Example Patterns

+
+
+

Simple Counter Arxlet

+
+
+
export function render(container: HTMLElement) {
+  let count: number = 0;
+
+  container.innerHTML = `
+    <div class="card bg-base-100 shadow-xl max-w-sm mx-auto">
+      <div class="card-body text-center">
+        <h2 class="card-title justify-center">Counter App</h2>
+        <div class="text-6xl font-bold text-primary my-4" id="display">
+          ${count}
+        </div>
+        <div class="card-actions justify-center gap-4">
+          <button class="btn btn-error" id="decrement">−</button>
+          <button class="btn btn-success" id="increment">+</button>
+          <button class="btn btn-ghost" id="reset">Reset</button>
+        </div>
+      </div>
+    </div>
+  `;
+
+  const display = container.querySelector<HTMLDivElement>("#display")!;
+  const incrementBtn = container.querySelector<HTMLButtonElement>("#increment")!;
+  const decrementBtn = container.querySelector<HTMLButtonElement>("#decrement")!;
+  const resetBtn = container.querySelector<HTMLButtonElement>("#reset")!;
+
+  const updateDisplay = (): void => {
+    display.textContent = count.toString();
+    display.className = `text-6xl font-bold my-4 ${
+      count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary"
+    }`;
+  };
+
+  incrementBtn.onclick = (): void => {
+    count++;
+    updateDisplay();
+  };
+  decrementBtn.onclick = (): void => {
+    count--;
+    updateDisplay();
+  };
+  resetBtn.onclick = (): void => {
+    count = 0;
+    updateDisplay();
+  };
+}
+
+
+
+
+

Nostr Event Publisher

+
+
+
import type { NostrEvent } from "./type-definitions.ts";
+
+export async function render(container: HTMLElement): Promise<void> {
+  container.innerHTML = `
+    <div class="card bg-base-100 shadow-xl max-w-2xl mx-auto">
+      <div class="card-body">
+        <h2 class="card-title">📝 Publish a Note</h2>
+
+        <div class="form-control">
+          <label class="label">
+            <span class="label-text">What's on your mind?</span>
+            <span class="label-text-alt" id="charCount">0/280</span>
+          </label>
+          <textarea
+            class="textarea textarea-bordered h-32"
+            id="noteContent"
+            placeholder="Share your thoughts with your CCN..."
+            maxlength="280">
+          </textarea>
+        </div>
+
+        <div class="card-actions justify-between items-center">
+          <div id="status" class="flex-1"></div>
+          <button class="btn btn-primary" id="publishBtn" disabled>
+            Publish Note
+          </button>
+        </div>
+      </div>
+    </div>
+  `;
+
+  const textarea = container.querySelector<HTMLTextAreaElement>("#noteContent")!;
+  const publishBtn = container.querySelector<HTMLButtonElement>("#publishBtn")!;
+  const status = container.querySelector<HTMLDivElement>("#status")!;
+  const charCount = container.querySelector<HTMLSpanElement>("#charCount")!;
+
+  textarea.oninput = (): void => {
+    const length: number = textarea.value.length;
+    charCount.textContent = `${length}/280`;
+    publishBtn.disabled = length === 0 || length > 280;
+  };
+
+  publishBtn.onclick = async (e): Promise<void> => {
+    const content: string = textarea.value.trim();
+    if (!content) return;
+
+    publishBtn.disabled = true;
+    publishBtn.textContent = "Publishing...";
+    status.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
+
+    try {
+      const unsignedEvent: NostrEvent = {
+        kind: 1, // Text note
+        content: content,
+        tags: [["client", "arxlet-publisher"]],
+        created_at: Math.floor(Date.now() / 1000),
+        pubkey: await window.eve.publicKey,
+      };
+
+      const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent);
+
+      await window.eve.publish(signedEvent);
+
+      status.innerHTML = `
+        <div class="alert alert-success">
+          <span>✅ Note published successfully!</span>
+        </div>
+      `;
+
+      textarea.value = "";
+      textarea.oninput?.(e);
+    } catch (error: unknown) {
+      const errorMessage = error instanceof Error ? error.message : "Unknown error";
+      console.error("Publishing failed:", error);
+      status.innerHTML = `
+        <div class="alert alert-error">
+          <span>❌ Failed to publish: ${errorMessage}</span>
+        </div>
+      `;
+    } finally {
+      publishBtn.disabled = false;
+      publishBtn.textContent = "Publish Note";
+    }
+  };
+}
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/pages/docs/arxlets/arxlet-docs.adoc b/src/pages/docs/arxlets/arxlet-docs.adoc new file mode 100644 index 0000000..94d6c00 --- /dev/null +++ b/src/pages/docs/arxlets/arxlet-docs.adoc @@ -0,0 +1,979 @@ += Arxlets API Context +:description: Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. +:doctype: book +:icons: font +:source-highlighter: highlight.js +:toc: left +:toclevels: 2 +:sectlinks: + +== Installing EveOS + +sudo coreos-installer install /dev/sda --ignition-url https://arx-ccn.com/eveos.ign + +// Overview Section +== Overview + +Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run in isolated iframes and are registered on your CCN (Closed Community Network) for member-only access. Arxlets provide a powerful way to build custom applications that interact with Nostr events and profiles through Eve. + +=== Core Concepts + +What are Arxlets? +- **Sandboxed Applications**: Run in isolated iframes for security +- **JavaScript-based**: Written in TypeScript/JavaScript with wasm support coming in the future +- **CCN Integration**: Registered on your Closed Community Network +- **Nostr-native**: Built-in access to Nostr protocol operations +- **Real-time**: Support for live event subscriptions and updates + +NOTE: WASM support will be added in future releases for even more powerful applications. + +=== CCN Local-First Architecture + +CCNs (Closed Community Networks) are designed with a local-first approach that ensures data availability and functionality even when offline: + +* **Local Data Storage**: All Nostr events and profiles are stored locally on your device, providing instant access without network dependencies +* **Offline Functionality**: Arxlets can read, display, and interact with locally cached data when disconnected from the internet +* **Sync When Connected**: When network connectivity is restored, the CCN automatically synchronizes with remote relays to fetch new events and propagate local changes +* **Resilient Operation**: Your applications continue to work seamlessly regardless of network conditions, making CCNs ideal for unreliable connectivity scenarios +* **Privacy by Design**: Local-first storage means your data remains on your device, reducing exposure to external services and improving privacy + +=== Architecture + +- **Frontend**: TypeScript applications with render functions +- **Backend**: Eve relay providing Nostr protocol access +- **Communication**: window.eve API or direct WebSocket connections + +// API Reference Section +== API Reference + +The primary interface for Arxlets to interact with Eve's Nostr relay. All methods return promises for async operations. + +=== window.eve API + +[source,typescript] +---- +// Using window.eve API for Nostr operations +import type { Filter, NostrEvent } from "./types"; + +// Publish a new event +const event: NostrEvent = { + kind: 1, + content: "Hello from my Arxlet!", + tags: [], + created_at: Math.floor(Date.now() / 1000), + pubkey: "your-pubkey-here", +}; + +await window.eve.publish(event); + +// Get a specific event by ID +const eventId = "event-id-here"; +const event = await window.eve.getSingleEventById(eventId); + +// Query events with a filter +const filter: Filter = { + kinds: [1], + authors: ["pubkey-here"], + limit: 10, +}; + +const singleEvent = await window.eve.getSingleEventWithFilter(filter); +const allEvents = await window.eve.getAllEventsWithFilter(filter); + +// Real-time subscription with RxJS Observable +const subscription = window.eve.subscribeToEvents(filter).subscribe({ + next: (event) => { + console.log("New event received:", event); + // Update your UI with the new event + }, + error: (err) => console.error("Subscription error:", err), + complete: () => console.log("Subscription completed"), +}); + +// Subscribe to profile updates for a specific user +const profileSubscription = window.eve.subscribeToProfile(pubkey).subscribe({ + next: (profile) => { + console.log("Profile updated:", profile); + // Update your UI with the new profile data + }, + error: (err) => console.error("Profile subscription error:", err), +}); + +// Don't forget to unsubscribe when done +// subscription.unsubscribe(); +// profileSubscription.unsubscribe(); + +// Get user profile and avatar +const pubkey = "user-pubkey-here"; +const profile = await window.eve.getProfile(pubkey); +const avatarUrl = await window.eve.getAvatar(pubkey); +---- + +=== Real-time Subscriptions + +[source,typescript] +---- +// Real-time subscription examples +import { filter, map, takeUntil } from "rxjs/operators"; + +// Basic subscription +const subscription = window.eve + .subscribeToEvents({ + kinds: [1], // Text notes + limit: 50, + }) + .subscribe((event) => { + console.log("New text note:", event.content); + }); + +// Advanced filtering with RxJS operators +const filteredSubscription = window.eve + .subscribeToEvents({ + kinds: [1, 6, 7], // Notes, reposts, reactions + authors: ["pubkey1", "pubkey2"], + }) + .pipe( + filter((event) => event.content.includes("#arxlet")), // Only events mentioning arxlets + map((event) => ({ + id: event.id, + author: event.pubkey, + content: event.content, + timestamp: new Date(event.created_at * 1000), + })), + ) + .subscribe({ + next: (processedEvent) => { + // Update your UI + updateEventsList(processedEvent); + }, + error: (err) => { + console.error("Subscription error:", err); + showErrorMessage("Failed to receive real-time updates"); + }, + }); + +// Profile subscription example +const profileSubscription = window.eve.subscribeToProfile("user-pubkey-here").subscribe({ + next: (profile) => { + console.log("Profile updated:", profile); + updateUserProfile(profile); + }, + error: (err) => { + console.error("Profile subscription error:", err); + }, +}); + +// Clean up subscriptions when component unmounts +// subscription.unsubscribe(); +// filteredSubscription.unsubscribe(); +// profileSubscription.unsubscribe(); +---- + +=== WebSocket Alternative + +For advanced use cases, connect directly to Eve's WebSocket relay, or use any nostr library. This is not recommended: + +[source,typescript] +---- +// Alternative: Direct WebSocket connection +const ws = new WebSocket("ws://localhost:6942"); + +ws.onopen = () => { + // Subscribe to events + ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1], limit: 10 }])); +}; + +ws.onmessage = (event) => { + const [type, subId, data] = JSON.parse(event.data); + if (type === "EVENT") { + console.log("Received event:", data); + } +}; + +// Publish an event +const signedEvent = await window.nostr.signEvent(unsignedEvent); +ws.send(JSON.stringify(["EVENT", signedEvent])); +---- + +// Type Definitions Section +== Type Definitions + +[source,typescript] +---- +import type { Observable } from "rxjs"; + +export interface NostrEvent { + id?: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig?: string; +} + +export interface Filter { + ids?: string[]; + authors?: string[]; + kinds?: number[]; + since?: number; + until?: number; + limit?: number; + [key: string]: any; +} + +export interface Profile { + name?: string; + about?: string; + picture?: string; + nip05?: string; + [key: string]: any; +} + +export interface WindowEve { + publish(event: NostrEvent): Promise; + getSingleEventById(id: string): Promise; + getSingleEventWithFilter(filter: Filter): Promise; + getAllEventsWithFilter(filter: Filter): Promise; + subscribeToEvents(filter: Filter): Observable; + subscribeToProfile(pubkey: string): Observable; + getProfile(pubkey: string): Promise; + getAvatar(pubkey: string): Promise; + signEvent(event: NostrEvent): Promise; + get publicKey(): Promise; +} + +declare global { + interface Window { + eve: WindowEve; + } +} +---- + +// Registration Section +== Registration + +Arxlets are registered using Nostr events with kind `30420`: + +[source,json] +---- +{ + "kind": 30420, + "tags": [ + ["d", "my-calculator"], + ["name", "Simple Calculator"], + ["description", "A basic calculator for quick math"], + ["script", "export function render(el) { /* your code */ }"], + ["icon", "mdi:calculator", "#3b82f6"] + ], + "content": "", + "created_at": 1735171200 +} +---- + +=== Required Tags +* `d`: Unique identifier (alphanumeric, hyphens, underscores) +* `name`: Human-readable display name +* `script`: Complete JavaScript code with render export function + +=== Optional Tags +* `description`: Brief description of functionality +* `icon`: Iconify icon name and hex color + +// Development Patterns Section +== Development Patterns + +=== Basic Arxlet Structure + +[source,typescript] +---- +/** + * Required export function - Entry point for your Arxlet + */ +export function render(container: HTMLElement): void { + // Initialize your application + container.innerHTML = ` +
+

My Arxlet

+

Hello from Eve!

+ +
+ `; + + // Add event listeners with proper typing + const button = container.querySelector("#myButton"); + button?.addEventListener("click", (): void => { + alert("Button clicked!"); + }); + + // Your app logic here... +} +---- + +=== Real-time Updates +[source,typescript] +---- +export function render(container: HTMLElement): void { + let subscription: Subscription; + + // Set up UI + container.innerHTML = `
`; + const eventsContainer = container.querySelector("#events"); + + // Subscribe to real-time events + subscription = window.eve + .subscribeToEvents({ + kinds: [1], + limit: 50, + }) + .subscribe({ + next: (event) => { + // Update UI with new event + const eventElement = document.createElement("div"); + eventElement.textContent = event.content; + eventsContainer?.prepend(eventElement); + }, + error: (err) => console.error("Subscription error:", err), + }); + + // Cleanup when arxlet is destroyed + window.addEventListener("beforeunload", () => { + subscription?.unsubscribe(); + }); +} +---- + +=== Publishing Events + +[source,typescript] +---- +import type { NostrEvent } from "./type-definitions.ts"; + +export async function render(container: HTMLElement): Promise { + container.innerHTML = ` +
+
+

📝 Publish a Note

+ +
+ + +
+ +
+
+ +
+
+
+ `; + + const textarea = container.querySelector("#noteContent")!; + const publishBtn = container.querySelector("#publishBtn")!; + const status = container.querySelector("#status")!; + const charCount = container.querySelector("#charCount")!; + + textarea.oninput = (): void => { + const length: number = textarea.value.length; + charCount.textContent = `${length}/280`; + publishBtn.disabled = length === 0 || length > 280; + }; + + publishBtn.onclick = async (e): Promise => { + const content: string = textarea.value.trim(); + if (!content) return; + + publishBtn.disabled = true; + publishBtn.textContent = "Publishing..."; + status.innerHTML = ''; + + try { + const unsignedEvent: NostrEvent = { + kind: 1, // Text note + content: content, + tags: [["client", "arxlet-publisher"]], + created_at: Math.floor(Date.now() / 1000), + pubkey: await window.eve.publicKey, + }; + + const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent); + + await window.eve.publish(signedEvent); + + status.innerHTML = ` +
+ ✅ Note published successfully! +
+ `; + + textarea.value = ""; + textarea.oninput?.(e); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Publishing failed:", error); + status.innerHTML = ` +
+ ❌ Failed to publish: ${errorMessage} +
+ `; + } finally { + publishBtn.disabled = false; + publishBtn.textContent = "Publish Note"; + } + }; +} +---- + +// Best Practices Section +== Best Practices + +=== Error Handling +- Always wrap API calls in try-catch blocks +- Check for null returns from query methods +- Provide user feedback for failed operations + +=== Performance +- Use specific filters to limit result sets +- Cache profile data to avoid repeated lookups +- Unsubscribe from observables when done +- Debounce rapid API calls +- Consider pagination for large datasets + +=== Security +- Validate all user inputs +- Sanitize content before displaying +- Use proper event signing for authenticity +- Follow principle of least privilege + +=== Memory Management +- Always unsubscribe from RxJS observables +- Clean up event listeners on component destruction +- Avoid memory leaks in long-running subscriptions +- Use weak references where appropriate + +// Common Use Cases Section +== Common Use Cases + +=== Social Feed +- Subscribe to events from followed users +- Display real-time updates +- Handle profile information and avatars +- Implement engagement features + +=== Publishing Tools +- Create and sign events +- Validate content before publishing +- Handle publishing errors gracefully +- Provide user feedback + +=== Data Visualization +- Query historical events +- Process and aggregate data +- Create interactive charts and graphs +- Real-time data updates + +=== Communication Apps +- Direct messaging interfaces +- Group chat functionality +- Notification systems +- Presence indicators + +// Framework Integration Section +== Framework Integration + +Arxlets support various JavaScript frameworks. All frameworks must export a `render` function that accepts a container element: + +=== Vanilla JavaScript + +[source,typescript] +---- +export function render(container: HTMLElement) { + let count: number = 0; + + container.innerHTML = ` +
+
+

Counter App

+
+ ${count} +
+
+ + + +
+
+
+ `; + + const display = container.querySelector("#display")!; + const incrementBtn = container.querySelector("#increment")!; + const decrementBtn = container.querySelector("#decrement")!; + const resetBtn = container.querySelector("#reset")!; + + const updateDisplay = (): void => { + display.textContent = count.toString(); + display.className = `text-6xl font-bold my-4 ${ + count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary" + }`; + }; + + incrementBtn.onclick = (): void => { + count++; + updateDisplay(); + }; + decrementBtn.onclick = (): void => { + count--; + updateDisplay(); + }; + resetBtn.onclick = (): void => { + count = 0; + updateDisplay(); + }; +} +---- + +=== Preact/React + +[source,tsx] +---- +// @jsx h +// @jsxImportSource preact + +import { render as renderPreact } from "preact"; +import { useState } from "preact/hooks"; + +const CounterApp = () => { + const [count, setCount] = useState(0); + const [message, setMessage] = useState(""); + + const increment = () => { + setCount((prev) => prev + 1); + setMessage(`Clicked ${count + 1} times!`); + }; + + const decrement = () => { + setCount((prev) => prev - 1); + setMessage(`Count decreased to ${count - 1}`); + }; + + const reset = () => { + setCount(0); + setMessage("Counter reset!"); + }; + + return ( +
+
+

Preact Counter

+ +
0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}`} + > + {count} +
+ +
+ + + +
+ + {message && ( +
+ {message} +
+ )} +
+
+ ); +}; + +export function render(container: HTMLElement): void { + renderPreact(, container); +} +---- + +=== Svelte + +[source,svelte] +---- + + +
+
+

🔥 Svelte Counter

+ +
+ {count} +
+ +
+ + + +
+ + {#if message} +
+ {message} +
+ {/if} +
+
+ + +---- + +=== Build Process +All frameworks require bundling into a single JavaScript file: + +[source,bash] +---- +# For TypeScript/JavaScript projects +bun build index.ts --outfile=build.js --minify --target=browser --production + +# The resulting build.js content goes in your registration event's script tag +---- + +==== Svelte Build Requirements + +IMPORTANT: The standard build command above will NOT work for Svelte projects. Svelte requires specific Vite configuration to compile properly. + +For Svelte arxlets: + +. Use the https://git.arx-ccn.com/Arx/arxlets-template[arxlets-template] which includes the correct Vite configuration +. Run `bun run build` instead of the standard build command +. Your compiled file will be available at `dist/bundle.js` + +While the initial setup is more complex, Svelte provides an excellent development experience once configured, with features like: + +- Built-in reactivity with runes (`$state()`, `$derived()`, etc.) +- Scoped CSS +- Compile-time optimizations +- No runtime overhead + +// Debugging and Development Section +== Debugging and Development + +=== Console Logging +- Use `console.log()` for debugging +- Events and errors are logged to browser console + +=== Error Handling +- Catch and log API errors +- Display user-friendly error messages +- Implement retry mechanisms for transient failures + +=== Testing +- Test with various event types and filters +- Verify subscription cleanup +- Test error scenarios and edge cases +- Validate event signing and publishing + +// Limitations and Considerations Section +== Limitations and Considerations + +=== Sandbox Restrictions +- Limited access to browser APIs +- No direct file system access +- Restricted network access (only to Eve relay) +- No access to parent window context + +=== Performance Constraints +- Iframe overhead for each arxlet +- Memory usage for subscriptions +- Event processing limitations + +=== Security Considerations +- All events are public on Nostr +- Private key management handled by Eve +- Content sanitization required +- XSS prevention necessary + +// DaisyUI Components Section +== DaisyUI Components + +Arxlets have access to DaisyUI 5, a comprehensive CSS component library. Use these pre-built components for consistent, accessible UI: + +=== Essential Components +[source,html] +---- + +
+
+

Card Title

+

Card content goes here

+
+ +
+
+
+ + + + + + + + + +
+ + +
+ + +
+ ✅ Success message +
+
+ ❌ Error message +
+ + + + + + + + + +---- + +=== Layout Utilities +[source,html] +---- + +
+
Content 1
+
Content 2
+
Content 3
+
+ + +
+ Left content + +
+ + +
+ +
+---- + +=== Color System +[source,html] +---- + +
Default background
+
Slightly darker
+
Primary color
+
Secondary color
+ + +Primary text +Success text +Error text +Default text +---- + +// Complete Example Patterns Section +== Complete Example Patterns + +=== Simple Counter Arxlet + +[source,typescript] +---- +export function render(container: HTMLElement) { + let count: number = 0; + + container.innerHTML = ` +
+
+

Counter App

+
+ ${count} +
+
+ + + +
+
+
+ `; + + const display = container.querySelector("#display")!; + const incrementBtn = container.querySelector("#increment")!; + const decrementBtn = container.querySelector("#decrement")!; + const resetBtn = container.querySelector("#reset")!; + + const updateDisplay = (): void => { + display.textContent = count.toString(); + display.className = `text-6xl font-bold my-4 ${ + count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary" + }`; + }; + + incrementBtn.onclick = (): void => { + count++; + updateDisplay(); + }; + decrementBtn.onclick = (): void => { + count--; + updateDisplay(); + }; + resetBtn.onclick = (): void => { + count = 0; + updateDisplay(); + }; +} +---- + +=== Nostr Event Publisher + +[source,typescript] +---- +import type { NostrEvent } from "./type-definitions.ts"; + +export async function render(container: HTMLElement): Promise { + container.innerHTML = ` +
+
+

📝 Publish a Note

+ +
+ + +
+ +
+
+ +
+
+
+ `; + + const textarea = container.querySelector("#noteContent")!; + const publishBtn = container.querySelector("#publishBtn")!; + const status = container.querySelector("#status")!; + const charCount = container.querySelector("#charCount")!; + + textarea.oninput = (): void => { + const length: number = textarea.value.length; + charCount.textContent = `${length}/280`; + publishBtn.disabled = length === 0 || length > 280; + }; + + publishBtn.onclick = async (e): Promise => { + const content: string = textarea.value.trim(); + if (!content) return; + + publishBtn.disabled = true; + publishBtn.textContent = "Publishing..."; + status.innerHTML = ''; + + try { + const unsignedEvent: NostrEvent = { + kind: 1, // Text note + content: content, + tags: [["client", "arxlet-publisher"]], + created_at: Math.floor(Date.now() / 1000), + pubkey: await window.eve.publicKey, + }; + + const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent); + + await window.eve.publish(signedEvent); + + status.innerHTML = ` +
+ ✅ Note published successfully! +
+ `; + + textarea.value = ""; + textarea.oninput?.(e); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Publishing failed:", error); + status.innerHTML = ` +
+ ❌ Failed to publish: ${errorMessage} +
+ `; + } finally { + publishBtn.disabled = false; + publishBtn.textContent = "Publish Note"; + } + }; +} +---- diff --git a/src/pages/docs/arxlets/arxlet-docs.css b/src/pages/docs/arxlets/arxlet-docs.css new file mode 100644 index 0000000..e80dde6 --- /dev/null +++ b/src/pages/docs/arxlets/arxlet-docs.css @@ -0,0 +1,746 @@ +@import url("https://esm.sh/prismjs/themes/prism-tomorrow.css"); + +html { + scroll-behavior: smooth; +} + +* { + transition: all 0.2s ease; +} + +code[class*="language-"], +pre[class*="language-"] { + font-family: "Fira Code", "Monaco", "Cascadia Code", "Roboto Mono", monospace; + font-size: 0.875rem; + line-height: 1.5; +} + +kbd { + border-radius: 25%; + color: white; + margin-right: 2px; +} + +.mockup-code pre::before { + display: none; +} + +/* Next-Level Sidebar Styling */ +.sidebar-container { + background: linear-gradient(180deg, hsl(var(--b2)) 0%, hsl(var(--b1)) 100%); + border-right: 1px solid hsl(var(--b3)); + position: relative; + overflow: hidden; +} + +.sidebar-container::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, hsl(var(--p) / 0.5), transparent); + animation: shimmer 3s ease-in-out infinite; +} + +@keyframes shimmer { + 0%, + 100% { + opacity: 0; + } + + 50% { + opacity: 1; + } +} + +/* Global Progress Bar */ +.global-progress-container { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 2px; + background: hsl(var(--b3) / 0.1); + z-index: 9999; + backdrop-filter: blur(10px); +} + +.global-progress-bar { + height: 100%; + background: cyan; + transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 0 12px hsl(var(--p) / 0.5), + 0 1px 3px hsl(var(--p) / 0.3); + position: relative; + border-radius: 0 1px 1px 0; +} + +.global-progress-bar::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, hsl(var(--p) / 0.8) 0%, hsl(var(--s) / 0.9) 50%, hsl(var(--a) / 0.8) 100%); + animation: shimmer-progress 2s ease-in-out infinite; +} + +.global-progress-bar::after { + content: ""; + position: absolute; + top: -1px; + right: -2px; + width: 4px; + height: 4px; + background: hsl(var(--pc)); + border-radius: 50%; + box-shadow: 0 0 6px hsl(var(--pc) / 0.8); + animation: pulse-dot 1.5s ease-in-out infinite; +} + +@keyframes shimmer-progress { + 0%, + 100% { + opacity: 0.8; + } + + 50% { + opacity: 1; + } +} + +@keyframes pulse-dot { + 0%, + 100% { + transform: scale(1); + opacity: 0.8; + } + + 50% { + transform: scale(1.2); + opacity: 1; + } +} + +/* Ensure content doesn't get hidden behind progress bar */ +body { + padding-top: 2px; +} + +/* Enhanced Header */ +.sidebar-header { + background: hsl(var(--b1)); + backdrop-filter: blur(10px); + border-bottom: 1px solid hsl(var(--b3) / 0.5); + position: relative; +} + +.header-icon { + position: relative; + overflow: hidden; +} + +.header-icon::before { + content: ""; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient(45deg, transparent, hsl(var(--pc) / 0.1), transparent); + animation: rotate 3s linear infinite; +} + +@keyframes rotate { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Navigation Container */ +.navigation-container { + position: relative; +} + +.navigation-container.scrolling { + background: hsl(var(--b2) / 0.95); +} + +.drawer-side .menu { + padding: 1rem; + gap: 0.5rem; +} + +/* Navigation Items */ +.nav-item { + animation: slideInLeft 0.6s ease-out; + animation-delay: calc(var(--item-index) * 0.1s); + animation-fill-mode: both; +} + +@keyframes slideInLeft { + from { + opacity: 0; + transform: translateX(-20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Section Links */ +.section-link { + font-weight: 600; + font-size: 0.95rem; + padding: 1rem; + border-radius: 0.75rem; + margin-bottom: 0.25rem; + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + gap: 0.75rem; + overflow: hidden; + backdrop-filter: blur(5px); +} + +.section-link::before { + content: ""; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, hsl(var(--p) / 0.1), transparent); + transition: left 0.5s ease; +} + +.section-link:hover::before { + left: 100%; +} + +.section-link:hover { + background: hsl(var(--b3) / 0.7); + transform: translateX(4px) scale(1.02); + box-shadow: 0 8px 25px hsl(var(--b3) / 0.3); +} + +.section-link.active { + background: linear-gradient(135deg, hsl(var(--p)) 0%, hsl(var(--s)) 100%); + color: hsl(var(--pc)); + font-weight: 700; + box-shadow: + 0 8px 25px hsl(var(--p) / 0.4), + 0 0 0 1px hsl(var(--p) / 0.2), + inset 0 1px 0 hsl(var(--pc) / 0.1); + transform: translateX(6px); +} + +.section-link.active::after { + content: ""; + position: absolute; + left: -1rem; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 70%; + background: linear-gradient(180deg, hsl(var(--p)), hsl(var(--s))); + border-radius: 2px; + box-shadow: 0 0 10px hsl(var(--p) / 0.5); +} + +/* Section Icon Container */ +.section-icon-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; +} + +.section-icon { + font-size: 1.1rem; + transition: all 0.3s ease; + position: relative; + z-index: 2; +} + +.icon-glow { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + background: radial-gradient(circle, hsl(var(--p) / 0.2) 0%, transparent 70%); + border-radius: 50%; + opacity: 0; + transition: opacity 0.3s ease; +} + +.section-link.active .icon-glow { + opacity: 1; + animation: pulse-glow 2s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 0.2; + } + + 50% { + transform: translate(-50%, -50%) scale(1.2); + opacity: 0.4; + } +} + +.section-link.active .section-icon { + transform: scale(1.1); + filter: drop-shadow(0 0 8px hsl(var(--pc) / 0.5)); +} + +/* Section Text */ +.section-text { + flex: 1; + transition: all 0.3s ease; +} + +.section-link.active .section-text { + text-shadow: 0 0 10px hsl(var(--pc) / 0.3); +} + +/* Section Badge */ +.section-badge { + display: flex; + align-items: center; +} + +.subsection-count { + background: hsl(var(--b3) / 0.5); + color: hsl(var(--bc) / 0.7); + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.5rem; + border-radius: 1rem; + min-width: 1.5rem; + text-align: center; + transition: all 0.3s ease; +} + +.section-link.active .subsection-count { + background: hsl(var(--pc) / 0.2); + color: hsl(var(--pc)); + box-shadow: 0 0 10px hsl(var(--pc) / 0.3); +} + +/* Enhanced Subsection Styling */ +.subsection-menu { + margin-left: 1rem; + margin-top: 0.75rem; + padding-left: 1.5rem; + border-left: 2px solid hsl(var(--b3) / 0.3); + gap: 0.25rem; + position: relative; + animation: slideDown 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.subsection-menu::before { + content: ""; + position: absolute; + left: -2px; + top: 0; + width: 2px; + height: 100%; + background: linear-gradient(180deg, hsl(var(--p) / 0.5), transparent); + transform: scaleY(0); + transform-origin: top; + animation: expandLine 0.6s ease-out 0.2s forwards; +} + +@keyframes expandLine { + to { + transform: scaleY(1); + } +} + +.subsection-menu li { + animation: slideInRight 0.4s ease-out; + animation-delay: calc(var(--sub-index) * 0.05s + 0.1s); + animation-fill-mode: both; +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(-15px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.subsection-link { + font-size: 0.875rem; + font-weight: 500; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + color: hsl(var(--bc) / 0.7); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.125rem; +} + +.subsection-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: hsl(var(--bc) / 0.3); + transition: all 0.3s ease; + position: relative; +} + +.subsection-dot::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 0; + height: 0; + background: hsl(var(--p)); + border-radius: 50%; + transition: all 0.3s ease; +} + +.subsection-text { + flex: 1; + transition: all 0.3s ease; +} + +.subsection-link:hover { + background: hsl(var(--b3) / 0.4); + color: hsl(var(--bc)); + transform: translateX(4px); + box-shadow: 0 4px 12px hsl(var(--b3) / 0.2); +} + +.subsection-link:hover .subsection-dot { + background: hsl(var(--p) / 0.7); + transform: scale(1.2); +} + +.subsection-link:hover .subsection-dot::after { + width: 12px; + height: 12px; +} + +.subsection-link.active { + background: linear-gradient(135deg, hsl(var(--p) / 0.15), hsl(var(--s) / 0.1)); + color: hsl(var(--p)); + font-weight: 600; + transform: translateX(6px); + box-shadow: + 0 4px 15px hsl(var(--p) / 0.2), + inset 0 1px 0 hsl(var(--p) / 0.1); + border-left: 3px solid hsl(var(--p)); + padding-left: calc(1rem - 3px); +} + +.subsection-link.active .subsection-dot { + background: hsl(var(--p)); + transform: scale(1.3); + box-shadow: 0 0 10px hsl(var(--p) / 0.5); +} + +.subsection-link.active .subsection-dot::after { + width: 16px; + height: 16px; + background: hsl(var(--p) / 0.3); + animation: ripple 1.5s ease-out infinite; +} + +@keyframes ripple { + 0% { + transform: translate(-50%, -50%) scale(0); + opacity: 1; + } + + 100% { + transform: translate(-50%, -50%) scale(2); + opacity: 0; + } +} + +.subsection-link.active .subsection-text { + text-shadow: 0 0 8px hsl(var(--p) / 0.3); +} + +/* Smooth animations for section changes */ +.drawer-side .menu ul { + animation: slideDown 0.3s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Enhanced scrollbar for sidebar */ +.drawer-side .menu { + scrollbar-width: thin; + scrollbar-color: hsl(var(--p) / 0.3) transparent; +} + +.drawer-side .menu::-webkit-scrollbar { + width: 6px; +} + +.drawer-side .menu::-webkit-scrollbar-track { + background: transparent; +} + +.drawer-side .menu::-webkit-scrollbar-thumb { + background: hsl(var(--p) / 0.3); + border-radius: 3px; +} + +.drawer-side .menu::-webkit-scrollbar-thumb:hover { + background: hsl(var(--p) / 0.5); +} + +/* Enhance +d Sidebar Structure */ +.drawer-side aside { + background: hsl(var(--b2)); + border-right: 1px solid hsl(var(--b3)); +} + +/* Section Links with Icons */ +.section-link { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.section-icon { + font-size: 1.1rem; + opacity: 0.8; + transition: all 0.2s ease; +} + +.section-link.active .section-icon { + opacity: 1; + transform: scale(1.1); +} + +/* Subsection Menu */ +.subsection-menu { + margin-left: 0.5rem; + margin-top: 0.5rem; + padding-left: 1rem; + border-left: 2px solid hsl(var(--b3)); + gap: 0.125rem; + animation: slideDown 0.3s ease-out; +} + +.subsection-link { + font-size: 0.875rem; + font-weight: 500; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + color: hsl(var(--bc) / 0.7); + transition: all 0.2s ease; + position: relative; + display: block; +} + +.subsection-link:hover { + background: hsl(var(--b3) / 0.5); + color: hsl(var(--bc)); + transform: translateX(2px); +} + +.subsection-link.active { + background: hsl(var(--p) / 0.1); + color: hsl(var(--p)); + font-weight: 600; + border-left: 3px solid hsl(var(--p)); + padding-left: calc(0.75rem - 3px); +} + +.subsection-link.active::before { + content: "▶"; + position: absolute; + left: -1.25rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.625rem; + color: hsl(var(--p)); +} + +/* Enhanced scrollbar for navigation */ +.drawer-side nav { + scrollbar-width: thin; + scrollbar-color: hsl(var(--p) / 0.3) transparent; +} + +.drawer-side nav::-webkit-scrollbar { + width: 6px; +} + +.drawer-side nav::-webkit-scrollbar-track { + background: transparent; +} + +.drawer-side nav::-webkit-scrollbar-thumb { + background: hsl(var(--p) / 0.3); + border-radius: 3px; +} + +.drawer-side nav::-webkit-scrollbar-thumb:hover { + background: hsl(var(--p) / 0.5); +} + +/* Enhanced Footer */ +.sidebar-footer { + background: linear-gradient(180deg, hsl(var(--b1)) 0%, hsl(var(--b2)) 100%); + border-top: 1px solid hsl(var(--b3) / 0.5); + backdrop-filter: blur(10px); +} + +/* Progress Dots */ +.progress-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: hsl(var(--bc) / 0.2); + transition: all 0.3s ease; + position: relative; +} + +.progress-dot.active { + background: hsl(var(--p)); + box-shadow: 0 0 10px hsl(var(--p) / 0.5); + transform: scale(1.2); +} + +.progress-dot.active::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 16px; + height: 16px; + border: 2px solid hsl(var(--p) / 0.3); + border-radius: 50%; + animation: expand-ring 2s ease-out infinite; +} + +@keyframes expand-ring { + 0% { + transform: translate(-50%, -50%) scale(0); + opacity: 1; + } + + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 0; + } +} + +/* Enhanced Animations */ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: 0.7; + transform: scale(1.05); + } +} + +/* Smooth Transitions */ +* { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Glassmorphism Effects */ +.sidebar-header, +.sidebar-footer { + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +/* Hover Glow Effects */ +.section-link:hover, +.subsection-link:hover { + position: relative; +} + +.section-link:hover::after { + content: ""; + position: absolute; + top: -2px; + left: -2px; + right: -2px; + bottom: -2px; + background: linear-gradient(45deg, hsl(var(--p) / 0.1), hsl(var(--s) / 0.1)); + border-radius: inherit; + z-index: -1; + filter: blur(4px); +} + +/* Responsive Enhancements */ +@media (max-width: 768px) { + .sidebar-container { + width: 100%; + } + + .section-link { + padding: 0.75rem; + } + + .subsection-link { + padding: 0.5rem 0.75rem; + } +} + +/* Dark Mode Optimizations */ +@media (prefers-color-scheme: dark) { + .sidebar-container { + background: linear-gradient(180deg, hsl(var(--b2)) 0%, hsl(220 13% 9%) 100%); + } + + .progress-bar { + box-shadow: 0 0 20px hsl(var(--p) / 0.4); + } +} diff --git a/src/pages/docs/arxlets/arxlet-docs.html b/src/pages/docs/arxlets/arxlet-docs.html new file mode 100644 index 0000000..858287f --- /dev/null +++ b/src/pages/docs/arxlets/arxlet-docs.html @@ -0,0 +1,26 @@ + + + + + + Arxlets Documentation - Eve + + + + + + + + + + + + diff --git a/src/pages/docs/arxlets/arxlet-docs.jsx b/src/pages/docs/arxlets/arxlet-docs.jsx new file mode 100644 index 0000000..ae55fbc --- /dev/null +++ b/src/pages/docs/arxlets/arxlet-docs.jsx @@ -0,0 +1,343 @@ +// @jsx h +// @jsxImportSource preact + +import { render } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import "./arxlet-docs.css"; + +import { APISection } from "./components/APISection.jsx"; +import { BestPracticesSection } from "./components/BestPracticesSection.jsx"; +import { DevelopmentSection } from "./components/DevelopmentSection.jsx"; +import { ExamplesSection } from "./components/ExamplesSection.jsx"; +import { LLMsSection } from "./components/LLMsSection.jsx"; +import { OverviewSection } from "./components/OverviewSection.jsx"; +import { RegistrationSection } from "./components/RegistrationSection.jsx"; +import { useSyntaxHighlighting } from "./hooks/useSyntaxHighlighting.js"; + +const SECTIONS = { + Overview: { + component: , + subsections: {}, + }, + Registration: { + component: , + subsections: { + "nostr-event-structure": "Nostr Event Structure", + "tag-reference": "Tag Reference", + }, + }, + Development: { + component: , + subsections: { + "understanding-arxlets": "Understanding the Arxlet Environment", + "nostr-vs-arxlets": "Nostr Apps vs Arxlets", + "available-apis": "Available APIs", + "security-restrictions": "Security Restrictions", + "typescript-development": "TypeScript Development", + "required-export-function": "Required Export Function", + }, + }, + "Best Practices": { + component: , + subsections: { + "error-handling": "Error Handling & Reliability", + performance: "Performance & Efficiency", + subscriptions: "Subscription Management", + "user-experience": "User Experience Excellence", + security: "Security & Privacy", + "production-checklist": "Production Readiness", + }, + }, + "API Reference": { + component: , + subsections: { + "window-eve-api": "window.eve API", + "real-time-subscriptions": "Real-time Subscriptions", + "websocket-alternative": "WebSocket Alternative", + "best-practices": "Best Practices", + }, + }, + Examples: { + component: , + subsections: { + vanilla: "Vanilla JS", + svelte: "Svelte", + preact: "Preact + JSX", + nostr: "Nostr Publisher", + }, + }, + LLMs: { + component: , + subsections: {}, + }, +}; + +const ArxletDocs = () => { + useSyntaxHighlighting(); + const [activeSection, setActiveSection] = useState("Overview"); + const [activeExample, setActiveExample] = useState("vanilla"); + const [activeSubsection, setActiveSubsection] = useState(""); + const [scrollProgress, setScrollProgress] = useState(0); + const [isScrolling, setIsScrolling] = useState(false); + + useEffect(() => { + const handleHashChange = () => { + const hash = window.location.hash.substring(1); + if (hash) { + setActiveExample(hash); + } + }; + + window.addEventListener("hashchange", handleHashChange); + handleHashChange(); + + return () => { + window.removeEventListener("hashchange", handleHashChange); + }; + }, []); + + // Enhanced Intersection Observer to track which subsection is currently visible + useEffect(() => { + const observerOptions = { + root: null, + rootMargin: "-10% 0px -60% 0px", // More responsive triggering + threshold: [0, 0.1, 0.5, 1.0], // Multiple thresholds for better detection + }; + + const visibleSections = new Set(); + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + visibleSections.add(entry.target.id); + } else { + visibleSections.delete(entry.target.id); + } + }); + + // Set the active subsection to the first visible one (topmost) + if (visibleSections.size > 0) { + const currentSectionData = SECTIONS[activeSection]; + if (currentSectionData?.subsections) { + const subsectionIds = Object.keys(currentSectionData.subsections); + const firstVisible = subsectionIds.find((id) => visibleSections.has(id)); + if (firstVisible) { + setActiveSubsection(firstVisible); + } + } + } + }, observerOptions); + + // Get all subsection IDs from the current section + const currentSectionData = SECTIONS[activeSection]; + if (currentSectionData?.subsections) { + const subsectionIds = Object.keys(currentSectionData.subsections); + + // Clear previous observations + visibleSections.clear(); + + // Observe elements with these IDs + subsectionIds.forEach((id) => { + const element = document.getElementById(id); + if (element) { + observer.observe(element); + } + }); + + return () => { + subsectionIds.forEach((id) => { + const element = document.getElementById(id); + if (element) { + observer.unobserve(element); + } + }); + visibleSections.clear(); + }; + } + }, [activeSection]); + + // Scroll progress tracking + useEffect(() => { + let scrollTimeout; + + const handleScroll = () => { + const scrollTop = window.scrollY; + const docHeight = document.documentElement.scrollHeight - window.innerHeight; + const progress = Math.min((scrollTop / docHeight) * 100, 100); + + setScrollProgress(progress); + setIsScrolling(true); + + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + setIsScrolling(false); + }, 150); + }; + + window.addEventListener("scroll", handleScroll); + return () => { + window.removeEventListener("scroll", handleScroll); + clearTimeout(scrollTimeout); + }; + }, []); + + useEffect(() => { + window.scrollTo(0, 0); + }, [activeSection, activeExample]); + + const renderContent = () => { + const section = SECTIONS[activeSection]; + if (!section) return
Section not found
; + + if (activeSection === "Examples") { + return ; + } + + return section.component; + }; + + const handleNavClick = (e, section) => { + e.preventDefault(); + setActiveSection(section); + window.location.hash = ""; + }; + + const handleSubNavClick = (e, subsectionId) => { + e.preventDefault(); + if (activeSection === "Examples") { + window.location.hash = subsectionId; + } else { + document.getElementById(subsectionId)?.scrollIntoView({ behavior: "smooth" }); + } + }; + + return ( +
+ {/* Global Progress Bar */} +
+
+
+ + +
+ {/* Header */} +
+
+
+ 🚀 +
+
+

Arxlets

+

Secure Applications for Eve

+
+
+
+
+ + {renderContent()} + + {/* Footer */} +
+
+

Arxlets Documentation • Eve

+

Build secure, sandboxed applications for your CCN

+
+
+
+
+ + +
+
+ ); +}; + +render(, document.body); diff --git a/src/pages/docs/arxlets/components/APISection.jsx b/src/pages/docs/arxlets/components/APISection.jsx new file mode 100644 index 0000000..1d9c459 --- /dev/null +++ b/src/pages/docs/arxlets/components/APISection.jsx @@ -0,0 +1,678 @@ +// @jsx h +// @jsxImportSource preact + +import eveApiExample from "../highlight/eve-api-example.ts" with { type: "text" }; +import subscriptionExamples from "../highlight/subscription-examples.ts" with { type: "text" }; +import websocketExample from "../highlight/websocket-example.ts" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; +import { CodeBlock } from "./CodeBlock.jsx"; + +/** + * API Section - Comprehensive guide to available APIs + * Covers window.eve API and WebSocket alternatives + */ +export const APISection = () => { + useSyntaxHighlighting(); + + return ( +
+

API Reference

+ +
+
+

Understanding Arxlet APIs

+
+

+ Your Arxlet has access to powerful APIs that let you interact with Nostr data, manage user profiles, and + create real-time applications. Think of these APIs as your toolkit for building social, decentralized + applications within the CCN ecosystem. +

+ +

+ Two approaches available: You can use the convenient window.eve API + (recommended for most cases) or connect directly via WebSocket for advanced scenarios. Both give you full + access to Nostr events and CCN features. +

+
+
+
+ +
+
+

🎯 Which API Should You Use?

+ +
+
+

✨ window.eve API (Recommended)

+

+ Best for most Arxlets. This high-level API handles all the complex Nostr protocol + details for you. +

+
+
+ + Simple promise-based functions +
+
+ + Automatic error handling +
+
+ + Built-in RxJS observables for real-time data +
+
+ + Profile and avatar helpers +
+
+ + Perfect for beginners +
+
+
+ +
+

⚡ Direct WebSocket

+

+ For advanced use cases. Direct connection to the Nostr relay with full protocol + control. +

+
+
+ + Maximum performance and control +
+
+ + Custom subscription management +
+
+ + Raw Nostr protocol access +
+
+ ! + Requires Nostr protocol knowledge +
+
+ ! + More complex error handling +
+
+
+
+ +
+ + 💡 Our Recommendation: Start with window.eve for your first Arxlet. You can + always switch to WebSocket later if you need more control or performance. + +
+
+
+ + {/* window.eve API */} +
+
+

+ 🚀 window.eve API - Your Main Toolkit +

+
+

+ The window.eve API is your primary interface for working with Nostr data in Arxlets. It + provides simple, promise-based functions that handle all the complex protocol details behind the scenes. +

+ +

+ How it works: Each function communicates with the local Nostr relay, processes the + results, and returns clean JavaScript objects. No need to understand Nostr protocol internals - just call + the functions and get your data. +

+
+ +
+
+

📤 Publishing & Writing Data

+
+
+
publish(event)
+

+ Publishes a Nostr event to the relay. This is how you save data, post messages, or create any + content. +

+

+ Use cases: Posting messages, saving user preferences, creating notes, updating + profiles +

+
Promise<void>
+
+ +
+
signEvent(event)
+

+ Signs an unsigned Nostr event with the user's private key. Required before publishing most events. +

+

+ Use cases: Preparing events for publication, authenticating user actions +

+
Promise<NostrEvent>
+
+
+
+ +
+

🔍 Reading & Querying Data

+
+
+
getSingleEventById(id)
+

+ Retrieves a specific event when you know its exact ID. Perfect for loading specific posts or data. +

+

+ Use cases: Loading a specific message, fetching referenced content, getting event + details +

+
Promise<NostrEvent | null>
+
+ +
+
getSingleEventWithFilter(filter)
+

+ Gets the first event matching your criteria. Useful when you expect only one result or want the most + recent. +

+

+ Use cases: Getting a user's latest profile, finding the most recent post, checking + if something exists +

+
Promise<NostrEvent | null>
+
+ +
+
getAllEventsWithFilter(filter)
+

+ Gets all events matching your criteria. Use this for lists, feeds, or when you need multiple + results. +

+

+ Use cases: Building feeds, loading message history, getting all posts by a user +

+
Promise<NostrEvent[]>
+
+
+
+ +
+

🔄 Real-time Subscriptions

+
+
+
subscribeToEvents(filter)
+

+ Creates a live stream of events matching your filter. Your app updates automatically when new events + arrive. +

+

+ Use cases: Live chat, real-time feeds, notifications, collaborative features +

+
Observable<NostrEvent>
+
+ +
+
subscribeToProfile(pubkey)
+

+ Watches for profile changes for a specific user. Updates automatically when they change their name, + bio, avatar, etc. +

+

+ Use cases: User profile displays, contact lists, member directories +

+
Observable<Profile>
+
+
+
+ +
+

👤 User & Profile Helpers

+
+
+
getProfile(pubkey)
+

+ Retrieves user profile information (name, bio, avatar, etc.) for any user by their public key. +

+

+ Use cases: Displaying user info, building contact lists, showing message authors +

+
Promise<Profile | null>
+
+ +
+
getAvatar(pubkey)
+

+ Quick helper to get just the avatar URL from a user's profile. Saves you from parsing the full + profile. +

+

+ Use cases: Profile pictures, user avatars in lists, message author images +

+
Promise<string | null>
+
+ +
+
publicKey
+

+ Gets the current user's public key. This identifies the user and is needed for many operations. +

+

+ Use cases: Identifying the current user, filtering their content, permission checks +

+
Promise<string>
+
+
+
+
+ +
+

Practical Example:

+

+ Here's how these functions work together in a real Arxlet. This example shows fetching events, displaying + user profiles, and handling real-time updates: +

+ +
+
+
+ + {/* Real-time Subscriptions */} +
+
+

+ 🔄 Understanding Real-time Subscriptions +

+ +
+

+ What are subscriptions? Think of subscriptions as "live feeds" that automatically notify + your Arxlet when new data arrives. Instead of repeatedly asking "is there new data?", subscriptions push + updates to you instantly. +

+ +

+ How they work: When you subscribe to events or profiles, you get an RxJS Observable - a + stream of data that flows over time. Your Arxlet can "listen" to this stream and update the UI whenever + new data arrives. +

+ +

+ Why use them? Subscriptions make your Arxlet feel alive and responsive. Users see new + messages instantly, profile changes update immediately, and collaborative features work in real-time. +

+
+ +
+
+

🎯 Event Subscriptions

+

+ subscribeToEvents(filter) gives you a live stream of events matching your criteria. +

+
+
+ Perfect for: +
+
    +
  • Live chat applications
  • +
  • Real-time feeds and timelines
  • +
  • Notification systems
  • +
  • Collaborative tools
  • +
  • Activity monitoring
  • +
+
+
+ +
+

👤 Profile Subscriptions

+

+ subscribeToProfile(pubkey) watches for changes to a specific user's profile. +

+
+
+ Perfect for: +
+
    +
  • User profile displays
  • +
  • Contact lists that stay current
  • +
  • Member directories
  • +
  • Avatar/name displays
  • +
  • User status indicators
  • +
+
+
+
+ +
+

How to Use Subscriptions:

+

+ Here's a complete example showing how to set up subscriptions, handle incoming data, and clean up + properly: +

+ +
+ +
+
+
+

! Memory Management

+
+

+ Always call unsubscribe() when: +

+
    +
  • Your component unmounts
  • +
  • User navigates away
  • +
  • You no longer need the data
  • +
  • Your Arxlet is closing
  • +
+

+ Why? Prevents memory leaks and unnecessary disk i/o. +

+
+
+
+ +
+
+

✨ Pro Tips

+
+
    +
  • Use specific filters to reduce data volume
  • +
  • Debounce rapid updates for better UX
  • +
  • Cache data to avoid duplicate processing
  • +
  • + Handle errors gracefully with catchError +
  • +
  • + Consider using takeUntil for automatic cleanup +
  • +
+
+
+
+
+
+
+ + {/* WebSocket Alternative */} +
+
+

+ 🔌 Direct WebSocket Connection - Advanced Usage +

+ +
+

+ What is the WebSocket approach? Instead of using the convenient window.eve{" "} + API, you can connect directly to the Nostr relay at ws://localhost:6942 and speak the raw + Nostr protocol. +

+ +

+ Why would you use this? Direct WebSocket gives you maximum control and performance. You + can implement custom subscription logic, handle multiple concurrent subscriptions efficiently, or + integrate with existing Nostr libraries. +

+ +

+ The trade-off: You'll need to understand the Nostr protocol, handle JSON message parsing, + manage connection states, and implement your own error handling. It's more work but gives you complete + flexibility. +

+
+ +
+

WebSocket Implementation Example:

+

+ Here's how to establish a WebSocket connection and communicate using standard Nostr protocol messages: +

+ +
+ +
+
+

✨ Use window.eve When:

+
+
+ + Building your first Arxlet +
+
+ + You want simple, clean code +
+
+ + Standard CRUD operations are enough +
+
+ + You prefer promise-based APIs +
+
+ + Built-in RxJS observables work for you +
+
+ + You don't need custom protocol handling +
+
+
+ +
+

⚡ Use WebSocket When:

+
+
+ + You need maximum performance +
+
+ + Custom subscription management required +
+
+ + Integrating existing Nostr libraries +
+
+ + You understand the Nostr protocol +
+
+ + Need fine-grained connection control +
+
+ + Building high-frequency applications +
+
+
+
+ +
+
+

🎯 Choosing the Right Approach

+
+

+ Start with window.eve: Even if you think you might need WebSocket later, begin with + the high-level API. You can always refactor specific parts to use WebSocket once you understand your + performance requirements. +

+

+ Hybrid approach: Many successful Arxlets use window.eve for most + operations and WebSocket only for specific high-performance features like real-time chat or live + collaboration. +

+

+ Migration path: The data structures are the same, so you can gradually migrate from{" "} + window.eve + to WebSocket for specific features without rewriting your entire application. +

+
+
+
+
+
+ + {/* Best Practices */} +
+
+

+ 💡 Best Practices for Robust Arxlets +

+ +
+
+

🛡 Error Handling & Reliability

+
+
+ Always use try-catch blocks: +

+ Network requests can fail, relays can be down, or data might be malformed. Wrap all API calls to + prevent crashes. +

+
+
+ Check for null/undefined returns: +

+ Query methods return null when no data is found. Always check before using the result. +

+
+
+ Provide meaningful user feedback: +

+ Show loading states, error messages, and success confirmations. Users should know what's happening. +

+
+
+ Implement retry logic for critical operations: +

Publishing events or loading essential data should retry on failure with exponential backoff.

+
+
+
+ +
+

⚡ Performance & Efficiency

+
+
+ Use specific, narrow filters: +

+ Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data + transfer. +

+
+
+ Cache frequently accessed data: +

Profile information, avatars, and static content should be cached to avoid repeated API calls.

+
+
+ Implement pagination for large datasets: +

+ Don't load thousands of events at once. Use limit and until parameters for + pagination. +

+
+
+ Debounce rapid user actions: +

+ If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the + relay. +

+
+
+ Unsubscribe from observables: +

Always clean up subscriptions to prevent memory leaks and unnecessary network traffic.

+
+
+
+ +
+

🎯 User Experience

+
+
+ Show loading states: +

Use spinners, skeletons, or progress indicators while data loads. Empty screens feel broken.

+
+
+ Handle empty states gracefully: +

When no data is found, show helpful messages or suggestions rather than blank areas.

+
+
+ Implement optimistic updates: +

+ Update the UI immediately when users take actions, then sync with the server. Makes apps feel + faster. +

+
+
+ Provide offline indicators: +

Let users know when they're disconnected or when operations might not work.

+
+
+
+ +
+

🔒 Security & Privacy

+
+
+ Validate all user inputs: +

Never trust user input. Validate, sanitize, and escape data before using it in events or UI.

+
+
+ Be mindful of public data: +

+ Remember that events are visible to everyone in your CCN by default. Don't accidentally expose + private information. +

+
+
+ Handle signing errors gracefully: +

Users might reject signing requests. Always have fallbacks and clear error messages.

+
+
+ Respect user privacy preferences: +

Some users prefer pseudonymous usage. Don't force real names or personal information.

+
+
+
+
+ +
+
+

🚀 Quick Checklist for Production Arxlets

+
+
+ Code Quality: +
    +
  • All API calls wrapped in try-catch
  • +
  • Null checks before using data
  • +
  • Subscriptions properly cleaned up
  • +
  • Input validation implemented
  • +
+
+
+ User Experience: +
    +
  • Loading states for all async operations
  • +
  • Error messages are user-friendly
  • +
  • Empty states handled gracefully
  • +
  • Performance tested with large datasets
  • +
+
+
+
+
+
+
+
+ ); +}; diff --git a/src/pages/docs/arxlets/components/BestPracticesSection.jsx b/src/pages/docs/arxlets/components/BestPracticesSection.jsx new file mode 100644 index 0000000..62cca25 --- /dev/null +++ b/src/pages/docs/arxlets/components/BestPracticesSection.jsx @@ -0,0 +1,628 @@ +// @jsx h +// @jsxImportSource preact + +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; +import { CodeBlock } from "./CodeBlock.jsx"; + +/** + * Best Practices Section - Comprehensive development guidelines for Arxlets + */ +export const BestPracticesSection = () => { + useSyntaxHighlighting(); + + const errorHandlingExample = `// Always wrap API calls in try-catch blocks +async function loadUserData() { + try { + const events = await window.eve.getAllEventsWithFilter({ + kinds: [0], // Profile events + limit: 10 + }); + + if (events.length === 0) { + showEmptyState("No profiles found"); + return; + } + + displayProfiles(events); + } catch (error) { + console.error("Failed to load profiles:", error); + showErrorMessage("Unable to load profiles. Please try again."); + } +} + +// Provide user feedback for all states +function showErrorMessage(message) { + const alert = document.createElement('div'); + alert.className = 'alert alert-error'; + alert.innerHTML = \`\${message}\`; + container.appendChild(alert); +}`; + + const performanceExample = `// Use specific filters to reduce data transfer +const efficientFilter = { + kinds: [1], // Only text notes + authors: [userPubkey], // Only from specific user + since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours + limit: 20 // Reasonable limit +}; + +// Cache frequently accessed data +const profileCache = new Map(); + +async function getCachedProfile(pubkey) { + if (profileCache.has(pubkey)) { + return profileCache.get(pubkey); + } + + const profile = await window.eve.getProfile(pubkey); + if (profile) { + profileCache.set(pubkey, profile); + } + return profile; +} + +// Debounce rapid user actions +let searchTimeout; +function handleSearchInput(query) { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + performSearch(query); + }, 300); // Wait 300ms after user stops typing +}`; + + const subscriptionExample = `// Proper subscription management +let eventSubscription; + +function startListening() { + eventSubscription = window.eve.subscribeToEvents({ + kinds: [1], + limit: 50 + }).subscribe({ + next: (event) => { + addEventToUI(event); + }, + error: (error) => { + console.error("Subscription error:", error); + showErrorMessage("Lost connection. Reconnecting..."); + // Implement retry logic + setTimeout(startListening, 5000); + } + }); +} + +// CRITICAL: Always clean up subscriptions +function cleanup() { + if (eventSubscription) { + eventSubscription.unsubscribe(); + eventSubscription = null; + } +} + +// Clean up when Arxlet is closed or user navigates away +window.addEventListener('beforeunload', cleanup);`; + + const uiExample = `// Use DaisyUI components for consistency +function createLoadingState() { + return \` +
+ + Loading profiles... +
+ \`; +} + +function createEmptyState() { + return \` +
+
📭
+

No messages yet

+

Be the first to start a conversation!

+ +
+ \`; +} + +// Implement optimistic updates for better UX +async function publishMessage(content) { + // Show message immediately (optimistic) + const tempId = 'temp-' + Date.now(); + addMessageToUI({ id: tempId, content, pending: true }); + + try { + const event = await window.eve.publish({ + kind: 1, + content: content, + tags: [] + }); + + // Replace temp message with real one + replaceMessageInUI(tempId, event); + } catch (error) { + // Remove temp message and show error + removeMessageFromUI(tempId); + showErrorMessage("Failed to send message"); + } +}`; + + return ( +
+
+

✨ Best Practices

+

+ Master the art of building production-ready Arxlets with these comprehensive development guidelines +

+
+ +
+
+

Building Production-Ready Arxlets

+
+

+ Creating a great Arxlet goes beyond just making it work - it needs to be reliable, performant, and provide + an excellent user experience. These best practices will help you build Arxlets that users love and that + work consistently in the CCN environment. +

+ +

+ Why these practices matter: Arxlets run in a shared environment where performance issues + can affect other applications, and users expect the same level of polish they get from native apps. + Following these guidelines ensures your Arxlet integrates seamlessly with the CCN ecosystem. +

+
+
+
+ +
+
+

+ 🛡 Error Handling & Reliability +

+ +
+

+ User experience first: When something goes wrong, users should know what happened and + what they can do about it. Silent failures are frustrating and make your app feel broken. +

+
+ +
+
+

Essential Error Handling Patterns

+
+
+ Wrap all API calls in try-catch blocks: +

+ Every call to window.eve functions can potentially fail. Always handle exceptions. +

+
+
+ Check for null/undefined returns: +

+ Query methods return null when no data is found. Verify results before using them. +

+
+
+ Provide meaningful user feedback: +

Show specific error messages that help users understand what went wrong and how to fix it.

+
+
+ Implement retry logic for critical operations: +

+ Publishing events or loading essential data should retry automatically with exponential backoff. +

+
+
+
+ +
+

Practical Error Handling Example:

+ +
+
+ +
+
+
+

❌ Common Mistakes

+
    +
  • Not handling API failures
  • +
  • Assuming data will always exist
  • +
  • Silent failures with no user feedback
  • +
  • Generic "Something went wrong" messages
  • +
  • No retry mechanisms for critical operations
  • +
+
+
+ +
+
+

✅ Best Practices

+
    +
  • Specific, actionable error messages
  • +
  • Graceful degradation when features fail
  • +
  • Loading states for all async operations
  • +
  • Retry buttons for failed operations
  • +
  • Offline indicators when appropriate
  • +
+
+
+
+
+
+ +
+
+

+ ⚡ Performance & Efficiency +

+ +
+
+

Performance Optimization Strategies

+
+
+ Use specific, narrow filters: +

+ Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data + transfer. +

+
+
+ Implement intelligent caching: +

Cache profile information, avatars, and other static content to avoid repeated API calls.

+
+
+ Paginate large datasets: +

+ Don't load thousands of events at once. Use limit and until parameters for + pagination. +

+
+
+ Debounce rapid user actions: +

+ If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the + relay. +

+
+
+
+ +
+

Performance Optimization Example:

+ +
+
+ +
+
+

! Performance Pitfalls to Avoid

+
+

+ Overly broad filters: Fetching all events and filtering client-side wastes bandwidth. +

+

+ No pagination: Loading thousands of items at once can freeze the interface. +

+

+ Repeated API calls: Fetching the same profile data multiple times is inefficient. +

+

+ Unthrottled user input: Search-as-you-type without debouncing can overwhelm the + relay. +

+
+
+
+
+
+ +
+
+

+ 🔄 Subscription Management +

+ +
+

+ Subscriptions power real-time features. They make your Arxlet feel alive by automatically + updating when new data arrives. However, they need careful management to prevent memory leaks. +

+ +

+ Clean up is critical. Forgetting to unsubscribe from observables can cause memory leaks, + unnecessary disk i/o, and performance degradation over time. +

+
+ +
+
+

Subscription Best Practices

+
+
+ Always store subscription references: +

Keep references to all subscriptions so you can unsubscribe when needed.

+
+
+ Implement proper cleanup: +

Unsubscribe when components unmount, users navigate away, or the Arxlet closes.

+
+
+ Use specific filters: +

Narrow subscription filters reduce unnecessary data and improve performance.

+
+
+
+ +
+

Proper Subscription Management:

+ +
+
+ +
+
+

🚨 Memory Leak Prevention

+
+

+ Always unsubscribe: Every subscribe() call must have a corresponding{" "} + unsubscribe(). +

+

+ Clean up on navigation: Users might navigate away without properly closing your + Arxlet. +

+

+ Handle page refresh: Use beforeunload event to clean up subscriptions. +

+

+ Monitor subscription count: Too many active subscriptions can impact performance. +

+
+
+
+
+
+ +
+
+

+ 🎯 User Experience Excellence +

+ +
+

+ Great UX makes the difference. Users expect responsive, intuitive interfaces that provide + clear feedback. Small details like loading states and empty state messages significantly impact user + satisfaction. +

+ +

+ Consistency with CCN design. Using DaisyUI components ensures your Arxlet feels + integrated with the rest of the platform while saving you development time. +

+
+ +
+
+

UX Best Practices

+
+
+ Show loading states for all async operations: +

Users should never see blank screens or wonder if something is happening.

+
+
+ Handle empty states gracefully: +

When no data is available, provide helpful messages or suggestions for next steps.

+
+
+ Implement optimistic updates: +

Update the UI immediately when users take actions, then sync with the server.

+
+
+ Use consistent DaisyUI components: +

Leverage the pre-built component library for consistent styling and behavior.

+
+
+
+ +
+

UI/UX Implementation Examples:

+ +
+
+ +
+
+

✨ Excellent UX Includes

+
    +
  • Loading spinners for async operations
  • +
  • Helpful empty state messages
  • +
  • Immediate feedback for user actions
  • +
  • Clear error messages with solutions
  • +
  • Consistent visual design
  • +
  • Accessible keyboard navigation
  • +
  • Responsive layout for different screen sizes
  • +
+
+ +
+

! UX Anti-patterns

+
    +
  • Blank screens during loading
  • +
  • No feedback for user actions
  • +
  • Generic or confusing error messages
  • +
  • Inconsistent styling with CCN
  • +
  • Broken layouts on mobile devices
  • +
  • Inaccessible interface elements
  • +
  • Slow or unresponsive interactions
  • +
+
+
+
+
+ +
+
+

+ 🔒 Security & Privacy Considerations +

+ +
+

+ Security is everyone's responsibility. Even though Arxlets run in a sandboxed + environment, you still need to validate inputs, handle user data responsibly, and follow security best + practices. +

+ +

+ Privacy by design. Remember that Nostr events are public by default. Be mindful of what + data you're storing and how you're handling user information. +

+
+ +
+
+

Security Best Practices

+
+
+ Validate all user inputs: +

Never trust user input. Validate, sanitize, and escape data before using it in events or UI.

+
+
+ Be mindful of public data: +

Nostr events are public by default. Don't accidentally expose private information.

+
+
+ Handle signing errors gracefully: +

Users might reject signing requests. Always have fallbacks and clear error messages.

+
+
+ Respect user privacy preferences: +

Some users prefer pseudonymous usage. Don't force real names or personal information.

+
+
+ Sanitize HTML content: +

If displaying user-generated content, sanitize it to prevent XSS attacks.

+
+
+
+
+ +
+
+

🚨 Security Checklist

+
+
+ Input Validation: +
    +
  • Validate all form inputs
  • +
  • Sanitize user-generated content
  • +
  • Check data types and ranges
  • +
  • Escape HTML when displaying content
  • +
+
+
+ Privacy Protection: +
    +
  • Don't store sensitive data in events
  • +
  • Respect user anonymity preferences
  • +
  • Handle signing rejections gracefully
  • +
  • Be transparent about data usage
  • +
+
+
+
+
+
+
+ +
+
+

+ 🚀 Production Readiness Checklist +

+ +
+

+ Before publishing your Arxlet, run through this comprehensive checklist to ensure it meets production + quality standards. A well-tested Arxlet provides a better user experience and reflects positively on the + entire CCN ecosystem. +

+
+ +
+
+
+

✅ Code Quality

+
    +
  • All API calls wrapped in try-catch blocks
  • +
  • Null/undefined checks before using data
  • +
  • Subscriptions properly cleaned up
  • +
  • Input validation implemented
  • +
  • Error handling with user feedback
  • +
  • Performance optimizations applied
  • +
  • Code is well-commented and organized
  • +
+
+ +
+

🎯 User Experience

+
    +
  • Loading states for all async operations
  • +
  • Error messages are user-friendly
  • +
  • Empty states handled gracefully
  • +
  • Consistent DaisyUI styling
  • +
  • Responsive design for mobile
  • +
  • Keyboard navigation works
  • +
  • Accessibility features implemented
  • +
+
+
+ +
+
+

🔒 Security & Privacy

+
    +
  • User inputs are validated and sanitized
  • +
  • No sensitive data in public events
  • +
  • Signing errors handled gracefully
  • +
  • Privacy preferences respected
  • +
  • HTML content properly escaped
  • +
  • No hardcoded secrets or keys
  • +
  • Data usage is transparent
  • +
+
+ +
+

⚡ Performance

+
    +
  • Efficient Nostr filters used
  • +
  • Data caching implemented
  • +
  • Pagination for large datasets
  • +
  • User actions are debounced
  • +
  • Memory leaks prevented
  • +
  • Bundle size optimized
  • +
  • Performance tested with large datasets
  • +
+
+
+
+ +
+
+

🎉 Ready for Production!

+

+ Once you've checked off all these items, your Arxlet is ready to provide an excellent experience for CCN + users. Remember that you can always iterate and improve based on user feedback and changing + requirements. +

+
+
+
+
+
+ ); +}; diff --git a/src/pages/docs/arxlets/components/CodeBlock.jsx b/src/pages/docs/arxlets/components/CodeBlock.jsx new file mode 100644 index 0000000..70bbbbd --- /dev/null +++ b/src/pages/docs/arxlets/components/CodeBlock.jsx @@ -0,0 +1,53 @@ +// @jsx h +// @jsxImportSource preact +import { useState } from "preact/hooks"; + +/** + * Reusable code block component with syntax highlighting and a copy button. + */ +export const CodeBlock = ({ language = "javascript", code }) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = () => { + if (code) { + navigator.clipboard.writeText(code.trim()); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } + }; + + return ( +
+ +
+        {code?.trim()}
+      
+
+ ); +}; diff --git a/src/pages/docs/arxlets/components/DevelopmentSection.jsx b/src/pages/docs/arxlets/components/DevelopmentSection.jsx new file mode 100644 index 0000000..d67717b --- /dev/null +++ b/src/pages/docs/arxlets/components/DevelopmentSection.jsx @@ -0,0 +1,471 @@ +// @jsx h +// @jsxImportSource preact + +import buildCommand from "../highlight/build-command.sh" with { type: "text" }; +import renderFunction from "../highlight/render-function.ts" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; +import { CodeBlock } from "./CodeBlock.jsx"; + +/** + * Development Section - Guide for building Arxlets + * Covers APIs, restrictions, and the required render function + */ +export const DevelopmentSection = () => { + useSyntaxHighlighting(); + return ( +
+

Development Guide

+ +
+
+

+ Understanding the Arxlet Environment +

+

+ When you build an Arxlet, you're creating a web application that runs inside the CCN. Think of it like + building a mini-website that has access to special CCN features and Nostr data. Here's what you have + available and what the limitations are: +

+
+
+ +
+
+

+ 🔄 Nostr Apps vs Arxlets: What's the Difference? +

+ +
+

+ If you're coming from the broader Nostr ecosystem, you might be wondering how Arxlets relate to regular + Nostr applications. Here's the key relationship to understand: +

+ +
+
+

✅ Nostr App → Arxlet

+

+ Most Nostr apps CAN become Arxlets with some modifications: +

+
    +
  • + Replace external API calls with window.eve or local relay +
  • +
  • Adapt the UI to work within a container element
  • +
  • Remove routing if it conflicts with CCN navigation
  • +
  • + Use the provided window.nostr for signing +
  • +
  • Bundle everything into a single JavaScript file
  • +
+
+ +
+

! Arxlet → Nostr App

+

+ Not every Arxlet works as a standalone Nostr app because: +

+
    +
  • + May depend on CCN-specific APIs (window.eve) +
  • +
  • Designed for the sandboxed environment
  • +
  • Might rely on CCN member data or community features
  • +
  • UI optimized for container-based rendering
  • +
  • No independent relay connections
  • +
+
+
+
+ +
+
+

💡 Practical Examples:

+
+

+ Easy to Port: A simple note-taking app, image gallery, or profile viewer can usually + be adapted to work as both. +

+

+ CCN-Specific: A CCN member directory, community chat, or collaborative workspace + might only make sense as an Arxlet. +

+

+ Hybrid Approach: Many developers create a core library that works in both + environments, then build different interfaces for standalone vs Arxlet deployment. +

+
+
+
+
+
+ +
+
+

+ ✅ What You Can Use +

+ +
+
+

window.eve - Your CCN Toolkit

+

+ This is your main interface to the CCN. It provides functions to read and write Nostr events, manage + data, and interact with other CCN members. +

+

+ What it does: Lets you fetch events, publish new ones, manage user data, and access + CCN-specific features like member directories. +

+
+ +
+

window.nostr - Cryptographic Signing (NIP-07)

+

+ This is the standard Nostr extension API that lets your Arxlet create and sign events using the user's + private key. +

+

+ What it does: Allows your app to publish events on behalf of the user, like posting + messages, creating profiles, or any other Nostr activity that requires authentication. +

+
+ +
+

DaisyUI 5 - Pre-built UI Components

+

+ A complete CSS framework with beautiful, accessible components already loaded and ready to use. +

+

+ What it does: Provides buttons, cards, modals, forms, and dozens of other UI components + with consistent styling. No need to write CSS from scratch. +

+
+ +
+

Local Relay Connection

+

+ Direct WebSocket connection to ws://localhost:6942 for real-time Nostr event streaming. +

+

+ What it does: Lets you subscribe to live event feeds, get real-time updates, and + implement features like live chat or notifications. +

+
+ +
+

CCN Member Events

+

Access to events from other members of your current CCN community.

+

+ What it does: Enables community features like member directories, shared content, group + discussions, and collaborative tools. +

+
+ +
+

Standard Web APIs

+

+ Full access to modern browser APIs like localStorage, fetch (for local requests), DOM manipulation, and + more. +

+

+ What it does: Everything you'd expect in a web app - store data locally, manipulate the + page, handle user interactions, etc. +

+
+
+
+
+ +
+
+

+ 🔒 Security & Limitations +

+ +
+
+

No External Network Access

+

+ You can't make HTTP requests to external websites or APIs. All data must come through the CCN. This + prevents data leaks and ensures all communication goes through Nostr protocols. +

+
+ +
+

CCN-Scoped Data

+

+ You only have access to events and data from your current CCN community. You can't see events from other + CCNs or the broader Nostr network unless they're specifically shared with your community. +

+
+
+ +
+ + 💡 Why These Restrictions? These limitations ensure your Arxlet is secure, respects user + privacy, and works reliably within the CCN ecosystem. They also make your app more predictable and easier + to debug. + +
+
+
+ +
+
+
+ + 📚 Need More Details? Check the{" "} + {" "} + tab for comprehensive documentation of the window.eve API, code examples, and WebSocket usage + patterns. + +
+
+
+ +
+
+

+ 🚀 Building Your Arxlet with TypeScript +

+ +
+

+ What is TypeScript? TypeScript is JavaScript with type checking. It helps catch errors + before your code runs and provides better autocomplete in your editor. While you can write Arxlets in + plain JavaScript, TypeScript makes development much smoother. +

+ +

+ Why use it for Arxlets? Eve provides TypeScript definitions for all APIs, so you'll get + autocomplete for window.eve functions, proper error checking, and better documentation right + in your editor. +

+
+ +
+

Building Your Code

+

+ Since Arxlets need to be a single JavaScript file, you'll use Bun (a fast JavaScript + runtime and bundler) to compile your TypeScript code. Here's the command that does everything: +

+ + +
+
+
+

+ ⚠️ Svelte Exception: The above build command will NOT work for Svelte projects. + Svelte requires specific Vite configuration to compile properly. Instead, use our{" "} + + arxlets-template + {" "} + and simply run bun run build. Your compiled file will + be available at dist/bundle.js once built. +

+
+
+
+
+ +
+
+

What Each Build Option Does:

+
+
+ --minify +

+ Removes whitespace and shortens variable names to make your file smaller. Smaller files load faster. +

+
+
+ --target=browser +

Tells Bun to optimize the code for web browsers instead of server environments.

+
+
+ --production +

+ Enables all optimizations and removes development-only code for better performance. +

+
+
+
+ +
+

Why TypeScript Helps:

+
+
+ Catch Errors Early +

+ TypeScript finds mistakes like typos in function names or wrong parameter types before you run your + code. +

+
+
+ Better Autocomplete +

+ Your editor will suggest available functions and show you what parameters they expect. +

+
+
+ Easier Refactoring +

+ When you rename functions or change interfaces, TypeScript helps update all the places that use + them. +

+
+
+ Self-Documenting Code +

+ Type annotations serve as inline documentation, making your code easier to understand later. +

+
+
+
+
+ +
+
+

Recommended Development Workflow:

+
    +
  1. + Create your main file as index.ts (TypeScript) +
  2. +
  3. Write your Arxlet code with full TypeScript features
  4. +
  5. + Run the build command to create build.js +
  6. +
  7. + Copy the contents of build.js into your Nostr registration event +
  8. +
  9. Repeat steps 2-4 as you develop and test
  10. +
+
+
+
+
+ +
+
+

+ The Heart of Your Arxlet: The Render Function +

+ +
+

+ What is the render function? This is the main entry point of your Arxlet - think of it as + the main() + function in other programming languages. When someone opens your Arxlet, Eve calls this function and gives + it a container element where your app should display itself. +

+ +

+ How it works: The platform creates an empty <div> element and passes + it to your render function. Your job is to fill that container with your app's interface - buttons, forms, + content, whatever your Arxlet does. +

+ +

+ Why this pattern? This approach gives you complete control over your app's interface + while keeping it isolated from other Arxlets and the main CCN interface. +

+
+ +

Basic Structure:

+ + +
+

Breaking Down the Example:

+ +
+
+ export function render(container: HTMLElement) +

+ This declares your main function. The container parameter is the DOM element where your + app will live. The export keyword makes it available to the CCN. +

+
+ +
+ container.innerHTML = '...' +

+ This is the simplest way to add content - just set the HTML directly. For simple Arxlets, this might + be all you need. +

+
+ +
+ const button = container.querySelector('button') +

+ After adding HTML, you can find elements and attach event listeners to make your app interactive. +

+
+ +
+ window.eve.getEvents(...) +

+ This shows how to use the CCN API to fetch Nostr events. Most Arxlets will interact with Nostr data in + some way. +

+
+
+
+ +
+
+

Development Tips:

+
    +
  • + Start Simple: Begin with basic HTML and gradually add interactivity +
  • +
  • + Use Modern JavaScript: async/await, destructuring, arrow functions - it all works +
  • +
  • + Leverage DaisyUI: Use pre-built components instead of writing CSS from scratch +
  • +
  • + Handle Errors: Wrap API calls in try/catch blocks for better user experience +
  • +
  • + Think Reactive: Update the UI when data changes, don't just set it once +
  • +
+
+
+ +
+ + 💡 Advanced Patterns: You can use any frontend framework (React, Vue, Svelte) by + rendering it into the container, or build complex apps with routing, state management, and real-time + updates. The render function is just your starting point! + +
+
+
+
+ ); +}; diff --git a/src/pages/docs/arxlets/components/ExamplesSection.jsx b/src/pages/docs/arxlets/components/ExamplesSection.jsx new file mode 100644 index 0000000..df0164c --- /dev/null +++ b/src/pages/docs/arxlets/components/ExamplesSection.jsx @@ -0,0 +1,60 @@ +// @jsx h +// @jsxImportSource preact + +import { CounterExample } from "../examples/CounterExample.jsx"; +import { NostrPublisherExample } from "../examples/NostrPublisherExample.jsx"; +import { PreactCounterExample } from "../examples/PreactCounterExample.jsx"; +import { SvelteCounterExample } from "../examples/SvelteCounterExample.jsx"; + +const examples = { + vanilla: , + preact: , + svelte: , + nostr: , +}; + +/** + * Examples Section - Practical Arxlet implementations + * Shows real-world examples with detailed explanations + */ +export const ExamplesSection = ({ activeExample }) => { + const ActiveComponent = examples[activeExample] ||
Example not found
; + + return ( +
+

Example Arxlets

+ +
+
+
+

+ Framework Freedom: These examples show basic implementations, but you're not limited to + vanilla JavaScript! You can use any framework you prefer - React with JSX, Preact, Vue, Svelte, or any + other modern framework. Bun's powerful plugin system supports transpilation and bundling for virtually any + JavaScript ecosystem. +

+

+ Check out{" "} + + Bun's plugin documentation + {" "} + to see how you can integrate your preferred tools and frameworks. +

+

+ If you have any questions or run into problems, feel free to reach out to the team @ Arx (builders of Eve) + on Nostr: npub1ven4zk8xxw873876gx8y9g9l9fazkye9qnwnglcptgvfwxmygscqsxddfhif +

+
+
+
+ + {/* Tab Content */} +
{ActiveComponent}
+
+ ); +}; diff --git a/src/pages/docs/arxlets/components/LLMsSection.jsx b/src/pages/docs/arxlets/components/LLMsSection.jsx new file mode 100644 index 0000000..a789381 --- /dev/null +++ b/src/pages/docs/arxlets/components/LLMsSection.jsx @@ -0,0 +1,15 @@ +// @jsx h +// @jsxImportSource preact + +import contextMd from "../highlight/context.md" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; +import { CodeBlock } from "./CodeBlock.jsx"; + +export const LLMsSection = () => { + useSyntaxHighlighting(); + return ( +
+ +
+ ); +}; diff --git a/src/pages/docs/arxlets/components/OverviewSection.jsx b/src/pages/docs/arxlets/components/OverviewSection.jsx new file mode 100644 index 0000000..5e93274 --- /dev/null +++ b/src/pages/docs/arxlets/components/OverviewSection.jsx @@ -0,0 +1,50 @@ +// @jsx h +// @jsxImportSource preact + +import typeDefinitions from "../highlight/type-definitions.ts" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; +import { CodeBlock } from "./CodeBlock.jsx"; + +/** + * Overview Section - Introduction to Arxlets + * Explains what Arxlets are and their key features + */ +export const OverviewSection = () => { + useSyntaxHighlighting(); + return ( +
+
+
+

What are Arxlets?

+

+ Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run inside Eve + and are registered on your CCN for member-only access. +

+
+
+ +
+ + Coming Soon: WASM support will be added in future releases for even more powerful + applications. + +
+ +
+
+

📝 TypeScript Definitions

+

Use these type definitions for full TypeScript support in your Arxlets:

+ + + +
+ + Pro Tip: Save these types in a types.ts file and import them throughout your + Arxlet for better development experience and type safety. + +
+
+
+
+ ); +}; diff --git a/src/pages/docs/arxlets/components/RegistrationSection.jsx b/src/pages/docs/arxlets/components/RegistrationSection.jsx new file mode 100644 index 0000000..75dfdc8 --- /dev/null +++ b/src/pages/docs/arxlets/components/RegistrationSection.jsx @@ -0,0 +1,147 @@ +// @jsx h +// @jsxImportSource preact + +import registrationEvent from "../highlight/registration-event.json" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; +import { CodeBlock } from "./CodeBlock.jsx"; + +/** + * Registration Section - How to register Arxlets on CCN + * Covers the Nostr event structure and required fields + */ +export const RegistrationSection = () => { + useSyntaxHighlighting(); + return ( +
+

Arxlet Registration

+ +
+
+

What are Nostr Events?

+
+

+ Nostr (Notes and Other Stuff Transmitted by Relays) is a decentralized protocol where all + data is stored as events. Think of events as structured messages that contain information + and are cryptographically signed by their authors. +

+

Each event has:

+
    +
  • + Kind: A number that defines what type of data the event contains (like a category) +
  • +
  • + Content: The main data or message +
  • +
  • + Tags: Additional metadata organized as key-value pairs +
  • +
  • + Signature: Cryptographic proof that the author created this event +
  • +
+

+ To register your Arxlet, you create a special event (kind 30420) that tells the CCN about your + application. This event acts like a "business card" for your Arxlet, containing its code, name, and other + details. If you publish this event publicly outside your CCN, this will be available in the arxlet store, + and any CCN will be able to install it. +

+
+
+
+ +
+
+

+ Nostr Event Structure +

+

+ Register your Arxlet using a replaceable Nostr event with kind{" "} + 30420: +

+ + +
+
+ +
+
+

Tag Reference

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TagRequiredDescriptionExample
+ d + + Required + Unique identifier (alphanumeric, hyphens, underscores) + my-todo-app +
+ name + + Required + Human-readable display name + Todo Manager +
+ description + + Optional + Brief description of functionality + Manage your tasks +
+ script + + Required + Complete JavaScript code with render export + export function render... +
+ icon + + Optional + Iconify icon name and hex color + mdi:check-circle, #10b981 +
+
+
+
+
+ ); +}; diff --git a/src/pages/docs/arxlets/examples/CounterExample.jsx b/src/pages/docs/arxlets/examples/CounterExample.jsx new file mode 100644 index 0000000..96c6acc --- /dev/null +++ b/src/pages/docs/arxlets/examples/CounterExample.jsx @@ -0,0 +1,20 @@ +// @jsx h +// @jsxImportSource preact + +import { CodeBlock } from "../components/CodeBlock.jsx"; +import code from "../highlight/counter.ts" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; + +export const CounterExample = () => { + useSyntaxHighlighting(); + return ( +
+
+

🔢 Interactive Counter

+

A simple counter demonstrating state management and event handling:

+ + +
+
+ ); +}; diff --git a/src/pages/docs/arxlets/examples/DevelopmentTips.jsx b/src/pages/docs/arxlets/examples/DevelopmentTips.jsx new file mode 100644 index 0000000..e3e4a03 --- /dev/null +++ b/src/pages/docs/arxlets/examples/DevelopmentTips.jsx @@ -0,0 +1,623 @@ +// @jsx h +// @jsxImportSource preact + +import { CodeBlock } from "../components/CodeBlock.jsx"; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; + +/** + * Development Tips Component - Comprehensive best practices for Arxlet development + */ +export const DevelopmentTips = () => { + useSyntaxHighlighting(); + + const errorHandlingExample = `// Always wrap API calls in try-catch blocks +async function loadUserData() { + try { + const events = await window.eve.getAllEventsWithFilter({ + kinds: [0], // Profile events + limit: 10 + }); + + if (events.length === 0) { + showEmptyState("No profiles found"); + return; + } + + displayProfiles(events); + } catch (error) { + console.error("Failed to load profiles:", error); + showErrorMessage("Unable to load profiles. Please try again."); + } +} + +// Provide user feedback for all states +function showErrorMessage(message) { + const alert = document.createElement('div'); + alert.className = 'alert alert-error'; + alert.innerHTML = \`\${message}\`; + container.appendChild(alert); +}`; + + const performanceExample = `// Use specific filters to reduce data transfer +const efficientFilter = { + kinds: [1], // Only text notes + authors: [userPubkey], // Only from specific user + since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours + limit: 20 // Reasonable limit +}; + +// Cache frequently accessed data +const profileCache = new Map(); + +async function getCachedProfile(pubkey) { + if (profileCache.has(pubkey)) { + return profileCache.get(pubkey); + } + + const profile = await window.eve.getProfile(pubkey); + if (profile) { + profileCache.set(pubkey, profile); + } + return profile; +} + +// Debounce rapid user actions +let searchTimeout; +function handleSearchInput(query) { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + performSearch(query); + }, 300); // Wait 300ms after user stops typing +}`; + + const subscriptionExample = `// Proper subscription management +let eventSubscription; + +function startListening() { + eventSubscription = window.eve.subscribeToEvents({ + kinds: [1], + limit: 50 + }).subscribe({ + next: (event) => { + addEventToUI(event); + }, + error: (error) => { + console.error("Subscription error:", error); + showErrorMessage("Lost connection. Reconnecting..."); + // Implement retry logic + setTimeout(startListening, 5000); + } + }); +} + +// CRITICAL: Always clean up subscriptions +function cleanup() { + if (eventSubscription) { + eventSubscription.unsubscribe(); + eventSubscription = null; + } +} + +// Clean up when Arxlet is closed or user navigates away +window.addEventListener('beforeunload', cleanup);`; + + const uiExample = `// Use DaisyUI components for consistency +function createLoadingState() { + return \` +
+ + Loading profiles... +
+ \`; +} + +function createEmptyState() { + return \` +
+
📭
+

No messages yet

+

Be the first to start a conversation!

+ +
+ \`; +} + +// Implement optimistic updates for better UX +async function publishMessage(content) { + // Show message immediately (optimistic) + const tempId = 'temp-' + Date.now(); + addMessageToUI({ id: tempId, content, pending: true }); + + try { + const event = await window.eve.publish({ + kind: 1, + content: content, + tags: [] + }); + + // Replace temp message with real one + replaceMessageInUI(tempId, event); + } catch (error) { + // Remove temp message and show error + removeMessageFromUI(tempId); + showErrorMessage("Failed to send message"); + } +}`; + + return ( +
+

💡 Development Best Practices

+ +
+
+

Building Production-Ready Arxlets

+
+

+ Creating a great Arxlet goes beyond just making it work - it needs to be reliable, performant, and provide + an excellent user experience. These best practices will help you build Arxlets that users love and that + work consistently in the CCN environment. +

+ +

+ Why these practices matter: Arxlets run in a shared environment where performance issues + can affect other applications, and users expect the same level of polish they get from native apps. + Following these guidelines ensures your Arxlet integrates seamlessly with the CCN ecosystem. +

+
+
+
+ +
+
+

+ 🛡 Error Handling & Reliability +

+ +
+

+ User experience first: When something goes wrong, users should know what happened and + what they can do about it. Silent failures are frustrating and make your app feel broken. +

+
+ +
+
+

Essential Error Handling Patterns

+
+
+ Wrap all API calls in try-catch blocks: +

+ Every call to window.eve functions can potentially fail. Always handle exceptions. +

+
+
+ Check for null/undefined returns: +

+ Query methods return null when no data is found. Verify results before using them. +

+
+
+ Provide meaningful user feedback: +

Show specific error messages that help users understand what went wrong and how to fix it.

+
+
+ Implement retry logic for critical operations: +

+ Publishing events or loading essential data should retry automatically with exponential backoff. +

+
+
+
+ +
+

Practical Error Handling Example:

+ +
+
+ +
+
+
+

❌ Common Mistakes

+
    +
  • Not handling API failures
  • +
  • Assuming data will always exist
  • +
  • Silent failures with no user feedback
  • +
  • Generic "Something went wrong" messages
  • +
  • No retry mechanisms for critical operations
  • +
+
+
+ +
+
+

✅ Best Practices

+
    +
  • Specific, actionable error messages
  • +
  • Graceful degradation when features fail
  • +
  • Loading states for all async operations
  • +
  • Retry buttons for failed operations
  • +
  • Offline indicators when appropriate
  • +
+
+
+
+
+
+ +
+
+

+ ⚡ Performance & Efficiency +

+ +
+
+

Performance Optimization Strategies

+
+
+ Use specific, narrow filters: +

+ Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data + transfer. +

+
+
+ Implement intelligent caching: +

Cache profile information, avatars, and other static content to avoid repeated API calls.

+
+
+ Paginate large datasets: +

+ Don't load thousands of events at once. Use limit and until parameters for + pagination. +

+
+
+ Debounce rapid user actions: +

+ If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the + relay. +

+
+
+
+ +
+

Performance Optimization Example:

+ +
+
+ +
+
+

! Performance Pitfalls to Avoid

+
+

+ Overly broad filters: Fetching all events and filtering client-side wastes bandwidth. +

+

+ No pagination: Loading thousands of items at once can freeze the interface. +

+

+ Repeated API calls: Fetching the same profile data multiple times is inefficient. +

+

+ Unthrottled user input: Search-as-you-type without debouncing can overwhelm the + relay. +

+
+
+
+
+
+ +
+
+

+ 🔄 Subscription Management +

+ +
+

+ Subscriptions power real-time features. They make your Arxlet feel alive by automatically + updating when new data arrives. However, they need careful management to prevent memory leaks. +

+ +

+ Clean up is critical. Forgetting to unsubscribe from observables can cause memory leaks, + unnecessary i/o usage, and performance degradation over time. +

+
+ +
+
+

Subscription Best Practices

+
+
+ Always store subscription references: +

Keep references to all subscriptions so you can unsubscribe when needed.

+
+
+ Implement proper cleanup: +

Unsubscribe when components unmount, users navigate away, or the Arxlet closes.

+
+
+ Use specific filters: +

Narrow subscription filters reduce unnecessary data and improve performance.

+
+
+
+ +
+

Proper Subscription Management:

+ +
+
+ +
+
+

🚨 Memory Leak Prevention

+
+

+ Always unsubscribe: Every subscribe() call must have a corresponding{" "} + unsubscribe(). +

+

+ Clean up on navigation: Users might navigate away without properly closing your + Arxlet. +

+

+ Handle page refresh: Use beforeunload event to clean up subscriptions. +

+

+ Monitor subscription count: Too many active subscriptions can impact performance. +

+
+
+
+
+
+ +
+
+

+ 🎯 User Experience Excellence +

+ +
+

+ Great UX makes the difference. Users expect responsive, intuitive interfaces that provide + clear feedback. Small details like loading states and empty state messages significantly impact user + satisfaction. +

+ +

+ Consistency with CCN design. Using DaisyUI components ensures your Arxlet feels + integrated with the rest of the platform while saving you development time. +

+
+ +
+
+

UX Best Practices

+
+
+ Show loading states for all async operations: +

Users should never see blank screens or wonder if something is happening.

+
+
+ Handle empty states gracefully: +

When no data is available, provide helpful messages or suggestions for next steps.

+
+
+ Implement optimistic updates: +

Update the UI immediately when users take actions, then sync with the server.

+
+
+ Use consistent DaisyUI components: +

Leverage the pre-built component library for consistent styling and behavior.

+
+
+
+ +
+

UI/UX Implementation Examples:

+ +
+
+ +
+
+

✨ Excellent UX Includes

+
    +
  • Loading spinners for async operations
  • +
  • Helpful empty state messages
  • +
  • Immediate feedback for user actions
  • +
  • Clear error messages with solutions
  • +
  • Consistent visual design
  • +
  • Accessible keyboard navigation
  • +
  • Responsive layout for different screen sizes
  • +
+
+ +
+

! UX Anti-patterns

+
    +
  • Blank screens during loading
  • +
  • No feedback for user actions
  • +
  • Generic or confusing error messages
  • +
  • Inconsistent styling with CCN
  • +
  • Broken layouts on mobile devices
  • +
  • Inaccessible interface elements
  • +
  • Slow or unresponsive interactions
  • +
+
+
+
+
+ +
+
+

+ 🔒 Security & Privacy Considerations +

+ +
+

+ Security is everyone's responsibility. Even though Arxlets run in a sandboxed + environment, you still need to validate inputs, handle user data responsibly, and follow security best + practices. +

+ +

+ Privacy by design. Remember that events are visible to everyone in your CCN by default. + Be mindful of what data you're storing and how you're handling user information. +

+
+ +
+
+

Security Best Practices

+
+
+ Validate all user inputs: +

Never trust user input. Validate, sanitize, and escape data before using it in events or UI.

+
+
+ Be mindful of public data: +

Nostr events are public by default. Don't accidentally expose private information.

+
+
+ Handle signing errors gracefully: +

Users might reject signing requests. Always have fallbacks and clear error messages.

+
+
+ Respect user privacy preferences: +

Some users prefer pseudonymous usage. Don't force real names or personal information.

+
+
+ Sanitize HTML content: +

If displaying user-generated content, sanitize it to prevent XSS attacks.

+
+
+
+
+ +
+
+

🚨 Security Checklist

+
+
+ Input Validation: +
    +
  • Validate all form inputs
  • +
  • Sanitize user-generated content
  • +
  • Check data types and ranges
  • +
  • Escape HTML when displaying content
  • +
+
+
+ Privacy Protection: +
    +
  • Don't store sensitive data in events
  • +
  • Respect user anonymity preferences
  • +
  • Handle signing rejections gracefully
  • +
  • Be transparent about data usage
  • +
+
+
+
+
+
+
+ +
+
+

+ 🚀 Production Readiness Checklist +

+ +
+

+ Before publishing your Arxlet, run through this comprehensive checklist to ensure it meets production + quality standards. A well-tested Arxlet provides a better user experience and reflects positively on the + entire CCN ecosystem. +

+
+ +
+
+
+

✅ Code Quality

+
    +
  • All API calls wrapped in try-catch blocks
  • +
  • Null/undefined checks before using data
  • +
  • Subscriptions properly cleaned up
  • +
  • Input validation implemented
  • +
  • Error handling with user feedback
  • +
  • Performance optimizations applied
  • +
  • Code is well-commented and organized
  • +
+
+ +
+

🎯 User Experience

+
    +
  • Loading states for all async operations
  • +
  • Error messages are user-friendly
  • +
  • Empty states handled gracefully
  • +
  • Consistent DaisyUI styling
  • +
  • Responsive design for mobile
  • +
  • Keyboard navigation works
  • +
  • Accessibility features implemented
  • +
+
+
+ +
+
+

🔒 Security & Privacy

+
    +
  • User inputs are validated and sanitized
  • +
  • No sensitive data in public events
  • +
  • Signing errors handled gracefully
  • +
  • Privacy preferences respected
  • +
  • HTML content properly escaped
  • +
  • No hardcoded secrets or keys
  • +
  • Data usage is transparent
  • +
+
+ +
+

⚡ Performance

+
    +
  • Efficient Nostr filters used
  • +
  • Data caching implemented
  • +
  • Pagination for large datasets
  • +
  • User actions are debounced
  • +
  • Memory leaks prevented
  • +
  • Bundle size optimized
  • +
  • Performance tested with large datasets
  • +
+
+
+
+ +
+
+

🎉 Ready for Production!

+

+ Once you've checked off all these items, your Arxlet is ready to provide an excellent experience for CCN + users. Remember that you can always iterate and improve based on user feedback and changing + requirements. +

+
+
+
+
+
+ ); +}; diff --git a/src/pages/docs/arxlets/examples/NostrPublisherExample.jsx b/src/pages/docs/arxlets/examples/NostrPublisherExample.jsx new file mode 100644 index 0000000..8e3b1b4 --- /dev/null +++ b/src/pages/docs/arxlets/examples/NostrPublisherExample.jsx @@ -0,0 +1,20 @@ +// @jsx h +// @jsxImportSource preact + +import { CodeBlock } from "../components/CodeBlock.jsx"; +import code from "../highlight/nostr-publisher.ts" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; + +export const NostrPublisherExample = () => { + useSyntaxHighlighting(); + return ( +
+
+

📝 Nostr Note Publisher

+

Publish notes to your CCN using the window.eve API:

+ + +
+
+ ); +}; diff --git a/src/pages/docs/arxlets/examples/PreactCounterExample.jsx b/src/pages/docs/arxlets/examples/PreactCounterExample.jsx new file mode 100644 index 0000000..2930b61 --- /dev/null +++ b/src/pages/docs/arxlets/examples/PreactCounterExample.jsx @@ -0,0 +1,20 @@ +// @jsx h +// @jsxImportSource preact + +import { CodeBlock } from "../components/CodeBlock.jsx"; +import code from "../highlight/preact-counter.tsx" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; + +export const PreactCounterExample = () => { + useSyntaxHighlighting(); + return ( +
+
+

⚛ Preact Counter with JSX

+

A modern counter using Preact hooks and JSX syntax:

+ + +
+
+ ); +}; diff --git a/src/pages/docs/arxlets/examples/SvelteCounterExample.jsx b/src/pages/docs/arxlets/examples/SvelteCounterExample.jsx new file mode 100644 index 0000000..2ffb133 --- /dev/null +++ b/src/pages/docs/arxlets/examples/SvelteCounterExample.jsx @@ -0,0 +1,55 @@ +// @jsx h +// @jsxImportSource preact + +import { CodeBlock } from "../components/CodeBlock.jsx"; +import code from "../highlight/svelte-counter.svelte" with { type: "text" }; +import { useSyntaxHighlighting } from "../hooks/useSyntaxHighlighting.js"; + +export const SvelteCounterExample = () => { + useSyntaxHighlighting(); + return ( +
+
+

🔥 Svelte Counter

+

A reactive counter built with Svelte's elegant syntax and built-in reactivity:

+ +
+
+
+

+ Why Svelte? Svelte compiles to vanilla JavaScript with no runtime overhead, making it + perfect for arxlets. Features like runes ($state(),{" "} + $derived(), etc), scoped CSS and intuitive event handling make development a + breeze. +

+
+
+
+ +
+
+
+

+ Build Setup Note: Building Svelte for arxlets requires specific Vite configuration to + handle the compilation properly. While this adds some initial complexity, we've created a ready-to-use + template at{" "} + + arxlets-template + {" "} + with all the correct configurations. We still highly recommend Svelte because once set up, the + development experience is incredibly smooth and optimal. +

+
+
+
+ + +
+
+ ); +}; diff --git a/src/pages/docs/arxlets/highlight/build-command.sh b/src/pages/docs/arxlets/highlight/build-command.sh new file mode 100644 index 0000000..873ecda --- /dev/null +++ b/src/pages/docs/arxlets/highlight/build-command.sh @@ -0,0 +1 @@ +bun build --minify --outfile=build.js --target=browser --production index.ts diff --git a/src/pages/docs/arxlets/highlight/context.md b/src/pages/docs/arxlets/highlight/context.md new file mode 100644 index 0000000..ca145c4 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/context.md @@ -0,0 +1,718 @@ +# Arxlets API Context + +## Overview + +Arxlets are secure, sandboxed JavaScript applications that extend Eve's functionality. They run in isolated iframes and are registered on your CCN (Closed Community Network) for member-only access. Arxlets provide a powerful way to build custom applications that interact with Nostr events and profiles through Eve. + +## Core Concepts + +### What are Arxlets? + +- **Sandboxed Applications**: Run in isolated iframes for security +- **JavaScript-based**: Written in TypeScript/JavaScript with wasm support coming in the future +- **CCN Integration**: Registered on your Closed Community Network +- **Nostr-native**: Built-in access to Nostr protocol operations +- **Real-time**: Support for live event subscriptions and updates + +### CCN Local-First Architecture + +CCNs (Closed Community Networks) are designed with a local-first approach that ensures data availability and functionality even when offline: + +- **Local Data Storage**: All Nostr events and profiles are stored locally on your device, providing instant access without network dependencies +- **Offline Functionality**: Arxlets can read, display, and interact with locally cached data when disconnected from the internet +- **Sync When Connected**: When network connectivity is restored, the CCN automatically synchronizes with remote relays to fetch new events and propagate local changes +- **Resilient Operation**: Your applications continue to work seamlessly regardless of network conditions, making CCNs ideal for unreliable connectivity scenarios +- **Privacy by Design**: Local-first storage means your data remains on your device, reducing exposure to external services and improving privacy + +### Architecture + +- **Frontend**: TypeScript applications with render functions +- **Backend**: Eve relay providing Nostr protocol access +- **Communication**: window.eve API or direct WebSocket connections + +## API Reference + +### window.eve API + +The primary interface for Arxlets to interact with Eve's Nostr relay. All methods return promises for async operations. + +#### Event Operations + +```typescript +// Publish a Nostr event +await window.eve.publish(event: NostrEvent): Promise + +// Get a specific event by ID +const event = await window.eve.getSingleEventById(id: string): Promise + +// Get first event matching filter +const event = await window.eve.getSingleEventWithFilter(filter: Filter): Promise + +// Get all events matching filter +const events = await window.eve.getAllEventsWithFilter(filter: Filter): Promise +``` + +#### Real-time Subscriptions + +```typescript +// Subscribe to events with RxJS Observable +const subscription = window.eve.subscribeToEvents(filter: Filter): Observable + +// Subscribe to profile updates +const profileSub = window.eve.subscribeToProfile(pubkey: string): Observable + +// Always unsubscribe when done +subscription.unsubscribe() +``` + +#### Profile Operations + +```typescript +// Get user profile +const profile = await window.eve.getProfile(pubkey: string): Promise + +// Get user avatar URL +const avatarUrl = await window.eve.getAvatar(pubkey: string): Promise +``` + +#### Cryptographic Operations + +```typescript +// Sign an unsigned event +const signedEvent = await window.eve.signEvent(event: NostrEvent): Promise + +// Get current user's public key +const pubkey = await window.eve.publicKey: Promise +``` + +### WebSocket Alternative + +For advanced use cases, connect directly to Eve's WebSocket relay, or use any nostr library. This is not recommended: + +```typescript +const ws = new WebSocket("ws://localhost:6942"); + +// Send Nostr protocol messages +ws.send(JSON.stringify(["REQ", subscriptionId, filter])); +ws.send(JSON.stringify(["EVENT", event])); +ws.send(JSON.stringify(["CLOSE", subscriptionId])); +``` + +## Type Definitions + +```typescript +import { Observable } from "rxjs"; + +interface NostrEvent { + id?: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig?: string; +} + +interface Filter { + ids?: string[]; + authors?: string[]; + kinds?: number[]; + since?: number; + until?: number; + limit?: number; + [key: string]: any; +} + +interface Profile { + name?: string; + about?: string; + picture?: string; + nip05?: string; + [key: string]: any; +} + +interface WindowEve { + publish(event: NostrEvent): Promise; + getSingleEventById(id: string): Promise; + getSingleEventWithFilter(filter: Filter): Promise; + getAllEventsWithFilter(filter: Filter): Promise; + subscribeToEvents(filter: Filter): Observable; + subscribeToProfile(pubkey: string): Observable; + getProfile(pubkey: string): Promise; + getAvatar(pubkey: string): Promise; + signEvent(event: NostrEvent): Promise; + get publicKey(): Promise; +} + +// Global declarations for TypeScript +declare global { + interface Window { + eve: WindowEve; + nostr?: { + getPublicKey(): Promise; + signEvent(event: NostrEvent): Promise; + getRelays?(): Promise<{ + "ws://localhost:6942": { read: true; write: true }; + }>; + }; + } +} +``` + +## Registration + +Arxlets are registered using Nostr events with kind `30420`: + +```json +{ + "kind": 30420, + "content": "", + "tags": [ + ["d", "unique-arxlet-id"], + ["name", "Display Name"], + ["description", "Brief description"], + ["script", "export function render(container) { /* implementation */ }"], + ["icon", "mdi:icon-name, #hexcolor"] + ] +} +``` + +### Required Tags + +- `d`: Unique identifier (alphanumeric, hyphens, underscores) +- `name`: Human-readable display name +- `script`: Complete JavaScript code with render export function + +### Optional Tags + +- `description`: Brief description of functionality +- `icon`: Iconify icon name and hex color + +## Development Patterns + +### Basic Arxlet Structure + +```typescript +export function render(container: HTMLElement): void { + // Set up your UI + container.innerHTML = ` +
+
+

My Arxlet

+ +
+
+ `; + + // Add event listeners and logic + const button = container.querySelector("#myButton"); + button?.addEventListener("click", handleClick); +} + +async function handleClick() { + try { + // Use window.eve API + const events = await window.eve.getAllEventsWithFilter({ + kinds: [1], + limit: 10, + }); + // Update UI with events + } catch (error) { + console.error("Failed to fetch events:", error); + } +} +``` + +### Real-time Updates + +```typescript +export function render(container: HTMLElement): void { + let subscription: Subscription; + + // Set up UI + container.innerHTML = `
`; + const eventsContainer = container.querySelector("#events"); + + // Subscribe to real-time events + subscription = window.eve + .subscribeToEvents({ + kinds: [1], + limit: 50, + }) + .subscribe({ + next: (event) => { + // Update UI with new event + const eventElement = document.createElement("div"); + eventElement.textContent = event.content; + eventsContainer?.prepend(eventElement); + }, + error: (err) => console.error("Subscription error:", err), + }); + + // Cleanup when arxlet is destroyed + window.addEventListener("beforeunload", () => { + subscription?.unsubscribe(); + }); +} +``` + +### Publishing Events + +```typescript +async function publishNote(content: string): Promise { + try { + const unsignedEvent: NostrEvent = { + kind: 1, + content: content, + tags: [["client", "my-arxlet"]], + created_at: Math.floor(Date.now() / 1000), + pubkey: await window.eve.publicKey, + }; + + const signedEvent = await window.eve.signEvent(unsignedEvent); + await window.eve.publish(signedEvent); + + console.log("Event published successfully"); + } catch (error) { + console.error("Failed to publish event:", error); + throw error; + } +} +``` + +## Best Practices + +### Error Handling + +- Always wrap API calls in try-catch blocks +- Check for null returns from query methods +- Provide user feedback for failed operations + +### Performance + +- Use specific filters to limit result sets +- Cache profile data to avoid repeated lookups +- Unsubscribe from observables when done +- Debounce rapid API calls +- Consider pagination for large datasets + +### Security + +- Validate all user inputs +- Sanitize content before displaying +- Use proper event signing for authenticity +- Follow principle of least privilege + +### Memory Management + +- Always unsubscribe from RxJS observables +- Clean up event listeners on component destruction +- Avoid memory leaks in long-running subscriptions +- Use weak references where appropriate + +## Common Use Cases + +### Social Feed + +- Subscribe to events from followed users +- Display real-time updates +- Handle profile information and avatars +- Implement engagement features + +### Publishing Tools + +- Create and sign events +- Validate content before publishing +- Handle publishing errors gracefully +- Provide user feedback + +### Data Visualization + +- Query historical events +- Process and aggregate data +- Create interactive charts and graphs +- Real-time data updates + +### Communication Apps + +- Direct messaging interfaces +- Group chat functionality +- Notification systems +- Presence indicators + +## Framework Integration + +Arxlets support various JavaScript frameworks. All frameworks must export a `render` function that accepts a container element: + +### Vanilla JavaScript + +```typescript +export function render(container: HTMLElement): void { + // Set up your UI with direct DOM manipulation + container.innerHTML = ` +
+
+

My App

+ +
+
+ `; + + // Add event listeners + const button = container.querySelector("#myButton"); + button?.addEventListener("click", () => { + console.log("Button clicked!"); + }); +} +``` + +### Preact/React + +```typescript +// @jsx h +// @jsxImportSource preact +import { render } from 'preact'; +import { useState } from 'preact/hooks'; + +const App = () => { + const [count, setCount] = useState(0); + + return ( +
+
+

Counter: {count}

+ +
+
+ ); +}; + +export function render(container: HTMLElement): void { + render(, container); +} +``` + +### Svelte + +```typescript +import { mount } from "svelte"; +import "./app.css"; +import App from "./App.svelte"; + +export function render(container: HTMLElement) { + return mount(App, { + target: container, + }); +} +``` + +### Build Process + +All frameworks require bundling into a single JavaScript file: + +```bash +# For TypeScript/JavaScript projects +bun build index.ts --outfile=build.js --minify --target=browser --production + +# The resulting build.js content goes in your registration event's script tag +``` + +#### Svelte Build Requirements + +**Important:** The standard build command above will NOT work for Svelte projects. Svelte requires specific Vite configuration to compile properly. + +For Svelte arxlets: + +1. Use the [arxlets-template](https://git.arx-ccn.com/Arx/arxlets-template) which includes the correct Vite configuration +2. Run `bun run build` instead of the standard build command +3. Your compiled file will be available at `dist/bundle.js` + +While the initial setup is more complex, Svelte provides an excellent development experience once configured, with features like: + +- Built-in reactivity with runes (`$state()`, `$derived()`, etc.) +- Scoped CSS +- Compile-time optimizations +- No runtime overhead + +## Debugging and Development + +### Console Logging + +- Use `console.log()` for debugging +- Events and errors are logged to browser console + +### Error Handling + +- Catch and log API errors +- Display user-friendly error messages +- Implement retry mechanisms for transient failures + +### Testing + +- Test with various event types and filters +- Verify subscription cleanup +- Test error scenarios and edge cases +- Validate event signing and publishing + +## Limitations and Considerations + +### Sandbox Restrictions + +- Limited access to browser APIs +- No direct file system access +- Restricted network access (only to Eve relay) +- No access to parent window context + +### Performance Constraints + +- Iframe overhead for each arxlet +- Memory usage for subscriptions +- Event processing limitations + +### Security Considerations + +- All events are public on Nostr +- Private key management handled by Eve +- Content sanitization required +- XSS prevention necessary + +## DaisyUI Components + +Arxlets have access to DaisyUI 5, a comprehensive CSS component library. Use these pre-built components for consistent, accessible UI: + +### Essential Components + +```html + +
+
+

Card Title

+

Card content goes here

+
+ +
+
+
+ + + + + + + + + +
+ + +
+ + +
+ ✅ Success message +
+
+ ❌ Error message +
+ + + + + + + + + +``` + +### Layout Utilities + +```html + +
+
Content 1
+
Content 2
+
Content 3
+
+ + +
+ Left content + +
+ + +
+ +
+``` + +### Color System + +```html + +
Default background
+
Slightly darker
+
Primary color
+
Secondary color
+ + +Primary text +Success text +Error text +Default text +``` + +## Complete Example Patterns + +### Simple Counter Arxlet + +```typescript +export function render(container: HTMLElement): void { + let count = 0; + + function updateUI() { + container.innerHTML = ` +
+
+

Counter

+
+ ${count} +
+
+ + + +
+
+
+ `; + + // Attach event listeners + container.querySelector("#increment")?.addEventListener("click", () => { + count++; + updateUI(); + }); + + container.querySelector("#decrement")?.addEventListener("click", () => { + count--; + updateUI(); + }); + + container.querySelector("#reset")?.addEventListener("click", () => { + count = 0; + updateUI(); + }); + } + + updateUI(); +} +``` + +### Nostr Event Publisher + +```typescript +export async function render(container: HTMLElement): Promise { + container.innerHTML = ` +
+
+

📝 Publish a Note

+
+ + +
+
+
+ +
+
+
+ `; + + const textarea = + container.querySelector("#noteContent")!; + const publishBtn = container.querySelector("#publishBtn")!; + const status = container.querySelector("#status")!; + const charCount = container.querySelector("#charCount")!; + + textarea.oninput = () => { + const length = textarea.value.length; + charCount.textContent = `${length}/280`; + publishBtn.disabled = length === 0 || length > 280; + }; + + publishBtn.onclick = async () => { + const content = textarea.value.trim(); + if (!content) return; + + publishBtn.disabled = true; + publishBtn.textContent = "Publishing..."; + status.innerHTML = + ''; + + try { + const unsignedEvent: NostrEvent = { + kind: 1, + content: content, + tags: [["client", "my-arxlet"]], + created_at: Math.floor(Date.now() / 1000), + pubkey: await window.eve.publicKey, + }; + + const signedEvent = await window.eve.signEvent(unsignedEvent); + await window.eve.publish(signedEvent); + + status.innerHTML = ` +
+ ✅ Note published successfully! +
+ `; + + textarea.value = ""; + textarea.oninput?.(); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error("Publishing failed:", error); + status.innerHTML = ` +
+ ❌ Failed to publish: ${errorMessage} +
+ `; + } finally { + publishBtn.disabled = false; + publishBtn.textContent = "Publish Note"; + } + }; +} +``` + +This context provides comprehensive information about the Arxlets API, enabling LLMs to understand and work with the system effectively. diff --git a/src/pages/docs/arxlets/highlight/counter.ts b/src/pages/docs/arxlets/highlight/counter.ts new file mode 100644 index 0000000..f41ded2 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/counter.ts @@ -0,0 +1,44 @@ +export function render(container: HTMLElement) { + let count: number = 0; + + container.innerHTML = ` +
+
+

Counter App

+
+ ${count} +
+
+ + + +
+
+
+ `; + + const display = container.querySelector("#display")!; + const incrementBtn = container.querySelector("#increment")!; + const decrementBtn = container.querySelector("#decrement")!; + const resetBtn = container.querySelector("#reset")!; + + const updateDisplay = (): void => { + display.textContent = count.toString(); + display.className = `text-6xl font-bold my-4 ${ + count > 0 ? "text-success" : count < 0 ? "text-error" : "text-primary" + }`; + }; + + incrementBtn.onclick = (): void => { + count++; + updateDisplay(); + }; + decrementBtn.onclick = (): void => { + count--; + updateDisplay(); + }; + resetBtn.onclick = (): void => { + count = 0; + updateDisplay(); + }; +} diff --git a/src/pages/docs/arxlets/highlight/eve-api-example.ts b/src/pages/docs/arxlets/highlight/eve-api-example.ts new file mode 100644 index 0000000..4dddb2e --- /dev/null +++ b/src/pages/docs/arxlets/highlight/eve-api-example.ts @@ -0,0 +1,55 @@ +// Using window.eve API for Nostr operations +import type { Filter, NostrEvent } from "./types"; + +// Publish a new event +const event: NostrEvent = { + kind: 1, + content: "Hello from my Arxlet!", + tags: [], + created_at: Math.floor(Date.now() / 1000), + pubkey: "your-pubkey-here", +}; + +await window.eve.publish(event); + +// Get a specific event by ID +const eventId = "event-id-here"; +const event = await window.eve.getSingleEventById(eventId); + +// Query events with a filter +const filter: Filter = { + kinds: [1], + authors: ["pubkey-here"], + limit: 10, +}; + +const singleEvent = await window.eve.getSingleEventWithFilter(filter); +const allEvents = await window.eve.getAllEventsWithFilter(filter); + +// Real-time subscription with RxJS Observable +const subscription = window.eve.subscribeToEvents(filter).subscribe({ + next: (event) => { + console.log("New event received:", event); + // Update your UI with the new event + }, + error: (err) => console.error("Subscription error:", err), + complete: () => console.log("Subscription completed"), +}); + +// Subscribe to profile updates for a specific user +const profileSubscription = window.eve.subscribeToProfile(pubkey).subscribe({ + next: (profile) => { + console.log("Profile updated:", profile); + // Update your UI with the new profile data + }, + error: (err) => console.error("Profile subscription error:", err), +}); + +// Don't forget to unsubscribe when done +// subscription.unsubscribe(); +// profileSubscription.unsubscribe(); + +// Get user profile and avatar +const pubkey = "user-pubkey-here"; +const profile = await window.eve.getProfile(pubkey); +const avatarUrl = await window.eve.getAvatar(pubkey); diff --git a/src/pages/docs/arxlets/highlight/nostr-publisher.ts b/src/pages/docs/arxlets/highlight/nostr-publisher.ts new file mode 100644 index 0000000..8cc1492 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/nostr-publisher.ts @@ -0,0 +1,85 @@ +import type { NostrEvent } from "./type-definitions.ts"; + +export async function render(container: HTMLElement): Promise { + container.innerHTML = ` +
+
+

📝 Publish a Note

+ +
+ + +
+ +
+
+ +
+
+
+ `; + + const textarea = container.querySelector("#noteContent")!; + const publishBtn = container.querySelector("#publishBtn")!; + const status = container.querySelector("#status")!; + const charCount = container.querySelector("#charCount")!; + + textarea.oninput = (): void => { + const length: number = textarea.value.length; + charCount.textContent = `${length}/280`; + publishBtn.disabled = length === 0 || length > 280; + }; + + publishBtn.onclick = async (e): Promise => { + const content: string = textarea.value.trim(); + if (!content) return; + + publishBtn.disabled = true; + publishBtn.textContent = "Publishing..."; + status.innerHTML = ''; + + try { + const unsignedEvent: NostrEvent = { + kind: 1, // Text note + content: content, + tags: [["client", "arxlet-publisher"]], + created_at: Math.floor(Date.now() / 1000), + pubkey: await window.eve.publicKey, + }; + + const signedEvent: NostrEvent = await window.eve.signEvent(unsignedEvent); + + await window.eve.publish(signedEvent); + + status.innerHTML = ` +
+ ✅ Note published successfully! +
+ `; + + textarea.value = ""; + textarea.oninput?.(e); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + console.error("Publishing failed:", error); + status.innerHTML = ` +
+ ❌ Failed to publish: ${errorMessage} +
+ `; + } finally { + publishBtn.disabled = false; + publishBtn.textContent = "Publish Note"; + } + }; +} diff --git a/src/pages/docs/arxlets/highlight/preact-counter.tsx b/src/pages/docs/arxlets/highlight/preact-counter.tsx new file mode 100644 index 0000000..78b4318 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/preact-counter.tsx @@ -0,0 +1,61 @@ +// @jsx h +// @jsxImportSource preact + +import { render as renderPreact } from "preact"; +import { useState } from "preact/hooks"; + +const CounterApp = () => { + const [count, setCount] = useState(0); + const [message, setMessage] = useState(""); + + const increment = () => { + setCount((prev) => prev + 1); + setMessage(`Clicked ${count + 1} times!`); + }; + + const decrement = () => { + setCount((prev) => prev - 1); + setMessage(`Count decreased to ${count - 1}`); + }; + + const reset = () => { + setCount(0); + setMessage("Counter reset!"); + }; + + return ( +
+
+

Preact Counter

+ +
0 ? "text-success" : count < 0 ? "text-error" : "text-primary"}`} + > + {count} +
+ +
+ + + +
+ + {message && ( +
+ {message} +
+ )} +
+
+ ); +}; + +export function render(container: HTMLElement): void { + renderPreact(, container); +} diff --git a/src/pages/docs/arxlets/highlight/registration-event.json b/src/pages/docs/arxlets/highlight/registration-event.json new file mode 100644 index 0000000..47c8093 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/registration-event.json @@ -0,0 +1,12 @@ +{ + "kind": 30420, + "tags": [ + ["d", "my-calculator"], + ["name", "Simple Calculator"], + ["description", "A basic calculator for quick math"], + ["script", "export function render(el) { /* your code */ }"], + ["icon", "mdi:calculator", "#3b82f6"] + ], + "content": "", + "created_at": 1735171200 +} diff --git a/src/pages/docs/arxlets/highlight/render-function.ts b/src/pages/docs/arxlets/highlight/render-function.ts new file mode 100644 index 0000000..1e43d10 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/render-function.ts @@ -0,0 +1,23 @@ +/** + * Required export function - Entry point for your Arxlet + */ +export function render(container: HTMLElement): void { + // Initialize your application + container.innerHTML = ` +
+

My Arxlet

+

Hello from Eve!

+ +
+ `; + + // Add event listeners with proper typing + const button = container.querySelector("#myButton"); + button?.addEventListener("click", (): void => { + alert("Button clicked!"); + }); + + // Your app logic here... +} diff --git a/src/pages/docs/arxlets/highlight/subscription-examples.ts b/src/pages/docs/arxlets/highlight/subscription-examples.ts new file mode 100644 index 0000000..28fbd83 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/subscription-examples.ts @@ -0,0 +1,54 @@ +// Real-time subscription examples +import { filter, map, takeUntil } from "rxjs/operators"; + +// Basic subscription +const subscription = window.eve + .subscribeToEvents({ + kinds: [1], // Text notes + limit: 50, + }) + .subscribe((event) => { + console.log("New text note:", event.content); + }); + +// Advanced filtering with RxJS operators +const filteredSubscription = window.eve + .subscribeToEvents({ + kinds: [1, 6, 7], // Notes, reposts, reactions + authors: ["pubkey1", "pubkey2"], + }) + .pipe( + filter((event) => event.content.includes("#arxlet")), // Only events mentioning arxlets + map((event) => ({ + id: event.id, + author: event.pubkey, + content: event.content, + timestamp: new Date(event.created_at * 1000), + })), + ) + .subscribe({ + next: (processedEvent) => { + // Update your UI + updateEventsList(processedEvent); + }, + error: (err) => { + console.error("Subscription error:", err); + showErrorMessage("Failed to receive real-time updates"); + }, + }); + +// Profile subscription example +const profileSubscription = window.eve.subscribeToProfile("user-pubkey-here").subscribe({ + next: (profile) => { + console.log("Profile updated:", profile); + updateUserProfile(profile); + }, + error: (err) => { + console.error("Profile subscription error:", err); + }, +}); + +// Clean up subscriptions when component unmounts +// subscription.unsubscribe(); +// filteredSubscription.unsubscribe(); +// profileSubscription.unsubscribe(); diff --git a/src/pages/docs/arxlets/highlight/svelte-counter.svelte b/src/pages/docs/arxlets/highlight/svelte-counter.svelte new file mode 100644 index 0000000..1f7a43d --- /dev/null +++ b/src/pages/docs/arxlets/highlight/svelte-counter.svelte @@ -0,0 +1,49 @@ + + +
+
+

🔥 Svelte Counter

+ +
+ {count} +
+ +
+ + + +
+ + {#if message} +
+ {message} +
+ {/if} +
+
+ + diff --git a/src/pages/docs/arxlets/highlight/type-definitions.ts b/src/pages/docs/arxlets/highlight/type-definitions.ts new file mode 100644 index 0000000..33f8b42 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/type-definitions.ts @@ -0,0 +1,48 @@ +import type { Observable } from "rxjs"; + +export interface NostrEvent { + id?: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig?: string; +} + +export interface Filter { + ids?: string[]; + authors?: string[]; + kinds?: number[]; + since?: number; + until?: number; + limit?: number; + [key: string]: any; +} + +export interface Profile { + name?: string; + about?: string; + picture?: string; + nip05?: string; + [key: string]: any; +} + +export interface WindowEve { + publish(event: NostrEvent): Promise; + getSingleEventById(id: string): Promise; + getSingleEventWithFilter(filter: Filter): Promise; + getAllEventsWithFilter(filter: Filter): Promise; + subscribeToEvents(filter: Filter): Observable; + subscribeToProfile(pubkey: string): Observable; + getProfile(pubkey: string): Promise; + getAvatar(pubkey: string): Promise; + signEvent(event: NostrEvent): Promise; + get publicKey(): Promise; +} + +declare global { + interface Window { + eve: WindowEve; + } +} diff --git a/src/pages/docs/arxlets/highlight/websocket-example.ts b/src/pages/docs/arxlets/highlight/websocket-example.ts new file mode 100644 index 0000000..d5f35c9 --- /dev/null +++ b/src/pages/docs/arxlets/highlight/websocket-example.ts @@ -0,0 +1,18 @@ +// Alternative: Direct WebSocket connection +const ws = new WebSocket("ws://localhost:6942"); + +ws.onopen = () => { + // Subscribe to events + ws.send(JSON.stringify(["REQ", "sub1", { kinds: [1], limit: 10 }])); +}; + +ws.onmessage = (event) => { + const [type, subId, data] = JSON.parse(event.data); + if (type === "EVENT") { + console.log("Received event:", data); + } +}; + +// Publish an event +const signedEvent = await window.nostr.signEvent(unsignedEvent); +ws.send(JSON.stringify(["EVENT", signedEvent])); diff --git a/src/pages/docs/arxlets/hooks/useSyntaxHighlighting.js b/src/pages/docs/arxlets/hooks/useSyntaxHighlighting.js new file mode 100644 index 0000000..32ae0a6 --- /dev/null +++ b/src/pages/docs/arxlets/hooks/useSyntaxHighlighting.js @@ -0,0 +1,30 @@ +import { useEffect } from "preact/hooks"; +import Prism from "prismjs"; +import "prismjs/components/prism-json"; +import "prismjs/components/prism-javascript"; +import "prismjs/components/prism-typescript"; +import "prismjs/components/prism-bash"; +import "prism-svelte"; + +/** + * Custom hook for managing syntax highlighting + * Handles initialization and tab change events + */ +export const useSyntaxHighlighting = () => { + useEffect(() => { + const highlightCode = () => setTimeout(() => Prism.highlightAll(), 100); + + highlightCode(); + + const tabInputs = document.querySelectorAll('input[name="arxlet_tabs"]'); + tabInputs.forEach((input) => { + input.addEventListener("change", highlightCode); + }); + + return () => { + tabInputs.forEach((input) => { + input.removeEventListener("change", highlightCode); + }); + }; + }, []); +}; diff --git a/src/pages/home/home.css b/src/pages/home/home.css new file mode 100644 index 0000000..3997987 --- /dev/null +++ b/src/pages/home/home.css @@ -0,0 +1,5 @@ +@import "tailwindcss"; +@tailwind base; +@tailwind components; +@tailwind utilities; +@plugin "daisyui"; diff --git a/src/pages/home/home.html b/src/pages/home/home.html new file mode 100644 index 0000000..157cfd2 --- /dev/null +++ b/src/pages/home/home.html @@ -0,0 +1,285 @@ + + + + + + Eve - Secure, Decentralized Communities + + + + +
+ + + + +
+
+
+
+

Welcome to Eve

+

+ Your personal gateway to secure, decentralized communities. Create + encrypted Closed Community Networks (CCNs) where + your messages and data stay truly private. +

+ Get Started +
+
+
+ + +
+

Why Choose Eve?

+
+ +
+
+ + + +

End-to-End Encryption

+

+ Every message, every file, every interaction is secured with + cutting-edge encryption. Only you and your community hold the + keys. +

+
+
+
+
+ + + +

Decentralized by Design

+

+ No central servers, no single point of failure. Your community's + data is distributed, resilient, and censorship-resistant. +

+
+
+ +
+
+ + + +

Extensible with Arxlets

+

+ Supercharge your community with powerful mini-apps. From shared + calendars to collaborative tools, the possibilities are + limitless. +

+
+
+
+
+ +
+
+

How Eve Works

+
    +
  • +
    + + + +
    +
    + +
    Create a CCN
    + Generate a unique, encrypted Closed Community Network. This is + your private digital space, secured by a key that only you and + your members possess. +
    +
    +
  • +
  • +
    +
    + + + +
    +
    + +
    Invite Members
    + Securely share the CCN key with trusted members. Only those with + the key can join, ensuring your community remains private and + exclusive. +
    +
    +
  • +
  • +
    +
    + + + +
    +
    + +
    Communicate & Collaborate
    + Share messages, files, and use Arxlets within your secure + environment. Your data is always protected and under your + control. +
    +
    +
  • +
  • +
    +
    + + + +
    +
    + +
    Extend with Arxlets
    + Browse and install Arxlets to add new features to your CCN. + Customize your community's experience with powerful, + decentralized applications. +
    +
  • +
+
+
+ +
+
+
+
+

Unleash the Power of Arxlets

+

+ Arxlets are the heart of Eve's extensibility. + They are secure, sandboxed applications that run within your + CCN, allowing you to add powerful features without compromising + privacy. Imagine a decentralized social feed, a collaborative + whiteboard, or a secure voting system—all running within your + private community. +

+ Explore Arxlet Development +
+
+
+
+ +
+ +
+
+ + diff --git a/src/rollingIndex.ts b/src/rollingIndex.ts new file mode 100644 index 0000000..b7c90b6 --- /dev/null +++ b/src/rollingIndex.ts @@ -0,0 +1,100 @@ +import { bytesToHex, hexToBytes } from "nostr-tools/utils"; + +export const DEFAULT_PERIOD_MINUTES = 8 * 60; + +export class RollingIndex { + private static PERIOD_BYTES = 2; + private static PERIOD_OFFSET_BYTES = 6; + + static diff(left: Uint8Array, right: Uint8Array): Uint8Array[] { + const leftData = this.extract(left); + const rightData = this.extract(right); + + if (leftData.periodMinutes !== rightData.periodMinutes) + throw new Error( + `Period minutes mismatch! Left: ${leftData.periodMinutes}, Right: ${rightData.periodMinutes}.`, + ); + + const startPeriod = Math.min(leftData.periodNumber, rightData.periodNumber); + const endPeriod = Math.max(leftData.periodNumber, rightData.periodNumber); + const periodMinutes = leftData.periodMinutes; + + const result: Uint8Array[] = []; + + for ( + let periodNumber = startPeriod; + periodNumber <= endPeriod; + periodNumber++ + ) { + const buffer = new ArrayBuffer( + this.PERIOD_BYTES + this.PERIOD_OFFSET_BYTES, + ); + const view = new DataView(buffer); + + view.setUint16(0, periodMinutes, false); + view.setUint32(2, Math.floor(periodNumber / 0x10000), false); + view.setUint16(6, periodNumber & 0xffff, false); + + result.push(new Uint8Array(buffer)); + } + + return result; + } + + static compare(left: Uint8Array, right: Uint8Array): number { + const leftData = this.extract(left); + const rightData = this.extract(right); + + if (leftData.periodMinutes < rightData.periodMinutes) return -1; + if (leftData.periodMinutes > rightData.periodMinutes) return 1; + if (leftData.periodNumber < rightData.periodNumber) return -1; + if (leftData.periodNumber > rightData.periodNumber) return 1; + return 0; + } + + static at( + nowMs: number, + periodMinutes: number = DEFAULT_PERIOD_MINUTES, + ): Uint8Array { + const now = Math.floor(nowMs / 1000); + const periodSeconds = periodMinutes * 60; + const periodNumber = Math.floor(now / periodSeconds); + + const buffer = new ArrayBuffer( + this.PERIOD_BYTES + this.PERIOD_OFFSET_BYTES, + ); + const view = new DataView(buffer); + + view.setUint16(0, periodMinutes, false); + view.setUint32(2, Math.floor(periodNumber / 0x10000), false); + view.setUint16(6, periodNumber & 0xffff, false); + + return new Uint8Array(buffer); + } + + static get(periodMinutes: number = DEFAULT_PERIOD_MINUTES): Uint8Array { + return this.at(Date.now(), periodMinutes); + } + + static extract(index: Uint8Array): { + periodMinutes: number; + periodNumber: number; + } { + const view = new DataView(index.buffer); + + const periodMinutes = view.getUint16(0, false); + const periodNumberHigh = view.getUint32(2, false); + const periodNumberLow = view.getUint16(6, false); + const periodNumber = periodNumberHigh * 0x10000 + periodNumberLow; + + return { periodMinutes, periodNumber }; + } + + static toHex(index: Uint8Array): string { + return bytesToHex(index); + } + + static fromHex(hex: string): Uint8Array { + return hexToBytes(hex); + } +} diff --git a/src/utils/Uint8Array.ts b/src/utils/Uint8Array.ts new file mode 100644 index 0000000..128025c --- /dev/null +++ b/src/utils/Uint8Array.ts @@ -0,0 +1,83 @@ +import { bytesToHex, hexToBytes } from "nostr-tools/utils"; +import { isHex } from "./general"; + +export function write_varint(bytes: number[], n: number): number { + let len = 0; + while (true) { + let b = n & 0x7f; + n >>= 7; + if (n !== 0) b |= 0x80; + bytes.push(b); + len += 1; + if (n === 0) break; + } + return len; +} + +export const write_tagged_varint = ( + bytes: number[], + value: number, + tagged: boolean, +): number => write_varint(bytes, (value << 1) | (tagged ? 1 : 0)); + +export function write_string(bytes: number[], s: string) { + if (s.length === 0) return write_tagged_varint(bytes, 0, false); + + if (isHex(s)) { + const parsed = hexToBytes(s); + write_tagged_varint(bytes, parsed.length, true); + bytes.push(...parsed); + } else { + const contentBytes = new TextEncoder().encode(s); + write_tagged_varint(bytes, contentBytes.length, false); + bytes.push(...contentBytes); + } +} + +export const read_tagged_varint = ( + data: Uint8Array, + offset: number, +): [number, boolean, number] => { + const [value, bytes_read] = read_varint(data, offset); + const tagged = (value & 1) === 1; + const actual_value = value >> 1; + return [actual_value, tagged, bytes_read]; +}; + +export function read_varint( + buffer: Uint8Array, + offset: number, +): [number, number] { + let value = 0; + let shift = 0; + let bytesRead = 0; + + while (offset + bytesRead < buffer.length) { + const byte = buffer[offset + bytesRead]; + if (typeof byte === "undefined") return [value, bytesRead]; + bytesRead++; + + value |= (byte & 0x7f) << shift; + + if ((byte & 0x80) === 0) break; + + shift += 7; + } + + return [value, bytesRead]; +} + +export function read_string( + data: Uint8Array, + offset: number, +): [string, number] { + const [length, tagged, varint_bytes] = read_tagged_varint(data, offset); + offset += varint_bytes; + + if (length === 0) return ["", varint_bytes]; + + const stringData = data.slice(offset, offset + length); + + if (tagged) return [bytesToHex(stringData), varint_bytes + length]; + return [new TextDecoder().decode(stringData), varint_bytes + length]; +} diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 0000000..9bcf879 --- /dev/null +++ b/src/utils/color.ts @@ -0,0 +1,46 @@ +export function getColorFromPubkey(pubkey: string): string { + let hash = 0; + for (let i = 0; i < pubkey.length; i++) hash = ((hash << 5) - hash + pubkey.charCodeAt(i)) & 0xffffffff; + hash = Math.abs(hash); + + const hue = hash % 360; + + const saturation = hue >= 216 && hue <= 273 ? 0.8 : 0.9; + const lightness = hue >= 32 && hue <= 212 ? 0.85 : 0.65; + + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const huePrime = hue / 60; + const secondComponent = chroma * (1 - Math.abs((huePrime % 2) - 1)); + const lightnessAdjustment = lightness - chroma / 2; + + let [r, g, b] = [0, 0, 0]; + + const sector = Math.floor(huePrime); + switch (sector) { + case 0: + [r, g, b] = [chroma, secondComponent, 0]; + break; + case 1: + [r, g, b] = [secondComponent, chroma, 0]; + break; + case 2: + [r, g, b] = [0, chroma, secondComponent]; + break; + case 3: + [r, g, b] = [0, secondComponent, chroma]; + break; + case 4: + [r, g, b] = [secondComponent, 0, chroma]; + break; + default: + [r, g, b] = [chroma, 0, secondComponent]; + break; + } + + const toHex = (value: number): string => + Math.round((value + lightnessAdjustment) * 255) + .toString(16) + .padStart(2, "0"); + + return `${toHex(r)}${toHex(g)}${toHex(b)}`; +} diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts new file mode 100644 index 0000000..7c57e0a --- /dev/null +++ b/src/utils/encryption.ts @@ -0,0 +1,193 @@ +import { xchacha20poly1305 } from "@noble/ciphers/chacha"; +import { bytesToHex, hexToBytes } from "@noble/ciphers/utils"; +import { managedNonce } from "@noble/ciphers/webcrypto"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, + type NostrEvent, + nip13, + verifyEvent, +} from "nostr-tools"; +import { CURRENT_VERSION, POW_TO_ACCEPT, POW_TO_MINE } from "../consts"; +import { isHex } from "./general"; +import { + read_string, + read_varint, + write_string, + write_varint, +} from "./Uint8Array"; + +const secureClear = (data: Uint8Array) => data.fill(0); + +export function serializeEventData(event: NostrEvent): Uint8Array { + const bytes: number[] = []; + bytes.push(CURRENT_VERSION); + const id = hexToBytes(event.id); + bytes.push(...id); + const pk = hexToBytes(event.pubkey); + bytes.push(...pk); + const sig = hexToBytes(event.sig); + bytes.push(...sig); + write_varint(bytes, event.created_at); + write_varint(bytes, event.kind); + write_string(bytes, event.content); + write_varint(bytes, event.tags.length); + for (const tag of event.tags) { + write_varint(bytes, tag.length); + for (const element of tag) write_string(bytes, element); + } + + return new Uint8Array(bytes); +} + +function deserializeEventData(buffer: Uint8Array): NostrEvent { + let offset = 0; + + const version = buffer[offset++]; + if (version !== CURRENT_VERSION) + throw new Error(`Unsupported version: ${version}`); + + const id = bytesToHex(buffer.slice(offset, offset + 32)); + offset += 32; + const pubkey = bytesToHex(buffer.slice(offset, offset + 32)); + offset += 32; + const sig = bytesToHex(buffer.slice(offset, offset + 64)); + offset += 64; + const [created_at, created_at_bytes] = read_varint(buffer, offset); + offset += created_at_bytes; + const [kind, kind_bytes] = read_varint(buffer, offset); + offset += kind_bytes; + const [content, content_bytes] = read_string(buffer, offset); + offset += content_bytes; + const [tags_length, tags_length_bytes] = read_varint(buffer, offset); + offset += tags_length_bytes; + + const tags: string[][] = []; + for (let i = 0; i < tags_length; i++) { + const [tag_length, tag_length_bytes] = read_varint(buffer, offset); + offset += tag_length_bytes; + + const tag: string[] = []; + for (let j = 0; j < tag_length; j++) { + const [element, element_bytes] = read_string(buffer, offset); + offset += element_bytes; + + tag.push(element); + } + tags.push(tag); + } + + return { + id, + pubkey, + sig, + created_at, + kind, + content, + tags, + }; +} + +/** + * Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm. + * + * @param data - The data to be encrypted as a Uint8Array. + * @param key - The encryption key as a Uint8Array. + * @returns The encrypted data as a Uint8Array. + * + * @note The key being cloned is not a mistake in the function. If the key is not a copy, it will be cleared from memory, causing future encryptions and decryptions to use key = 0 + */ +export function encryptUint8Array( + data: Uint8Array, + key: Uint8Array, +): Uint8Array { + if (key.length !== 32) throw new Error("Encryption key must be 32 bytes"); + if (data.length === 0) throw new Error("Cannot encrypt empty data"); + return managedNonce(xchacha20poly1305)(new Uint8Array(key)).encrypt(data); +} + +/** + * Decrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm. + * + * @param data - The data to be decrypted as a Uint8Array. + * @param key - The decryption key as a Uint8Array. + * @returns The decrypted data as a Uint8Array. + * + * @note The key being cloned is not a mistake in the function. If the key is not a copy, it will be cleared from memory, causing future encryptions and decryptions to use key = 0 + */ +export function decryptUint8Array( + data: Uint8Array, + key: Uint8Array, +): Uint8Array { + if (key.length !== 32) throw new Error("Decryption key must be 32 bytes"); + if (data.length === 0) throw new Error("Cannot decrypt empty data"); + return managedNonce(xchacha20poly1305)(new Uint8Array(key)).decrypt(data); +} + +export async function createEncryptedEvent( + event: NostrEvent, + encryptionKey: Uint8Array, +): Promise { + const ccnPubkey = getPublicKey(encryptionKey); + let randomKey: Uint8Array | null = null; + + try { + const serializedData = serializeEventData(event); + + const encryptedEvent = encryptUint8Array(serializedData, encryptionKey); + + const randomTimeUpTo2DaysInThePast = + Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 86400); + + randomKey = generateSecretKey(); + const randomKeyPub = getPublicKey(randomKey); + + const mainEvent = nip13.minePow( + { + kind: 1060, + content: bytesToHex(encryptedEvent), + created_at: randomTimeUpTo2DaysInThePast, + tags: [["p", ccnPubkey]], + pubkey: randomKeyPub, + }, + POW_TO_MINE, + ); + + return finalizeEvent(mainEvent, randomKey); + } finally { + if (randomKey) secureClear(randomKey); + } +} + +export async function decryptEvent( + event: NostrEvent, + encryptionKey: Uint8Array, +) { + let decryptedData: Uint8Array | null = null; + + try { + if (!verifyEvent(event)) throw new Error("Operation failed: invalid event"); + + if (nip13.getPow(event.id) < POW_TO_ACCEPT) + throw new Error("Operation failed: insufficient proof of work"); + + if (!isHex(event.content)) + throw new Error("Operation failed: invalid content encoding"); + + if (event.kind !== 1060) throw new Error("Operation failed: invalid kind"); + + decryptedData = decryptUint8Array(hexToBytes(event.content), encryptionKey); + + const innerEvent = deserializeEventData(decryptedData); + + console.log(innerEvent); + + if (!verifyEvent(innerEvent)) + throw new Error("Operation failed: invalid inner event"); + + return innerEvent; + } finally { + if (decryptedData) secureClear(decryptedData); + } +} diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 0000000..4bae934 --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,29 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import type { NostrEvent } from "nostr-tools"; +import { CCN } from "../ccns"; + +export function getDataDir() { + if (Bun.env.NODE_ENV === "production") { + const baseDir = "/var/lib/eve-lite"; + mkdirSync(baseDir, { recursive: true }); + return baseDir; + } + let home = Bun.env.XDG_CONFIG_HOME; + if (!home) home = join(Bun.env.HOME!, ".config"); + const configDir = join(home, "arx", "eve-lite"); + mkdirSync(configDir, { recursive: true }); + return configDir; +} + +export async function loadSeenEvents() { + const ccn = await CCN.getActive(); + if (!ccn) throw "No CCN"; + return ccn.loadSeenEvents(); +} + +export async function saveSeenEvent(event: NostrEvent) { + const ccn = await CCN.getActive(); + if (!ccn) throw "No CCN"; + return ccn.saveSeenEvent(event); +} diff --git a/src/utils/general.ts b/src/utils/general.ts new file mode 100644 index 0000000..92c6fd0 --- /dev/null +++ b/src/utils/general.ts @@ -0,0 +1,98 @@ +import { type Filter, type NostrEvent, SimplePool } from "nostr-tools"; +import type { SubCloser } from "nostr-tools/abstract-pool"; + +export const isHex = (hex: string) => + /^[0-9a-fA-F]+$/.test(hex) && hex.length % 2 === 0; + +export const pool = new SimplePool(); + +export const relays = [ + "wss://relay.arx-ccn.com/", + "wss://nos.lol/", + "wss://nostr.einundzwanzig.space/", + "wss://nostr.massmux.com/", + "wss://nostr.mom/", + "wss://purplerelay.com/", + "wss://relay.damus.io/", + "wss://relay.goodmorningbitcoin.com/", + "wss://relay.lexingtonbitcoin.org/", + "wss://relay.nostr.band/", + "wss://relay.snort.social/", + "wss://strfry.iris.to/", + "wss://cache2.primal.net/v1", +]; + +export const queryRemoteRelays = ( + filer: Filter, + callback: (event: NostrEvent) => void, +): SubCloser => + pool.subscribe(relays, filer, { + onevent: callback, + }); + +export const queryRemoteRelaysSync = (filter: Filter): Promise => + pool.querySync(relays, filter); + +export const queryRemoteEvent = (id: string): Promise => + pool.get(relays, { + ids: [id], + limit: 1, + }); + +export async function sendEncryptedEventToRelays( + event: NostrEvent, +): Promise { + if (event.kind !== 1059 && event.kind !== 1060) + throw new Error("Event is not an eve encrypted event"); + + const pool = new SimplePool(); + + return Promise.any(pool.publish(relays, event)); +} + +export async function sendUnencryptedEventToLocalRelay( + event: NostrEvent, +): Promise { + const pool = new SimplePool(); + return Promise.any(pool.publish(["ws://localhost:6942"], event)); +} + +export function splitIntoParts(str: string, partsCount: number): string[] { + let remainder = str.length % partsCount; + const partSize = (str.length - remainder) / partsCount; + const parts: string[] = new Array(partsCount).fill(""); + let currentPart = 0; + for (let i = 0; i < str.length; ) { + let end = i + partSize; + if (remainder) { + end++; + remainder--; + } + parts[currentPart++] = str.slice(i, end); + i = end; + } + return parts; +} + +export function getSvgGroup(svg: string, transform: string): string { + const gOpen = svg.match(/]*)?>/); + if (!gOpen) throw new Error("Malformed SVG"); + + let depth = 1; + let i = gOpen.index! + gOpen[0].length; + while (depth && i < svg.length) { + const open = svg.indexOf("", i); + if (close === -1) throw new Error("Malformed SVG"); + if (open !== -1 && open < close) { + depth++; + i = open + 2; + } else { + depth--; + i = close + 4; + if (!depth) + return `${svg.slice(gOpen.index! + gOpen[0].length, close)}`; + } + } + throw new Error("Malformed SVG"); +} diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 0000000..90415fb --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,119 @@ +import { hexToBytes } from "@noble/ciphers/utils"; +import { getPublicKey } from "nostr-tools"; +import { isHex } from "../utils/general"; + +export function validatePrivateKey(privateKey: string) { + if (!privateKey) + return { + isValid: false, + error: "Private key is required", + }; + + if (!isHex(privateKey)) + return { + isValid: false, + error: "Private key must be a valid hexadecimal string", + }; + + if (privateKey.length !== 64) + return { + isValid: false, + error: "Private key must be exactly 32 bytes (64 hex characters)", + }; + + try { + const keyBytes = hexToBytes(privateKey); + getPublicKey(keyBytes); + return { isValid: true }; + } catch (error) { + return { + isValid: false, + error: "Invalid private key format", + details: error, + }; + } +} + +export function validateCommunityName(name: string) { + if (typeof name !== "string") + return { + isValid: false, + error: "Community name is required and must be a string", + }; + + if (!name || name.trim().length === 0) + return { + isValid: false, + error: "Community name cannot be empty", + }; + + if (name.length > 100) + return { + isValid: false, + error: "Community name must be 100 characters or less", + }; + + return { isValid: true }; +} + +export function validateCommunityDescription(description: string) { + if (typeof description !== "string") + return { + isValid: false, + error: "Community description must be a string", + }; + + if (description.length > 500) + return { + isValid: false, + error: "Community description must be 500 characters or less", + }; + + return { isValid: true }; +} + +export function validateRelayUrl(url: string) { + if (!url || typeof url !== "string") + return { + isValid: false, + error: "Relay URL is required and must be a string", + }; + + try { + const parsedUrl = new URL(url); + if (!["ws:", "wss:"].includes(parsedUrl.protocol)) + return { + isValid: false, + error: "Relay URL must use ws:// or wss:// protocol", + }; + return { isValid: true }; + } catch (error) { + return { + isValid: false, + error: "Invalid URL format", + details: error, + }; + } +} + +export function validatePowDifficulty(difficulty: number) { + if (typeof difficulty !== "number") + return { + isValid: false, + error: "PoW difficulty must be a number", + }; + + if (!Number.isInteger(difficulty)) + return { + isValid: false, + error: "PoW difficulty must be an integer", + }; + + if (difficulty < 0 || difficulty > 64) + return { + isValid: false, + error: "PoW difficulty must be between 0 and 64", + }; + + return { isValid: true }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}