initial version (alpha)

This commit is contained in:
Danny Morabito 2025-10-12 13:03:07 -05:00
commit 0c965b54ed
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
56 changed files with 10437 additions and 0 deletions

21
.githooks/pre-commit Executable file
View file

@ -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

34
.gitignore vendored Normal file
View file

@ -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

7
.zed/settings.json Normal file
View file

@ -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"
}

113
README.md Normal file
View file

@ -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
This project is private and proprietary. Please respect the license terms.
## 🙋‍♀️ 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._

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

38
biome.json Normal file
View file

@ -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"
}
}
}

164
bun.lock Normal file
View file

@ -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=="],
}
}

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]

418
index.ts Normal file
View file

@ -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<typeof setInterval> | 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(/(?<!\\):/));
const wifi = nmcliLines
.map(
([
ssid,
bssid,
mode,
freq,
chan,
rate,
signal,
security,
active,
]) => ({
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}`);

30
package.json Normal file
View file

@ -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"
}
}

59
src/arxlets.ts Normal file
View file

@ -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);
}

408
src/ccns.ts Normal file
View file

@ -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<CCN[]> {
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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 280" fill="none" shape-rendering="auto">
<rect width="280" height="280" fill="#${getColorFromPubkey(this.publicKey)}"/>
${avatars}
</svg>`;
}
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<Arxlet[]> {
const events = await this.getEvents({
kinds: [30420],
});
return events
.map((event) => parseArxletFromEvent(this)(event))
.filter((arxlet) => arxlet !== undefined);
}
async getArxletById(id: string): Promise<Arxlet | undefined> {
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<string[]> {
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<number> {
return await this.reputationManager.getReputation(userId);
}
}

150
src/ccns/reputation.ts Normal file
View file

@ -0,0 +1,150 @@
import { type Filter, type NostrEvent } from "nostr-tools";
interface ReputationInfo {
reputation: number;
voteCount: number;
lastEventTimestamp: number;
processedVotes: Set<string>;
}
export class ReputationManager {
private reputationCache = new Map<string, ReputationInfo>();
private reputationCalculationInProgress = new Map<string, Promise<void>>();
constructor(private getEvents: (filter: Filter) => Promise<NostrEvent[]>) {}
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<string>();
// 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<string>(),
});
}
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<number> {
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;
}
}

4
src/consts.ts Normal file
View file

@ -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;

45
src/eventPlugin.ts Normal file
View file

@ -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",
}),
);
}

File diff suppressed because it is too large Load diff

View file

@ -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<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 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 = `
<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
[source,typescript]
----
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
[source,typescript]
----
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 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 = `
<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
[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 (
<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
[source,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:
[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]
----
<!-- 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
[source,html]
----
<!-- 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
[source,html]
----
<!-- 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 Section
== Complete Example Patterns
=== Simple Counter Arxlet
[source,typescript]
----
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
[source,typescript]
----
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";
}
};
}
----

View file

@ -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);
}
}

View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arxlets Documentation - Eve</title>
<link rel="stylesheet" href="./arxlet-docs.css" />
<link
href="https://cdn.jsdelivr.net/npm/daisyui@5"
rel="stylesheet"
type="text/css"
/>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
rel="stylesheet"
type="text/css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
</head>
<body data-theme="dark" class="bg-base-100 text-base-content">
<script src="./arxlet-docs.jsx"></script>
</body>
</html>

View file

@ -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: <OverviewSection />,
subsections: {},
},
Registration: {
component: <RegistrationSection />,
subsections: {
"nostr-event-structure": "Nostr Event Structure",
"tag-reference": "Tag Reference",
},
},
Development: {
component: <DevelopmentSection />,
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: <BestPracticesSection />,
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: <APISection />,
subsections: {
"window-eve-api": "window.eve API",
"real-time-subscriptions": "Real-time Subscriptions",
"websocket-alternative": "WebSocket Alternative",
"best-practices": "Best Practices",
},
},
Examples: {
component: <ExamplesSection />,
subsections: {
vanilla: "Vanilla JS",
svelte: "Svelte",
preact: "Preact + JSX",
nostr: "Nostr Publisher",
},
},
LLMs: {
component: <LLMsSection />,
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 <div>Section not found</div>;
if (activeSection === "Examples") {
return <ExamplesSection activeExample={activeExample} />;
}
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 (
<div class="drawer drawer-open" data-theme="dark">
{/* Global Progress Bar */}
<div class="global-progress-container">
<div class="global-progress-bar" style={`width: ${scrollProgress}%`}></div>
</div>
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col p-8">
{/* Header */}
<header class="mb-12">
<div class="flex items-center gap-4 mb-6">
<div class="w-16 h-16 bg-gradient-to-br from-primary to-secondary rounded-xl flex items-center justify-center shadow-lg">
<span class="text-2xl">🚀</span>
</div>
<div>
<h1 class="text-5xl font-bold">Arxlets</h1>
<p class="text-xl text-base-content/70 mt-2">Secure Applications for Eve</p>
</div>
</div>
<div class="divider"></div>
</header>
{renderContent()}
{/* Footer */}
<footer class="mt-16 pt-8 border-t border-base-300">
<div class="text-center text-base-content/60">
<p class="text-lg">Arxlets Documentation Eve</p>
<p class="text-sm mt-2">Build secure, sandboxed applications for your CCN</p>
</div>
</footer>
</div>
<div class="drawer-side">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<aside class="sidebar-container w-80 min-h-full bg-base-200 flex flex-col">
{/* Sidebar Header */}
<div class="sidebar-header p-4 border-b border-base-300">
<div class="flex items-center gap-3">
<div class="header-icon w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-lg flex items-center justify-center">
<span class="text-sm">📚</span>
</div>
<div>
<h2 class="font-bold text-lg">Documentation</h2>
<p class="text-xs text-base-content/60">Navigate sections</p>
</div>
</div>
</div>
{/* Navigation Menu */}
<nav class={`navigation-container flex-1 overflow-y-auto ${isScrolling ? "scrolling" : ""}`}>
<ul class="menu p-4 w-full text-base-content">
{Object.entries(SECTIONS).map(([section, { subsections }], index) => (
<li key={section} class="nav-item" style={`--item-index: ${index}`}>
<button
type="button"
class={`${activeSection === section ? "active" : ""} section-link`}
onClick={(e) => handleNavClick(e, section)}
>
<span class="section-icon-container">
<span class="section-icon">
{section === "Overview" && "🏠"}
{section === "Registration" && "📝"}
{section === "Development" && "⚡"}
{section === "Best Practices" && "✨"}
{section === "API Reference" && "🔧"}
{section === "Examples" && "💡"}
{section === "LLMs" && "🤖"}
</span>
<span class="icon-glow"></span>
</span>
<span class="section-text">{section}</span>
<span class="section-badge">
{Object.keys(subsections).length > 0 && (
<span class="subsection-count">{Object.keys(subsections).length}</span>
)}
</span>
</button>
{activeSection === section && Object.keys(subsections).length > 0 && (
<ul class="subsection-menu">
{Object.entries(subsections).map(([subsectionId, subsectionLabel], subIndex) => (
<li key={subsectionId} style={`--sub-index: ${subIndex}`}>
<a
href={`#${subsectionId}`}
class={`subsection-link ${
activeSection === "Examples"
? activeExample === subsectionId
? "active"
: ""
: activeSubsection === subsectionId
? "active"
: ""
}`}
onClick={(e) => handleSubNavClick(e, subsectionId)}
>
<span class="subsection-dot"></span>
<span class="subsection-text">{subsectionLabel}</span>
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
{/* Sidebar Footer */}
<div class="sidebar-footer p-4 border-t border-base-300">
<div class="text-center space-y-2">
<div class="flex items-center justify-center gap-2">
<div class="w-2 h-2 rounded-full bg-success animate-pulse"></div>
<p class="text-xs text-base-content/50">Arxlets v0.1b</p>
</div>
<div class="flex justify-center gap-1">
{Object.keys(SECTIONS).map((_, index) => (
<div
class={`progress-dot ${index <= Object.keys(SECTIONS).findIndex(([key]) => key === activeSection) ? "active" : ""}`}
></div>
))}
</div>
</div>
</div>
</aside>
</div>
</div>
);
};
render(<ArxletDocs />, document.body);

View file

@ -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 (
<div class="space-y-6">
<h2 class="text-3xl font-bold">API Reference</h2>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-info mb-4">Understanding Arxlet APIs</h3>
<div class="space-y-4">
<p>
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.
</p>
<p>
<strong>Two approaches available:</strong> You can use the convenient <code>window.eve</code> API
(recommended for most cases) or connect directly via WebSocket for advanced scenarios. Both give you full
access to Nostr events and CCN features.
</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-primary mb-4">🎯 Which API Should You Use?</h3>
<div class="grid md:grid-cols-2 gap-6">
<div class="border-2 border-primary rounded-lg p-4">
<h4 class="font-bold text-primary mb-3"> window.eve API (Recommended)</h4>
<p class="text-sm mb-3">
<strong>Best for most Arxlets.</strong> This high-level API handles all the complex Nostr protocol
details for you.
</p>
<div class="space-y-2 text-sm">
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Simple promise-based functions</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Automatic error handling</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Built-in RxJS observables for real-time data</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Profile and avatar helpers</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Perfect for beginners</span>
</div>
</div>
</div>
<div class="border-2 border-accent rounded-lg p-4">
<h4 class="font-bold text-accent mb-3"> Direct WebSocket</h4>
<p class="text-sm mb-3">
<strong>For advanced use cases.</strong> Direct connection to the Nostr relay with full protocol
control.
</p>
<div class="space-y-2 text-sm">
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Maximum performance and control</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Custom subscription management</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Raw Nostr protocol access</span>
</div>
<div class="flex items-start gap-2">
<span class="text-warning">!</span>
<span>Requires Nostr protocol knowledge</span>
</div>
<div class="flex items-start gap-2">
<span class="text-warning">!</span>
<span>More complex error handling</span>
</div>
</div>
</div>
</div>
<div class="alert alert-info mt-6">
<span>
💡 <strong>Our Recommendation:</strong> Start with <code>window.eve</code> for your first Arxlet. You can
always switch to WebSocket later if you need more control or performance.
</span>
</div>
</div>
</div>
{/* window.eve API */}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="window-eve-api" class="card-title text-primary mb-4">
🚀 window.eve API - Your Main Toolkit
</h3>
<div class="space-y-4 mb-6">
<p>
The <code>window.eve</code> 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.
</p>
<p>
<strong>How it works:</strong> 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.
</p>
</div>
<div class="space-y-6">
<div>
<h4 class="font-bold text-lg mb-4 text-success">📤 Publishing & Writing Data</h4>
<div class="space-y-4">
<div class="border-l-4 border-success pl-4">
<h5 class="font-bold">publish(event)</h5>
<p class="text-sm opacity-80 mb-2">
Publishes a Nostr event to the relay. This is how you save data, post messages, or create any
content.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Posting messages, saving user preferences, creating notes, updating
profiles
</p>
<div class="badge badge-outline">Promise&lt;void&gt;</div>
</div>
<div class="border-l-4 border-success pl-4">
<h5 class="font-bold">signEvent(event)</h5>
<p class="text-sm opacity-80 mb-2">
Signs an unsigned Nostr event with the user's private key. Required before publishing most events.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Preparing events for publication, authenticating user actions
</p>
<div class="badge badge-outline">Promise&lt;NostrEvent&gt;</div>
</div>
</div>
</div>
<div>
<h4 class="font-bold text-lg mb-4 text-info">🔍 Reading & Querying Data</h4>
<div class="space-y-4">
<div class="border-l-4 border-info pl-4">
<h5 class="font-bold">getSingleEventById(id)</h5>
<p class="text-sm opacity-80 mb-2">
Retrieves a specific event when you know its exact ID. Perfect for loading specific posts or data.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Loading a specific message, fetching referenced content, getting event
details
</p>
<div class="badge badge-outline">Promise&lt;NostrEvent | null&gt;</div>
</div>
<div class="border-l-4 border-info pl-4">
<h5 class="font-bold">getSingleEventWithFilter(filter)</h5>
<p class="text-sm opacity-80 mb-2">
Gets the first event matching your criteria. Useful when you expect only one result or want the most
recent.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Getting a user's latest profile, finding the most recent post, checking
if something exists
</p>
<div class="badge badge-outline">Promise&lt;NostrEvent | null&gt;</div>
</div>
<div class="border-l-4 border-info pl-4">
<h5 class="font-bold">getAllEventsWithFilter(filter)</h5>
<p class="text-sm opacity-80 mb-2">
Gets all events matching your criteria. Use this for lists, feeds, or when you need multiple
results.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Building feeds, loading message history, getting all posts by a user
</p>
<div class="badge badge-outline">Promise&lt;NostrEvent[]&gt;</div>
</div>
</div>
</div>
<div>
<h4 class="font-bold text-lg mb-4 text-accent">🔄 Real-time Subscriptions</h4>
<div class="space-y-4">
<div class="border-l-4 border-accent pl-4">
<h5 class="font-bold">subscribeToEvents(filter)</h5>
<p class="text-sm opacity-80 mb-2">
Creates a live stream of events matching your filter. Your app updates automatically when new events
arrive.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Live chat, real-time feeds, notifications, collaborative features
</p>
<div class="badge badge-outline">Observable&lt;NostrEvent&gt;</div>
</div>
<div class="border-l-4 border-accent pl-4">
<h5 class="font-bold">subscribeToProfile(pubkey)</h5>
<p class="text-sm opacity-80 mb-2">
Watches for profile changes for a specific user. Updates automatically when they change their name,
bio, avatar, etc.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> User profile displays, contact lists, member directories
</p>
<div class="badge badge-outline">Observable&lt;Profile&gt;</div>
</div>
</div>
</div>
<div>
<h4 class="font-bold text-lg mb-4 text-warning">👤 User & Profile Helpers</h4>
<div class="space-y-4">
<div class="border-l-4 border-warning pl-4">
<h5 class="font-bold">getProfile(pubkey)</h5>
<p class="text-sm opacity-80 mb-2">
Retrieves user profile information (name, bio, avatar, etc.) for any user by their public key.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Displaying user info, building contact lists, showing message authors
</p>
<div class="badge badge-outline">Promise&lt;Profile | null&gt;</div>
</div>
<div class="border-l-4 border-warning pl-4">
<h5 class="font-bold">getAvatar(pubkey)</h5>
<p class="text-sm opacity-80 mb-2">
Quick helper to get just the avatar URL from a user's profile. Saves you from parsing the full
profile.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Profile pictures, user avatars in lists, message author images
</p>
<div class="badge badge-outline">Promise&lt;string | null&gt;</div>
</div>
<div class="border-l-4 border-warning pl-4">
<h5 class="font-bold">publicKey</h5>
<p class="text-sm opacity-80 mb-2">
Gets the current user's public key. This identifies the user and is needed for many operations.
</p>
<p class="text-sm mb-2">
<strong>Use cases:</strong> Identifying the current user, filtering their content, permission checks
</p>
<div class="badge badge-outline">Promise&lt;string&gt;</div>
</div>
</div>
</div>
</div>
<div class="mt-8">
<h4 class="font-semibold mb-3 text-lg">Practical Example:</h4>
<p class="mb-3 text-sm">
Here's how these functions work together in a real Arxlet. This example shows fetching events, displaying
user profiles, and handling real-time updates:
</p>
<CodeBlock language="typescript" code={eveApiExample} />
</div>
</div>
</div>
{/* Real-time Subscriptions */}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="real-time-subscriptions" class="card-title text-accent mb-4">
🔄 Understanding Real-time Subscriptions
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>What are subscriptions?</strong> 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.
</p>
<p>
<strong>How they work:</strong> 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.
</p>
<p>
<strong>Why use them?</strong> Subscriptions make your Arxlet feel alive and responsive. Users see new
messages instantly, profile changes update immediately, and collaborative features work in real-time.
</p>
</div>
<div class="grid md:grid-cols-2 gap-6 mb-6">
<div class="border-2 border-accent rounded-lg p-4">
<h4 class="font-bold text-accent mb-3">🎯 Event Subscriptions</h4>
<p class="text-sm mb-3">
<code>subscribeToEvents(filter)</code> gives you a live stream of events matching your criteria.
</p>
<div class="space-y-2 text-sm">
<div>
<strong>Perfect for:</strong>
</div>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>Live chat applications</li>
<li>Real-time feeds and timelines</li>
<li>Notification systems</li>
<li>Collaborative tools</li>
<li>Activity monitoring</li>
</ul>
</div>
</div>
<div class="border-2 border-warning rounded-lg p-4">
<h4 class="font-bold text-warning mb-3">👤 Profile Subscriptions</h4>
<p class="text-sm mb-3">
<code>subscribeToProfile(pubkey)</code> watches for changes to a specific user's profile.
</p>
<div class="space-y-2 text-sm">
<div>
<strong>Perfect for:</strong>
</div>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>User profile displays</li>
<li>Contact lists that stay current</li>
<li>Member directories</li>
<li>Avatar/name displays</li>
<li>User status indicators</li>
</ul>
</div>
</div>
</div>
<div class="mb-6">
<h4 class="font-semibold mb-3 text-lg">How to Use Subscriptions:</h4>
<p class="mb-3 text-sm">
Here's a complete example showing how to set up subscriptions, handle incoming data, and clean up
properly:
</p>
<CodeBlock language="typescript" code={subscriptionExamples} />
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="alert alert-warning">
<div>
<h4 class="font-bold mb-2">! Memory Management</h4>
<div class="text-sm space-y-1">
<p>
Always call <code>unsubscribe()</code> when:
</p>
<ul class="list-disc list-inside ml-2">
<li>Your component unmounts</li>
<li>User navigates away</li>
<li>You no longer need the data</li>
<li>Your Arxlet is closing</li>
</ul>
<p class="mt-2">
<strong>Why?</strong> Prevents memory leaks and unnecessary disk i/o.
</p>
</div>
</div>
</div>
<div class="alert alert-success">
<div>
<h4 class="font-bold mb-2"> Pro Tips</h4>
<div class="text-sm space-y-1">
<ul class="list-disc list-inside">
<li>Use specific filters to reduce data volume</li>
<li>Debounce rapid updates for better UX</li>
<li>Cache data to avoid duplicate processing</li>
<li>
Handle errors gracefully with <code>catchError</code>
</li>
<li>
Consider using <code>takeUntil</code> for automatic cleanup
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{/* WebSocket Alternative */}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="websocket-alternative" class="card-title text-accent mb-4">
🔌 Direct WebSocket Connection - Advanced Usage
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>What is the WebSocket approach?</strong> Instead of using the convenient <code>window.eve</code>{" "}
API, you can connect directly to the Nostr relay at <code>ws://localhost:6942</code> and speak the raw
Nostr protocol.
</p>
<p>
<strong>Why would you use this?</strong> 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.
</p>
<p>
<strong>The trade-off:</strong> 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.
</p>
</div>
<div class="mb-6">
<h4 class="font-semibold mb-3 text-lg">WebSocket Implementation Example:</h4>
<p class="mb-3 text-sm">
Here's how to establish a WebSocket connection and communicate using standard Nostr protocol messages:
</p>
<CodeBlock language="typescript" code={websocketExample} />
</div>
<div class="grid md:grid-cols-2 gap-6 mb-6">
<div class="border-2 border-success rounded-lg p-4">
<h4 class="font-bold text-success mb-3"> Use window.eve When:</h4>
<div class="space-y-2 text-sm">
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Building your first Arxlet</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>You want simple, clean code</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Standard CRUD operations are enough</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>You prefer promise-based APIs</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>Built-in RxJS observables work for you</span>
</div>
<div class="flex items-start gap-2">
<span class="text-success"></span>
<span>You don't need custom protocol handling</span>
</div>
</div>
</div>
<div class="border-2 border-accent rounded-lg p-4">
<h4 class="font-bold text-accent mb-3"> Use WebSocket When:</h4>
<div class="space-y-2 text-sm">
<div class="flex items-start gap-2">
<span class="text-accent"></span>
<span>You need maximum performance</span>
</div>
<div class="flex items-start gap-2">
<span class="text-accent"></span>
<span>Custom subscription management required</span>
</div>
<div class="flex items-start gap-2">
<span class="text-accent"></span>
<span>Integrating existing Nostr libraries</span>
</div>
<div class="flex items-start gap-2">
<span class="text-accent"></span>
<span>You understand the Nostr protocol</span>
</div>
<div class="flex items-start gap-2">
<span class="text-accent"></span>
<span>Need fine-grained connection control</span>
</div>
<div class="flex items-start gap-2">
<span class="text-accent"></span>
<span>Building high-frequency applications</span>
</div>
</div>
</div>
</div>
<div class="alert alert-info">
<div>
<h4 class="font-bold mb-2">🎯 Choosing the Right Approach</h4>
<div class="text-sm space-y-2">
<p>
<strong>Start with window.eve:</strong> 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.
</p>
<p>
<strong>Hybrid approach:</strong> Many successful Arxlets use <code>window.eve</code> for most
operations and WebSocket only for specific high-performance features like real-time chat or live
collaboration.
</p>
<p>
<strong>Migration path:</strong> The data structures are the same, so you can gradually migrate from{" "}
<code>window.eve</code>
to WebSocket for specific features without rewriting your entire application.
</p>
</div>
</div>
</div>
</div>
</div>
{/* Best Practices */}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="best-practices" class="card-title text-warning mb-4">
💡 Best Practices for Robust Arxlets
</h3>
<div class="space-y-6">
<div class="border-l-4 border-error pl-4">
<h4 class="font-bold text-lg text-error mb-3">🛡 Error Handling & Reliability</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Always use try-catch blocks:</strong>
<p>
Network requests can fail, relays can be down, or data might be malformed. Wrap all API calls to
prevent crashes.
</p>
</div>
<div>
<strong>Check for null/undefined returns:</strong>
<p>
Query methods return <code>null</code> when no data is found. Always check before using the result.
</p>
</div>
<div>
<strong>Provide meaningful user feedback:</strong>
<p>
Show loading states, error messages, and success confirmations. Users should know what's happening.
</p>
</div>
<div>
<strong>Implement retry logic for critical operations:</strong>
<p>Publishing events or loading essential data should retry on failure with exponential backoff.</p>
</div>
</div>
</div>
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg text-success mb-3"> Performance & Efficiency</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Use specific, narrow filters:</strong>
<p>
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
transfer.
</p>
</div>
<div>
<strong>Cache frequently accessed data:</strong>
<p>Profile information, avatars, and static content should be cached to avoid repeated API calls.</p>
</div>
<div>
<strong>Implement pagination for large datasets:</strong>
<p>
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
pagination.
</p>
</div>
<div>
<strong>Debounce rapid user actions:</strong>
<p>
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
relay.
</p>
</div>
<div>
<strong>Unsubscribe from observables:</strong>
<p>Always clean up subscriptions to prevent memory leaks and unnecessary network traffic.</p>
</div>
</div>
</div>
<div class="border-l-4 border-info pl-4">
<h4 class="font-bold text-lg text-info mb-3">🎯 User Experience</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Show loading states:</strong>
<p>Use spinners, skeletons, or progress indicators while data loads. Empty screens feel broken.</p>
</div>
<div>
<strong>Handle empty states gracefully:</strong>
<p>When no data is found, show helpful messages or suggestions rather than blank areas.</p>
</div>
<div>
<strong>Implement optimistic updates:</strong>
<p>
Update the UI immediately when users take actions, then sync with the server. Makes apps feel
faster.
</p>
</div>
<div>
<strong>Provide offline indicators:</strong>
<p>Let users know when they're disconnected or when operations might not work.</p>
</div>
</div>
</div>
<div class="border-l-4 border-warning pl-4">
<h4 class="font-bold text-lg text-warning mb-3">🔒 Security & Privacy</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Validate all user inputs:</strong>
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
</div>
<div>
<strong>Be mindful of public data:</strong>
<p>
Remember that events are visible to everyone in your CCN by default. Don't accidentally expose
private information.
</p>
</div>
<div>
<strong>Handle signing errors gracefully:</strong>
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
</div>
<div>
<strong>Respect user privacy preferences:</strong>
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
</div>
</div>
</div>
</div>
<div class="alert alert-success mt-6">
<div>
<h4 class="font-bold mb-2">🚀 Quick Checklist for Production Arxlets</h4>
<div class="grid md:grid-cols-2 gap-4 text-sm">
<div>
<strong>Code Quality:</strong>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>All API calls wrapped in try-catch</li>
<li>Null checks before using data</li>
<li>Subscriptions properly cleaned up</li>
<li>Input validation implemented</li>
</ul>
</div>
<div>
<strong>User Experience:</strong>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Loading states for all async operations</li>
<li>Error messages are user-friendly</li>
<li>Empty states handled gracefully</li>
<li>Performance tested with large datasets</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -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 = \`<span>\${message}</span>\`;
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 \`
<div class="flex justify-center items-center p-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="ml-4">Loading profiles...</span>
</div>
\`;
}
function createEmptyState() {
return \`
<div class="text-center p-8">
<div class="text-6xl mb-4">📭</div>
<h3 class="text-lg font-semibold mb-2">No messages yet</h3>
<p class="text-base-content/70">Be the first to start a conversation!</p>
<button class="btn btn-primary mt-4" onclick="openComposer()">
Write a message
</button>
</div>
\`;
}
// 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 (
<div class="space-y-8">
<div class="text-center mb-12">
<h2 class="text-4xl font-bold mb-4"> Best Practices</h2>
<p class="text-xl text-base-content/70 max-w-3xl mx-auto">
Master the art of building production-ready Arxlets with these comprehensive development guidelines
</p>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-info mb-4">Building Production-Ready Arxlets</h3>
<div class="space-y-4">
<p>
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.
</p>
<p>
<strong>Why these practices matter:</strong> 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.
</p>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="error-handling" class="card-title text-error mb-4">
🛡 Error Handling & Reliability
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>User experience first:</strong> 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.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-error pl-4">
<h4 class="font-bold text-lg mb-3">Essential Error Handling Patterns</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Wrap all API calls in try-catch blocks:</strong>
<p>
Every call to <code>window.eve</code> functions can potentially fail. Always handle exceptions.
</p>
</div>
<div>
<strong>Check for null/undefined returns:</strong>
<p>
Query methods return <code>null</code> when no data is found. Verify results before using them.
</p>
</div>
<div>
<strong>Provide meaningful user feedback:</strong>
<p>Show specific error messages that help users understand what went wrong and how to fix it.</p>
</div>
<div>
<strong>Implement retry logic for critical operations:</strong>
<p>
Publishing events or loading essential data should retry automatically with exponential backoff.
</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">Practical Error Handling Example:</h4>
<CodeBlock language="typescript" code={errorHandlingExample} />
</div>
</div>
<div class="grid md:grid-cols-2 gap-4 mt-6">
<div class="alert alert-error">
<div>
<h4 class="font-bold mb-2"> Common Mistakes</h4>
<ul class="text-sm list-disc list-inside space-y-1">
<li>Not handling API failures</li>
<li>Assuming data will always exist</li>
<li>Silent failures with no user feedback</li>
<li>Generic "Something went wrong" messages</li>
<li>No retry mechanisms for critical operations</li>
</ul>
</div>
</div>
<div class="alert alert-success">
<div>
<h4 class="font-bold mb-2"> Best Practices</h4>
<ul class="text-sm list-disc list-inside space-y-1">
<li>Specific, actionable error messages</li>
<li>Graceful degradation when features fail</li>
<li>Loading states for all async operations</li>
<li>Retry buttons for failed operations</li>
<li>Offline indicators when appropriate</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="performance" class="card-title text-success mb-4">
Performance & Efficiency
</h3>
<div class="space-y-6">
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg mb-3">Performance Optimization Strategies</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Use specific, narrow filters:</strong>
<p>
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
transfer.
</p>
</div>
<div>
<strong>Implement intelligent caching:</strong>
<p>Cache profile information, avatars, and other static content to avoid repeated API calls.</p>
</div>
<div>
<strong>Paginate large datasets:</strong>
<p>
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
pagination.
</p>
</div>
<div>
<strong>Debounce rapid user actions:</strong>
<p>
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
relay.
</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">Performance Optimization Example:</h4>
<CodeBlock language="typescript" code={performanceExample} />
</div>
</div>
<div class="alert alert-warning mt-6">
<div>
<h4 class="font-bold mb-2">! Performance Pitfalls to Avoid</h4>
<div class="text-sm space-y-2">
<p>
<strong>Overly broad filters:</strong> Fetching all events and filtering client-side wastes bandwidth.
</p>
<p>
<strong>No pagination:</strong> Loading thousands of items at once can freeze the interface.
</p>
<p>
<strong>Repeated API calls:</strong> Fetching the same profile data multiple times is inefficient.
</p>
<p>
<strong>Unthrottled user input:</strong> Search-as-you-type without debouncing can overwhelm the
relay.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="subscriptions" class="card-title text-accent mb-4">
🔄 Subscription Management
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>Subscriptions power real-time features.</strong> They make your Arxlet feel alive by automatically
updating when new data arrives. However, they need careful management to prevent memory leaks.
</p>
<p>
<strong>Clean up is critical.</strong> Forgetting to unsubscribe from observables can cause memory leaks,
unnecessary disk i/o, and performance degradation over time.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-accent pl-4">
<h4 class="font-bold text-lg mb-3">Subscription Best Practices</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Always store subscription references:</strong>
<p>Keep references to all subscriptions so you can unsubscribe when needed.</p>
</div>
<div>
<strong>Implement proper cleanup:</strong>
<p>Unsubscribe when components unmount, users navigate away, or the Arxlet closes.</p>
</div>
<div>
<strong>Use specific filters:</strong>
<p>Narrow subscription filters reduce unnecessary data and improve performance.</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">Proper Subscription Management:</h4>
<CodeBlock language="typescript" code={subscriptionExample} />
</div>
</div>
<div class="alert alert-error mt-6">
<div>
<h4 class="font-bold mb-2">🚨 Memory Leak Prevention</h4>
<div class="text-sm space-y-2">
<p>
<strong>Always unsubscribe:</strong> Every <code>subscribe()</code> call must have a corresponding{" "}
<code>unsubscribe()</code>.
</p>
<p>
<strong>Clean up on navigation:</strong> Users might navigate away without properly closing your
Arxlet.
</p>
<p>
<strong>Handle page refresh:</strong> Use <code>beforeunload</code> event to clean up subscriptions.
</p>
<p>
<strong>Monitor subscription count:</strong> Too many active subscriptions can impact performance.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="user-experience" class="card-title text-info mb-4">
🎯 User Experience Excellence
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>Great UX makes the difference.</strong> Users expect responsive, intuitive interfaces that provide
clear feedback. Small details like loading states and empty state messages significantly impact user
satisfaction.
</p>
<p>
<strong>Consistency with CCN design.</strong> Using DaisyUI components ensures your Arxlet feels
integrated with the rest of the platform while saving you development time.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-info pl-4">
<h4 class="font-bold text-lg mb-3">UX Best Practices</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Show loading states for all async operations:</strong>
<p>Users should never see blank screens or wonder if something is happening.</p>
</div>
<div>
<strong>Handle empty states gracefully:</strong>
<p>When no data is available, provide helpful messages or suggestions for next steps.</p>
</div>
<div>
<strong>Implement optimistic updates:</strong>
<p>Update the UI immediately when users take actions, then sync with the server.</p>
</div>
<div>
<strong>Use consistent DaisyUI components:</strong>
<p>Leverage the pre-built component library for consistent styling and behavior.</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">UI/UX Implementation Examples:</h4>
<CodeBlock language="typescript" code={uiExample} />
</div>
</div>
<div class="grid md:grid-cols-2 gap-4 mt-6">
<div class="border-2 border-success rounded-lg p-4">
<h4 class="font-bold text-success mb-3"> Excellent UX Includes</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Loading spinners for async operations</li>
<li>Helpful empty state messages</li>
<li>Immediate feedback for user actions</li>
<li>Clear error messages with solutions</li>
<li>Consistent visual design</li>
<li>Accessible keyboard navigation</li>
<li>Responsive layout for different screen sizes</li>
</ul>
</div>
<div class="border-2 border-warning rounded-lg p-4">
<h4 class="font-bold text-warning mb-3">! UX Anti-patterns</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Blank screens during loading</li>
<li>No feedback for user actions</li>
<li>Generic or confusing error messages</li>
<li>Inconsistent styling with CCN</li>
<li>Broken layouts on mobile devices</li>
<li>Inaccessible interface elements</li>
<li>Slow or unresponsive interactions</li>
</ul>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="security" class="card-title text-warning mb-4">
🔒 Security & Privacy Considerations
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>Security is everyone's responsibility.</strong> Even though Arxlets run in a sandboxed
environment, you still need to validate inputs, handle user data responsibly, and follow security best
practices.
</p>
<p>
<strong>Privacy by design.</strong> Remember that Nostr events are public by default. Be mindful of what
data you're storing and how you're handling user information.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-warning pl-4">
<h4 class="font-bold text-lg mb-3">Security Best Practices</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Validate all user inputs:</strong>
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
</div>
<div>
<strong>Be mindful of public data:</strong>
<p>Nostr events are public by default. Don't accidentally expose private information.</p>
</div>
<div>
<strong>Handle signing errors gracefully:</strong>
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
</div>
<div>
<strong>Respect user privacy preferences:</strong>
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
</div>
<div>
<strong>Sanitize HTML content:</strong>
<p>If displaying user-generated content, sanitize it to prevent XSS attacks.</p>
</div>
</div>
</div>
</div>
<div class="alert alert-error mt-6">
<div>
<h4 class="font-bold mb-2">🚨 Security Checklist</h4>
<div class="grid md:grid-cols-2 gap-4 text-sm">
<div>
<strong>Input Validation:</strong>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Validate all form inputs</li>
<li>Sanitize user-generated content</li>
<li>Check data types and ranges</li>
<li>Escape HTML when displaying content</li>
</ul>
</div>
<div>
<strong>Privacy Protection:</strong>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Don't store sensitive data in events</li>
<li>Respect user anonymity preferences</li>
<li>Handle signing rejections gracefully</li>
<li>Be transparent about data usage</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 id="production-checklist" class="card-title text-success mb-4">
🚀 Production Readiness Checklist
</h3>
<div class="space-y-4 mb-6">
<p>
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.
</p>
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-success mb-3"> Code Quality</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>All API calls wrapped in try-catch blocks</li>
<li>Null/undefined checks before using data</li>
<li>Subscriptions properly cleaned up</li>
<li>Input validation implemented</li>
<li>Error handling with user feedback</li>
<li>Performance optimizations applied</li>
<li>Code is well-commented and organized</li>
</ul>
</div>
<div class="border-l-4 border-info pl-4">
<h4 class="font-bold text-info mb-3">🎯 User Experience</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Loading states for all async operations</li>
<li>Error messages are user-friendly</li>
<li>Empty states handled gracefully</li>
<li>Consistent DaisyUI styling</li>
<li>Responsive design for mobile</li>
<li>Keyboard navigation works</li>
<li>Accessibility features implemented</li>
</ul>
</div>
</div>
<div class="space-y-4">
<div class="border-l-4 border-warning pl-4">
<h4 class="font-bold text-warning mb-3">🔒 Security & Privacy</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>User inputs are validated and sanitized</li>
<li>No sensitive data in public events</li>
<li>Signing errors handled gracefully</li>
<li>Privacy preferences respected</li>
<li>HTML content properly escaped</li>
<li>No hardcoded secrets or keys</li>
<li>Data usage is transparent</li>
</ul>
</div>
<div class="border-l-4 border-accent pl-4">
<h4 class="font-bold text-accent mb-3"> Performance</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Efficient Nostr filters used</li>
<li>Data caching implemented</li>
<li>Pagination for large datasets</li>
<li>User actions are debounced</li>
<li>Memory leaks prevented</li>
<li>Bundle size optimized</li>
<li>Performance tested with large datasets</li>
</ul>
</div>
</div>
</div>
<div class="alert alert-success mt-6">
<div>
<h4 class="font-bold mb-2">🎉 Ready for Production!</h4>
<p class="text-sm">
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.
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -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 (
<div class="mockup-code relative">
<button
class="absolute top-2 right-2 btn btn-ghost btn-sm"
type="button"
onClick={handleCopy}
aria-label="Copy code"
>
{isCopied ? (
<span class="text-success">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</span>
) : (
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</button>
<pre>
<code class={`language-${language}`}>{code?.trim()}</code>
</pre>
</div>
);
};

View file

@ -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 (
<div class="space-y-6">
<h2 class="text-3xl font-bold">Development Guide</h2>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 id="understanding-arxlets" class="card-title text-info mb-4">
Understanding the Arxlet Environment
</h3>
<p class="mb-6">
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:
</p>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 id="nostr-vs-arxlets" class="card-title text-purple-600 mb-4">
🔄 Nostr Apps vs Arxlets: What's the Difference?
</h3>
<div class="space-y-4 mb-6">
<p>
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:
</p>
<div class="grid md:grid-cols-2 gap-6">
<div class="border-2 border-success rounded-lg p-4">
<h4 class="font-bold text-success mb-3"> Nostr App Arxlet</h4>
<p class="text-sm mb-3">
<strong>Most Nostr apps CAN become Arxlets</strong> with some modifications:
</p>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>
Replace external API calls with <code>window.eve</code> or local relay
</li>
<li>Adapt the UI to work within a container element</li>
<li>Remove routing if it conflicts with CCN navigation</li>
<li>
Use the provided <code>window.nostr</code> for signing
</li>
<li>Bundle everything into a single JavaScript file</li>
</ul>
</div>
<div class="border-2 border-warning rounded-lg p-4">
<h4 class="font-bold text-warning mb-3">! Arxlet Nostr App</h4>
<p class="text-sm mb-3">
<strong>Not every Arxlet works as a standalone Nostr app</strong> because:
</p>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>
May depend on CCN-specific APIs (<code>window.eve</code>)
</li>
<li>Designed for the sandboxed environment</li>
<li>Might rely on CCN member data or community features</li>
<li>UI optimized for container-based rendering</li>
<li>No independent relay connections</li>
</ul>
</div>
</div>
</div>
<div class="alert alert-info">
<div>
<h4 class="font-bold mb-2">💡 Practical Examples:</h4>
<div class="text-sm space-y-2">
<p>
<strong>Easy to Port:</strong> A simple note-taking app, image gallery, or profile viewer can usually
be adapted to work as both.
</p>
<p>
<strong>CCN-Specific:</strong> A CCN member directory, community chat, or collaborative workspace
might only make sense as an Arxlet.
</p>
<p>
<strong>Hybrid Approach:</strong> Many developers create a core library that works in both
environments, then build different interfaces for standalone vs Arxlet deployment.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="available-apis" class="card-title text-success mb-4">
What You Can Use
</h3>
<div class="space-y-6">
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg">window.eve - Your CCN Toolkit</h4>
<p class="text-sm opacity-80 mb-2">
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.
</p>
<p class="text-sm">
<strong>What it does:</strong> Lets you fetch events, publish new ones, manage user data, and access
CCN-specific features like member directories.
</p>
</div>
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg">window.nostr - Cryptographic Signing (NIP-07)</h4>
<p class="text-sm opacity-80 mb-2">
This is the standard Nostr extension API that lets your Arxlet create and sign events using the user's
private key.
</p>
<p class="text-sm">
<strong>What it does:</strong> Allows your app to publish events on behalf of the user, like posting
messages, creating profiles, or any other Nostr activity that requires authentication.
</p>
</div>
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg">DaisyUI 5 - Pre-built UI Components</h4>
<p class="text-sm opacity-80 mb-2">
A complete CSS framework with beautiful, accessible components already loaded and ready to use.
</p>
<p class="text-sm">
<strong>What it does:</strong> Provides buttons, cards, modals, forms, and dozens of other UI components
with consistent styling. No need to write CSS from scratch.
</p>
</div>
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg">Local Relay Connection</h4>
<p class="text-sm opacity-80 mb-2">
Direct WebSocket connection to <code>ws://localhost:6942</code> for real-time Nostr event streaming.
</p>
<p class="text-sm">
<strong>What it does:</strong> Lets you subscribe to live event feeds, get real-time updates, and
implement features like live chat or notifications.
</p>
</div>
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg">CCN Member Events</h4>
<p class="text-sm opacity-80 mb-2">Access to events from other members of your current CCN community.</p>
<p class="text-sm">
<strong>What it does:</strong> Enables community features like member directories, shared content, group
discussions, and collaborative tools.
</p>
</div>
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg">Standard Web APIs</h4>
<p class="text-sm opacity-80 mb-2">
Full access to modern browser APIs like localStorage, fetch (for local requests), DOM manipulation, and
more.
</p>
<p class="text-sm">
<strong>What it does:</strong> Everything you'd expect in a web app - store data locally, manipulate the
page, handle user interactions, etc.
</p>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="security-restrictions" class="card-title text-warning mb-4">
🔒 Security & Limitations
</h3>
<div class="space-y-4">
<div class="border-l-4 border-warning pl-4">
<h4 class="font-bold">No External Network Access</h4>
<p class="text-sm">
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.
</p>
</div>
<div class="border-l-4 border-warning pl-4">
<h4 class="font-bold">CCN-Scoped Data</h4>
<p class="text-sm">
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.
</p>
</div>
</div>
<div class="alert alert-info mt-6">
<span>
💡 <strong>Why These Restrictions?</strong> 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.
</span>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="alert alert-info">
<span>
📚 <strong>Need More Details?</strong> Check the{" "}
<button
type="button"
class="link link-primary font-semibold underline"
onClick={() => {
// Find and click the API Reference tab
const tabs = document.querySelectorAll(".menu > li > a");
const apiTab = Array.from(tabs).find((tab) => tab.textContent.trim() === "API Reference");
if (apiTab) {
apiTab.click();
// Scroll to top after tab switch
setTimeout(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, 100);
}
}}
>
API Reference
</button>{" "}
tab for comprehensive documentation of the <code>window.eve</code> API, code examples, and WebSocket usage
patterns.
</span>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="typescript-development" class="card-title text-info mb-4">
🚀 Building Your Arxlet with TypeScript
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>What is TypeScript?</strong> 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.
</p>
<p>
<strong>Why use it for Arxlets?</strong> Eve provides TypeScript definitions for all APIs, so you'll get
autocomplete for <code>window.eve</code> functions, proper error checking, and better documentation right
in your editor.
</p>
</div>
<div class="mb-6">
<h4 class="font-semibold mb-3 text-lg">Building Your Code</h4>
<p class="mb-3">
Since Arxlets need to be a single JavaScript file, you'll use <strong>Bun</strong> (a fast JavaScript
runtime and bundler) to compile your TypeScript code. Here's the command that does everything:
</p>
<CodeBlock language="bash" code={buildCommand} />
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mt-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-amber-700">
<strong> Svelte Exception:</strong> The above build command will NOT work for Svelte projects.
Svelte requires specific Vite configuration to compile properly. Instead, use our{" "}
<a
href="https://git.arx-ccn.com/Arx/arxlets-template"
class="link link-primary"
target="_blank"
rel="noopener noreferrer"
>
arxlets-template
</a>{" "}
and simply run <code class="bg-amber-100 px-1 rounded">bun run build</code>. Your compiled file will
be available at <code class="bg-amber-100 px-1 rounded">dist/bundle.js</code> once built.
</p>
</div>
</div>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6 mt-6">
<div class="space-y-3">
<h4 class="font-semibold text-lg">What Each Build Option Does:</h4>
<div class="space-y-3">
<div class="border-l-4 border-info pl-3">
<code class="font-bold">--minify</code>
<p class="text-sm">
Removes whitespace and shortens variable names to make your file smaller. Smaller files load faster.
</p>
</div>
<div class="border-l-4 border-info pl-3">
<code class="font-bold">--target=browser</code>
<p class="text-sm">Tells Bun to optimize the code for web browsers instead of server environments.</p>
</div>
<div class="border-l-4 border-info pl-3">
<code class="font-bold">--production</code>
<p class="text-sm">
Enables all optimizations and removes development-only code for better performance.
</p>
</div>
</div>
</div>
<div class="space-y-3">
<h4 class="font-semibold text-lg">Why TypeScript Helps:</h4>
<div class="space-y-3">
<div class="border-l-4 border-success pl-3">
<strong>Catch Errors Early</strong>
<p class="text-sm">
TypeScript finds mistakes like typos in function names or wrong parameter types before you run your
code.
</p>
</div>
<div class="border-l-4 border-success pl-3">
<strong>Better Autocomplete</strong>
<p class="text-sm">
Your editor will suggest available functions and show you what parameters they expect.
</p>
</div>
<div class="border-l-4 border-success pl-3">
<strong>Easier Refactoring</strong>
<p class="text-sm">
When you rename functions or change interfaces, TypeScript helps update all the places that use
them.
</p>
</div>
<div class="border-l-4 border-success pl-3">
<strong>Self-Documenting Code</strong>
<p class="text-sm">
Type annotations serve as inline documentation, making your code easier to understand later.
</p>
</div>
</div>
</div>
</div>
<div class="alert alert-success mt-6">
<div>
<h4 class="font-bold mb-2">Recommended Development Workflow:</h4>
<ol class="list-decimal list-inside space-y-1 text-sm">
<li>
Create your main file as <kbd class="kbd">index.ts</kbd> (TypeScript)
</li>
<li>Write your Arxlet code with full TypeScript features</li>
<li>
Run the build command to create <kbd class="kbd">build.js</kbd>
</li>
<li>
Copy the contents of <kbd class="kbd">build.js</kbd> into your Nostr registration event
</li>
<li>Repeat steps 2-4 as you develop and test</li>
</ol>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="required-export-function" class="card-title mb-4">
The Heart of Your Arxlet: The Render Function
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>What is the render function?</strong> This is the main entry point of your Arxlet - think of it as
the <code>main()</code>
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.
</p>
<p>
<strong>How it works:</strong> The platform creates an empty <code>&lt;div&gt;</code> 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.
</p>
<p>
<strong>Why this pattern?</strong> This approach gives you complete control over your app's interface
while keeping it isolated from other Arxlets and the main CCN interface.
</p>
</div>
<h4 class="font-semibold mb-3 text-lg">Basic Structure:</h4>
<CodeBlock language="typescript" code={renderFunction} />
<div class="mt-6 space-y-4">
<h4 class="font-semibold text-lg">Breaking Down the Example:</h4>
<div class="grid gap-4">
<div class="border-l-4 border-primary pl-4">
<code class="font-bold">export function render(container: HTMLElement)</code>
<p class="text-sm mt-1">
This declares your main function. The <code>container</code> parameter is the DOM element where your
app will live. The <code>export</code> keyword makes it available to the CCN.
</p>
</div>
<div class="border-l-4 border-primary pl-4">
<code class="font-bold">container.innerHTML = '...'</code>
<p class="text-sm mt-1">
This is the simplest way to add content - just set the HTML directly. For simple Arxlets, this might
be all you need.
</p>
</div>
<div class="border-l-4 border-primary pl-4">
<code class="font-bold">const button = container.querySelector('button')</code>
<p class="text-sm mt-1">
After adding HTML, you can find elements and attach event listeners to make your app interactive.
</p>
</div>
<div class="border-l-4 border-primary pl-4">
<code class="font-bold">window.eve.getEvents(...)</code>
<p class="text-sm mt-1">
This shows how to use the CCN API to fetch Nostr events. Most Arxlets will interact with Nostr data in
some way.
</p>
</div>
</div>
</div>
<div class="alert alert-success mt-6">
<div>
<h4 class="font-bold mb-2">Development Tips:</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>
<strong>Start Simple:</strong> Begin with basic HTML and gradually add interactivity
</li>
<li>
<strong>Use Modern JavaScript:</strong> async/await, destructuring, arrow functions - it all works
</li>
<li>
<strong>Leverage DaisyUI:</strong> Use pre-built components instead of writing CSS from scratch
</li>
<li>
<strong>Handle Errors:</strong> Wrap API calls in try/catch blocks for better user experience
</li>
<li>
<strong>Think Reactive:</strong> Update the UI when data changes, don't just set it once
</li>
</ul>
</div>
</div>
<div class="alert alert-info mt-4">
<span>
💡 <strong>Advanced Patterns:</strong> 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!
</span>
</div>
</div>
</div>
</div>
);
};

View file

@ -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: <CounterExample />,
preact: <PreactCounterExample />,
svelte: <SvelteCounterExample />,
nostr: <NostrPublisherExample />,
};
/**
* Examples Section - Practical Arxlet implementations
* Shows real-world examples with detailed explanations
*/
export const ExamplesSection = ({ activeExample }) => {
const ActiveComponent = examples[activeExample] || <div>Example not found</div>;
return (
<div class="space-y-6">
<h2 class="text-3xl font-bold">Example Arxlets</h2>
<div class="bg-info border-l-4 border-info/50 p-4 mb-6">
<div class="flex">
<div class="ml-3 text-info-content">
<p class="text-sm text-info-content">
<strong>Framework Freedom:</strong> 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.
</p>
<p class="text-sm t-2">
Check out{" "}
<a
href="https://bun.com/docs/runtime/plugins"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-blue-900"
>
Bun's plugin documentation
</a>{" "}
to see how you can integrate your preferred tools and frameworks.
</p>
<p class="text-sm mt-2">
If you have any questions or run into problems, feel free to reach out to the team @ Arx (builders of Eve)
on Nostr: <kbd class="kbd">npub1ven4zk8xxw873876gx8y9g9l9fazkye9qnwnglcptgvfwxmygscqsxddfhif</kbd>
</p>
</div>
</div>
</div>
{/* Tab Content */}
<div class="mt-6">{ActiveComponent}</div>
</div>
);
};

View file

@ -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 (
<section id="llms" className="arxlet-docs-section">
<CodeBlock code={contextMd} />
</section>
);
};

View file

@ -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 (
<div class="space-y-8">
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">What are Arxlets?</h2>
<p class="text-lg mb-4">
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.
</p>
</div>
</div>
<div class="alert alert-info">
<span>
<strong>Coming Soon:</strong> WASM support will be added in future releases for even more powerful
applications.
</span>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title text-secondary">📝 TypeScript Definitions</h3>
<p class="mb-4">Use these type definitions for full TypeScript support in your Arxlets:</p>
<CodeBlock language="typescript" code={typeDefinitions} />
<div class="alert alert-info mt-4">
<span>
<strong>Pro Tip:</strong> Save these types in a <code>types.ts</code> file and import them throughout your
Arxlet for better development experience and type safety.
</span>
</div>
</div>
</div>
</div>
);
};

View file

@ -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 (
<div class="space-y-6">
<h2 class="text-3xl font-bold">Arxlet Registration</h2>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-info">What are Nostr Events?</h3>
<div class="space-y-3">
<p>
<strong>Nostr</strong> (Notes and Other Stuff Transmitted by Relays) is a decentralized protocol where all
data is stored as <strong>events</strong>. Think of events as structured messages that contain information
and are cryptographically signed by their authors.
</p>
<p>Each event has:</p>
<ul class="list-disc list-inside space-y-1 ml-4">
<li>
<strong>Kind:</strong> A number that defines what type of data the event contains (like a category)
</li>
<li>
<strong>Content:</strong> The main data or message
</li>
<li>
<strong>Tags:</strong> Additional metadata organized as key-value pairs
</li>
<li>
<strong>Signature:</strong> Cryptographic proof that the author created this event
</li>
</ul>
<p>
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.
</p>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="nostr-event-structure" class="card-title">
Nostr Event Structure
</h3>
<p class="mb-4">
Register your Arxlet using a replaceable Nostr event with kind{" "}
<code class="badge badge-primary">30420</code>:
</p>
<CodeBlock language="json" code={registrationEvent} />
</div>
</div>
<div id="tag-reference" class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title">Tag Reference</h3>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Tag</th>
<th>Required</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>d</code>
</td>
<td>
<span class="badge badge-error">Required</span>
</td>
<td>Unique identifier (alphanumeric, hyphens, underscores)</td>
<td>
<code>my-todo-app</code>
</td>
</tr>
<tr>
<td>
<code>name</code>
</td>
<td>
<span class="badge badge-error">Required</span>
</td>
<td>Human-readable display name</td>
<td>
<code>Todo Manager</code>
</td>
</tr>
<tr>
<td>
<code>description</code>
</td>
<td>
<span class="badge badge-warning">Optional</span>
</td>
<td>Brief description of functionality</td>
<td>
<code>Manage your tasks</code>
</td>
</tr>
<tr>
<td>
<code>script</code>
</td>
<td>
<span class="badge badge-error">Required</span>
</td>
<td>Complete JavaScript code with render export</td>
<td>
<code>export function render...</code>
</td>
</tr>
<tr>
<td>
<code>icon</code>
</td>
<td>
<span class="badge badge-warning">Optional</span>
</td>
<td>Iconify icon name and hex color</td>
<td>
<code>mdi:check-circle, #10b981</code>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};

View file

@ -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 (
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title">🔢 Interactive Counter</h3>
<p class="mb-4">A simple counter demonstrating state management and event handling:</p>
<CodeBlock language="typescript" code={code} />
</div>
</div>
);
};

View file

@ -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 = \`<span>\${message}</span>\`;
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 \`
<div class="flex justify-center items-center p-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="ml-4">Loading profiles...</span>
</div>
\`;
}
function createEmptyState() {
return \`
<div class="text-center p-8">
<div class="text-6xl mb-4">📭</div>
<h3 class="text-lg font-semibold mb-2">No messages yet</h3>
<p class="text-base-content/70">Be the first to start a conversation!</p>
<button class="btn btn-primary mt-4" onclick="openComposer()">
Write a message
</button>
</div>
\`;
}
// 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 (
<div class="space-y-6">
<h2 class="text-3xl font-bold">💡 Development Best Practices</h2>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-info mb-4">Building Production-Ready Arxlets</h3>
<div class="space-y-4">
<p>
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.
</p>
<p>
<strong>Why these practices matter:</strong> 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.
</p>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="error-handling" class="card-title text-error mb-4">
🛡 Error Handling & Reliability
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>User experience first:</strong> 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.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-error pl-4">
<h4 class="font-bold text-lg mb-3">Essential Error Handling Patterns</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Wrap all API calls in try-catch blocks:</strong>
<p>
Every call to <code>window.eve</code> functions can potentially fail. Always handle exceptions.
</p>
</div>
<div>
<strong>Check for null/undefined returns:</strong>
<p>
Query methods return <code>null</code> when no data is found. Verify results before using them.
</p>
</div>
<div>
<strong>Provide meaningful user feedback:</strong>
<p>Show specific error messages that help users understand what went wrong and how to fix it.</p>
</div>
<div>
<strong>Implement retry logic for critical operations:</strong>
<p>
Publishing events or loading essential data should retry automatically with exponential backoff.
</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">Practical Error Handling Example:</h4>
<CodeBlock language="typescript" code={errorHandlingExample} />
</div>
</div>
<div class="grid md:grid-cols-2 gap-4 mt-6">
<div class="alert alert-error">
<div>
<h4 class="font-bold mb-2"> Common Mistakes</h4>
<ul class="text-sm list-disc list-inside space-y-1">
<li>Not handling API failures</li>
<li>Assuming data will always exist</li>
<li>Silent failures with no user feedback</li>
<li>Generic "Something went wrong" messages</li>
<li>No retry mechanisms for critical operations</li>
</ul>
</div>
</div>
<div class="alert alert-success">
<div>
<h4 class="font-bold mb-2"> Best Practices</h4>
<ul class="text-sm list-disc list-inside space-y-1">
<li>Specific, actionable error messages</li>
<li>Graceful degradation when features fail</li>
<li>Loading states for all async operations</li>
<li>Retry buttons for failed operations</li>
<li>Offline indicators when appropriate</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="performance" class="card-title text-success mb-4">
Performance & Efficiency
</h3>
<div class="space-y-6">
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-lg mb-3">Performance Optimization Strategies</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Use specific, narrow filters:</strong>
<p>
Instead of fetching all events and filtering in JavaScript, use precise Nostr filters to reduce data
transfer.
</p>
</div>
<div>
<strong>Implement intelligent caching:</strong>
<p>Cache profile information, avatars, and other static content to avoid repeated API calls.</p>
</div>
<div>
<strong>Paginate large datasets:</strong>
<p>
Don't load thousands of events at once. Use <code>limit</code> and <code>until</code> parameters for
pagination.
</p>
</div>
<div>
<strong>Debounce rapid user actions:</strong>
<p>
If users can trigger API calls quickly (like typing in search), debounce to avoid overwhelming the
relay.
</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">Performance Optimization Example:</h4>
<CodeBlock language="typescript" code={performanceExample} />
</div>
</div>
<div class="alert alert-warning mt-6">
<div>
<h4 class="font-bold mb-2">! Performance Pitfalls to Avoid</h4>
<div class="text-sm space-y-2">
<p>
<strong>Overly broad filters:</strong> Fetching all events and filtering client-side wastes bandwidth.
</p>
<p>
<strong>No pagination:</strong> Loading thousands of items at once can freeze the interface.
</p>
<p>
<strong>Repeated API calls:</strong> Fetching the same profile data multiple times is inefficient.
</p>
<p>
<strong>Unthrottled user input:</strong> Search-as-you-type without debouncing can overwhelm the
relay.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="subscriptions" class="card-title text-accent mb-4">
🔄 Subscription Management
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>Subscriptions power real-time features.</strong> They make your Arxlet feel alive by automatically
updating when new data arrives. However, they need careful management to prevent memory leaks.
</p>
<p>
<strong>Clean up is critical.</strong> Forgetting to unsubscribe from observables can cause memory leaks,
unnecessary i/o usage, and performance degradation over time.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-accent pl-4">
<h4 class="font-bold text-lg mb-3">Subscription Best Practices</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Always store subscription references:</strong>
<p>Keep references to all subscriptions so you can unsubscribe when needed.</p>
</div>
<div>
<strong>Implement proper cleanup:</strong>
<p>Unsubscribe when components unmount, users navigate away, or the Arxlet closes.</p>
</div>
<div>
<strong>Use specific filters:</strong>
<p>Narrow subscription filters reduce unnecessary data and improve performance.</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">Proper Subscription Management:</h4>
<CodeBlock language="typescript" code={subscriptionExample} />
</div>
</div>
<div class="alert alert-error mt-6">
<div>
<h4 class="font-bold mb-2">🚨 Memory Leak Prevention</h4>
<div class="text-sm space-y-2">
<p>
<strong>Always unsubscribe:</strong> Every <code>subscribe()</code> call must have a corresponding{" "}
<code>unsubscribe()</code>.
</p>
<p>
<strong>Clean up on navigation:</strong> Users might navigate away without properly closing your
Arxlet.
</p>
<p>
<strong>Handle page refresh:</strong> Use <code>beforeunload</code> event to clean up subscriptions.
</p>
<p>
<strong>Monitor subscription count:</strong> Too many active subscriptions can impact performance.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="user-experience" class="card-title text-info mb-4">
🎯 User Experience Excellence
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>Great UX makes the difference.</strong> Users expect responsive, intuitive interfaces that provide
clear feedback. Small details like loading states and empty state messages significantly impact user
satisfaction.
</p>
<p>
<strong>Consistency with CCN design.</strong> Using DaisyUI components ensures your Arxlet feels
integrated with the rest of the platform while saving you development time.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-info pl-4">
<h4 class="font-bold text-lg mb-3">UX Best Practices</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Show loading states for all async operations:</strong>
<p>Users should never see blank screens or wonder if something is happening.</p>
</div>
<div>
<strong>Handle empty states gracefully:</strong>
<p>When no data is available, provide helpful messages or suggestions for next steps.</p>
</div>
<div>
<strong>Implement optimistic updates:</strong>
<p>Update the UI immediately when users take actions, then sync with the server.</p>
</div>
<div>
<strong>Use consistent DaisyUI components:</strong>
<p>Leverage the pre-built component library for consistent styling and behavior.</p>
</div>
</div>
</div>
<div class="mt-6">
<h4 class="font-semibold mb-3 text-lg">UI/UX Implementation Examples:</h4>
<CodeBlock language="typescript" code={uiExample} />
</div>
</div>
<div class="grid md:grid-cols-2 gap-4 mt-6">
<div class="border-2 border-success rounded-lg p-4">
<h4 class="font-bold text-success mb-3"> Excellent UX Includes</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Loading spinners for async operations</li>
<li>Helpful empty state messages</li>
<li>Immediate feedback for user actions</li>
<li>Clear error messages with solutions</li>
<li>Consistent visual design</li>
<li>Accessible keyboard navigation</li>
<li>Responsive layout for different screen sizes</li>
</ul>
</div>
<div class="border-2 border-warning rounded-lg p-4">
<h4 class="font-bold text-warning mb-3">! UX Anti-patterns</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Blank screens during loading</li>
<li>No feedback for user actions</li>
<li>Generic or confusing error messages</li>
<li>Inconsistent styling with CCN</li>
<li>Broken layouts on mobile devices</li>
<li>Inaccessible interface elements</li>
<li>Slow or unresponsive interactions</li>
</ul>
</div>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 id="security" class="card-title text-warning mb-4">
🔒 Security & Privacy Considerations
</h3>
<div class="space-y-4 mb-6">
<p>
<strong>Security is everyone's responsibility.</strong> Even though Arxlets run in a sandboxed
environment, you still need to validate inputs, handle user data responsibly, and follow security best
practices.
</p>
<p>
<strong>Privacy by design.</strong> 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.
</p>
</div>
<div class="space-y-6">
<div class="border-l-4 border-warning pl-4">
<h4 class="font-bold text-lg mb-3">Security Best Practices</h4>
<div class="space-y-3 text-sm">
<div>
<strong>Validate all user inputs:</strong>
<p>Never trust user input. Validate, sanitize, and escape data before using it in events or UI.</p>
</div>
<div>
<strong>Be mindful of public data:</strong>
<p>Nostr events are public by default. Don't accidentally expose private information.</p>
</div>
<div>
<strong>Handle signing errors gracefully:</strong>
<p>Users might reject signing requests. Always have fallbacks and clear error messages.</p>
</div>
<div>
<strong>Respect user privacy preferences:</strong>
<p>Some users prefer pseudonymous usage. Don't force real names or personal information.</p>
</div>
<div>
<strong>Sanitize HTML content:</strong>
<p>If displaying user-generated content, sanitize it to prevent XSS attacks.</p>
</div>
</div>
</div>
</div>
<div class="alert alert-error mt-6">
<div>
<h4 class="font-bold mb-2">🚨 Security Checklist</h4>
<div class="grid md:grid-cols-2 gap-4 text-sm">
<div>
<strong>Input Validation:</strong>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Validate all form inputs</li>
<li>Sanitize user-generated content</li>
<li>Check data types and ranges</li>
<li>Escape HTML when displaying content</li>
</ul>
</div>
<div>
<strong>Privacy Protection:</strong>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Don't store sensitive data in events</li>
<li>Respect user anonymity preferences</li>
<li>Handle signing rejections gracefully</li>
<li>Be transparent about data usage</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 id="production-checklist" class="card-title text-success mb-4">
🚀 Production Readiness Checklist
</h3>
<div class="space-y-4 mb-6">
<p>
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.
</p>
</div>
<div class="grid md:grid-cols-2 gap-6">
<div class="space-y-4">
<div class="border-l-4 border-success pl-4">
<h4 class="font-bold text-success mb-3"> Code Quality</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>All API calls wrapped in try-catch blocks</li>
<li>Null/undefined checks before using data</li>
<li>Subscriptions properly cleaned up</li>
<li>Input validation implemented</li>
<li>Error handling with user feedback</li>
<li>Performance optimizations applied</li>
<li>Code is well-commented and organized</li>
</ul>
</div>
<div class="border-l-4 border-info pl-4">
<h4 class="font-bold text-info mb-3">🎯 User Experience</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Loading states for all async operations</li>
<li>Error messages are user-friendly</li>
<li>Empty states handled gracefully</li>
<li>Consistent DaisyUI styling</li>
<li>Responsive design for mobile</li>
<li>Keyboard navigation works</li>
<li>Accessibility features implemented</li>
</ul>
</div>
</div>
<div class="space-y-4">
<div class="border-l-4 border-warning pl-4">
<h4 class="font-bold text-warning mb-3">🔒 Security & Privacy</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>User inputs are validated and sanitized</li>
<li>No sensitive data in public events</li>
<li>Signing errors handled gracefully</li>
<li>Privacy preferences respected</li>
<li>HTML content properly escaped</li>
<li>No hardcoded secrets or keys</li>
<li>Data usage is transparent</li>
</ul>
</div>
<div class="border-l-4 border-accent pl-4">
<h4 class="font-bold text-accent mb-3"> Performance</h4>
<ul class="text-sm space-y-1 list-disc list-inside">
<li>Efficient Nostr filters used</li>
<li>Data caching implemented</li>
<li>Pagination for large datasets</li>
<li>User actions are debounced</li>
<li>Memory leaks prevented</li>
<li>Bundle size optimized</li>
<li>Performance tested with large datasets</li>
</ul>
</div>
</div>
</div>
<div class="alert alert-success mt-6">
<div>
<h4 class="font-bold mb-2">🎉 Ready for Production!</h4>
<p class="text-sm">
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.
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -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 (
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title">📝 Nostr Note Publisher</h3>
<p class="mb-4">Publish notes to your CCN using the window.eve API:</p>
<CodeBlock language="typescript" code={code} />
</div>
</div>
);
};

View file

@ -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 (
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title"> Preact Counter with JSX</h3>
<p class="mb-4">A modern counter using Preact hooks and JSX syntax:</p>
<CodeBlock language="typescript" code={code} />
</div>
</div>
);
};

View file

@ -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 (
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h3 class="card-title">🔥 Svelte Counter</h3>
<p class="mb-4">A reactive counter built with Svelte's elegant syntax and built-in reactivity:</p>
<div class="bg-green-50 border-l-4 border-green-400 p-4 mb-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-green-700">
<strong>Why Svelte?</strong> Svelte compiles to vanilla JavaScript with no runtime overhead, making it
perfect for arxlets. Features like runes (<kbd class="kbd">$state()</kbd>,{" "}
<kbd class="kbd">$derived()</kbd>, etc), scoped CSS and intuitive event handling make development a
breeze.
</p>
</div>
</div>
</div>
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-amber-700">
<strong>Build Setup Note:</strong> 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{" "}
<a
href="https://git.arx-ccn.com/Arx/arxlets-template"
class="link link-primary"
target="_blank"
rel="noopener noreferrer"
>
arxlets-template
</a>{" "}
with all the correct configurations. We still highly recommend Svelte because once set up, the
development experience is incredibly smooth and optimal.
</p>
</div>
</div>
</div>
<CodeBlock language="svelte" code={code} />
</div>
</div>
);
};

View file

@ -0,0 +1 @@
bun build --minify --outfile=build.js --target=browser --production index.ts

View file

@ -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<void>
// Get a specific event by ID
const event = await window.eve.getSingleEventById(id: string): Promise<NostrEvent | null>
// Get first event matching filter
const event = await window.eve.getSingleEventWithFilter(filter: Filter): Promise<NostrEvent | null>
// Get all events matching filter
const events = await window.eve.getAllEventsWithFilter(filter: Filter): Promise<NostrEvent[]>
```
#### Real-time Subscriptions
```typescript
// Subscribe to events with RxJS Observable
const subscription = window.eve.subscribeToEvents(filter: Filter): Observable<NostrEvent>
// Subscribe to profile updates
const profileSub = window.eve.subscribeToProfile(pubkey: string): Observable<Profile>
// Always unsubscribe when done
subscription.unsubscribe()
```
#### Profile Operations
```typescript
// Get user profile
const profile = await window.eve.getProfile(pubkey: string): Promise<Profile | null>
// Get user avatar URL
const avatarUrl = await window.eve.getAvatar(pubkey: string): Promise<string | null>
```
#### Cryptographic Operations
```typescript
// Sign an unsigned event
const signedEvent = await window.eve.signEvent(event: NostrEvent): Promise<NostrEvent>
// Get current user's public key
const pubkey = await window.eve.publicKey: Promise<string>
```
### 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<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>;
}
// Global declarations for TypeScript
declare global {
interface Window {
eve: WindowEve;
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: NostrEvent): Promise<NostrEvent>;
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 = `
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">My Arxlet</h2>
<!-- Your content here -->
</div>
</div>
`;
// 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 = `<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
```typescript
async function publishNote(content: string): Promise<void> {
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 = `
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">My App</h2>
<button id="myButton" class="btn btn-primary">Click me</button>
</div>
</div>
`;
// 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 (
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">Counter: {count}</h2>
<button
class="btn btn-primary"
onClick={() => setCount(count + 1)}
>
Increment
</button>
</div>
</div>
);
};
export function render(container: HTMLElement): void {
render(<App />, 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
<!-- 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
```html
<!-- 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
```html
<!-- 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
```typescript
export function render(container: HTMLElement): void {
let count = 0;
function updateUI() {
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</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" id="decrement"></button>
<button class="btn btn-success" id="increment">+</button>
<button class="btn btn-ghost" id="reset">Reset</button>
</div>
</div>
</div>
`;
// 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<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..."
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 = () => {
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 =
'<span class="loading loading-spinner loading-sm"></span>';
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 = `
<div class="alert alert-success">
<span>✅ Note published successfully!</span>
</div>
`;
textarea.value = "";
textarea.oninput?.();
} 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";
}
};
}
```
This context provides comprehensive information about the Arxlets API, enabling LLMs to understand and work with the system effectively.

View file

@ -0,0 +1,44 @@
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();
};
}

View file

@ -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);

View file

@ -0,0 +1,85 @@
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";
}
};
}

View file

@ -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 (
<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);
}

View file

@ -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
}

View file

@ -0,0 +1,23 @@
/**
* 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...
}

View file

@ -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();

View file

@ -0,0 +1,49 @@
<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>

View file

@ -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<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;
}
}

View file

@ -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]));

View file

@ -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);
});
};
}, []);
};

5
src/pages/home/home.css Normal file
View file

@ -0,0 +1,5 @@
@import "tailwindcss";
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin "daisyui";

285
src/pages/home/home.html Normal file
View file

@ -0,0 +1,285 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Eve - Secure, Decentralized Communities</title>
<link rel="stylesheet" href="home.css" />
<style>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
</style>
</head>
<body class="bg-base-200">
<div data-theme="cyberpunk">
<!-- Navbar -->
<div class="navbar bg-base-100 shadow-lg">
<div class="flex-1">
<a class="btn btn-ghost normal-case text-xl">
<img src="/assets/logo.png" alt="Eve Logo" class="w-8 h-8 mr-2" />
Eve
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1">
<li><a href="#features">Features</a></li>
<li><a href="#how-it-works">How It Works</a></li>
<li><a href="/docs/arxlets">Arxlet Docs</a></li>
</ul>
</div>
</div>
<!-- Hero Section -->
<div class="hero min-h-screen">
<div class="hero-overlay bg-opacity-60"></div>
<div class="hero-content text-center text-neutral-content">
<div class="max-w-md">
<h1 class="mb-5 text-5xl font-bold">Welcome to Eve</h1>
<p class="mb-5">
Your personal gateway to secure, decentralized communities. Create
encrypted <strong>Closed Community Networks (CCNs)</strong> where
your messages and data stay truly private.
</p>
<a href="/docs/arxlets" class="btn btn-primary">Get Started</a>
</div>
</div>
</div>
<!-- Features Section -->
<div id="features" class="container mx-auto px-4 py-16">
<h2 class="text-4xl font-bold text-center mb-12">Why Choose Eve?</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Feature 1: End-to-End Encryption -->
<div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
>
<div class="card-body items-center text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<h3 class="card-title mt-4">End-to-End Encryption</h3>
<p>
Every message, every file, every interaction is secured with
cutting-edge encryption. Only you and your community hold the
keys.
</p>
</div>
</div>
<div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
style="animation-delay: 0.1s"
>
<div class="card-body items-center text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12s-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.368a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"
/>
</svg>
<h3 class="card-title mt-4">Decentralized by Design</h3>
<p>
No central servers, no single point of failure. Your community's
data is distributed, resilient, and censorship-resistant.
</p>
</div>
</div>
<!-- Feature 3: Extensible with Arxlets -->
<div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300 fade-in"
style="animation-delay: 0.2s"
>
<div class="card-body items-center text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4-8-4V7m8 4l8-4"
/>
</svg>
<h3 class="card-title mt-4">Extensible with Arxlets</h3>
<p>
Supercharge your community with powerful mini-apps. From shared
calendars to collaborative tools, the possibilities are
limitless.
</p>
</div>
</div>
</div>
</div>
<div id="how-it-works" class="bg-base-100">
<div class="container mx-auto px-4 py-16">
<h2 class="text-4xl font-bold text-center mb-12">How Eve Works</h2>
<ul
class="timeline timeline-snap-icon max-md:timeline-compact timeline-vertical"
>
<li>
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="timeline-start md:text-end mb-10">
<time class="font-mono italic">Step 1</time>
<div class="text-lg font-black">Create a CCN</div>
Generate a unique, encrypted Closed Community Network. This is
your private digital space, secured by a key that only you and
your members possess.
</div>
<hr />
</li>
<li>
<hr />
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="timeline-end mb-10">
<time class="font-mono italic">Step 2</time>
<div class="text-lg font-black">Invite Members</div>
Securely share the CCN key with trusted members. Only those with
the key can join, ensuring your community remains private and
exclusive.
</div>
<hr />
</li>
<li>
<hr />
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="timeline-start md:text-end mb-10">
<time class="font-mono italic">Step 3</time>
<div class="text-lg font-black">Communicate & Collaborate</div>
Share messages, files, and use Arxlets within your secure
environment. Your data is always protected and under your
control.
</div>
<hr />
</li>
<li>
<hr />
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="timeline-end">
<time class="font-mono italic">Step 4</time>
<div class="text-lg font-black">Extend with Arxlets</div>
Browse and install Arxlets to add new features to your CCN.
Customize your community's experience with powerful,
decentralized applications.
</div>
</li>
</ul>
</div>
</div>
<div class="container mx-auto px-4 py-16">
<div class="hero bg-base-200 rounded-box">
<div class="hero-content flex-col lg:flex-row">
<div>
<h2 class="text-3xl font-bold">Unleash the Power of Arxlets</h2>
<p class="py-6">
<strong>Arxlets</strong> 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.
</p>
<a href="/docs/arxlets" class="btn btn-primary"
>Explore Arxlet Development</a
>
</div>
</div>
</div>
</div>
<footer class="footer footer-center p-10 bg-base-200 text-base-content">
<aside>
<img src="/assets/logo.png" alt="Eve Logo" class="w-16 h-16" />
<p class="font-bold">Eve Lite<br />Secure, Decentralized, Yours.</p>
<p>Copyright © 2025 - All right reserved</p>
</aside>
</footer>
</div>
</body>
</html>

100
src/rollingIndex.ts Normal file
View file

@ -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);
}
}

83
src/utils/Uint8Array.ts Normal file
View file

@ -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];
}

46
src/utils/color.ts Normal file
View file

@ -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)}`;
}

193
src/utils/encryption.ts Normal file
View file

@ -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<NostrEvent> {
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);
}
}

29
src/utils/files.ts Normal file
View file

@ -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);
}

98
src/utils/general.ts Normal file
View file

@ -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<NostrEvent[]> =>
pool.querySync(relays, filter);
export const queryRemoteEvent = (id: string): Promise<NostrEvent | null> =>
pool.get(relays, {
ids: [id],
limit: 1,
});
export async function sendEncryptedEventToRelays(
event: NostrEvent,
): Promise<string> {
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<string> {
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(/<g(\s[^>]*)?>/);
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("<g", i);
const close = svg.indexOf("</g>", 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 `<g${gOpen[1]?.replace(/(mask|transform|stroke)="[^"]*"/g, "").trim()} transform="${transform}" stroke="black" stroke-width="1.5">${svg.slice(gOpen.index! + gOpen[0].length, close)}</g>`;
}
}
throw new Error("Malformed SVG");
}

119
src/validation/index.ts Normal file
View file

@ -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 };
}

29
tsconfig.json Normal file
View file

@ -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
}
}