Compare commits

...

242 Commits

Author SHA1 Message Date
24eef5b5a8 fix CORS headers and a wasm experiment
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-14 19:15:50 +00:00
9fb976703d hello world in wat 2025-11-14 14:37:36 +00:00
1d9a6903b8 bump version
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-14 12:18:01 +00:00
29e175efb0 implement event table subtyping for small events in value log
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-14 12:15:52 +00:00
7169a2158f when in "none" ACL mode, privileged checks are not enforced
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-13 08:31:02 +00:00
baede6d37f extend script test to two read two write to ensure script continues running
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-11 15:24:58 +00:00
3e7cc01d27 make script stderr print into relay logs
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-11 14:41:54 +00:00
cc99fcfab5 bump to v0.27.5
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-11 14:38:05 +00:00
b2056b6636 bump to v0.27.5
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-11 13:48:23 +00:00
108cbdce93 fix docker image cleanups in test 2025-11-11 13:47:57 +00:00
e9fb314496 fully test and verify policy script functionality
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-11 09:37:42 +00:00
597711350a fix script startup and validate with tests
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-10 12:36:55 +00:00
7113848de8 fix error handling of default policy script
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-10 11:55:42 +00:00
54606c6318 curl|bash deploy script 2025-11-10 11:42:59 +00:00
09bcbac20d create concurrent script runner per rule script
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
bump to v0.27.1
2025-11-10 10:56:02 +00:00
84b7c0e11c bump to v0.27.0
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-09 10:42:50 +00:00
d0dbd2e2dc implemented and tested NIP-43 invite based ACL 2025-11-09 10:41:58 +00:00
f0beb83ceb fix utf8 handling bug, bump to v0.26.4
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-08 10:29:24 +00:00
5d04193bb7 implement messages and operations for FIND 2025-11-08 09:02:32 +00:00
b4760c49b6 implement messages and operations for FIND 2025-11-08 08:54:58 +00:00
587116afa8 add noise protocol security and site certificate third party signing 2025-11-08 00:13:23 +00:00
960bfe7dda add noise protocol security and site certificate third party signing 2025-11-08 00:01:06 +00:00
f5cfcff6c9 draft name registry proposal 2025-11-07 22:52:22 +00:00
2e690f5b83 draft name registry proposal 2025-11-07 22:37:52 +00:00
c79cd2ffee Remove deprecated files and enhance subscription stability
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Deleted obsolete files including ALL_FIXES.md, MESSAGE_QUEUE_FIX.md, PUBLISHER_FIX.md, and others to streamline the codebase.
- Implemented critical fixes for subscription stability, ensuring receiver channels are consumed and preventing drops.
- Introduced per-subscription consumer goroutines to enhance event delivery and prevent message queue overflow.
- Updated documentation to reflect changes and provide clear testing guidelines for subscription stability.
- Bumped version to v0.26.3 to signify these important updates.
2025-11-06 20:10:08 +00:00
581e0ec588 Implement comprehensive WebSocket subscription stability fixes
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Resolved critical issues causing subscriptions to drop after 30-60 seconds due to unconsumed receiver channels.
- Introduced per-subscription consumer goroutines to ensure continuous event delivery and prevent channel overflow.
- Enhanced REQ parsing to handle both wrapped and unwrapped filter arrays, eliminating EOF errors.
- Updated publisher logic to correctly send events to receiver channels, ensuring proper event delivery to subscribers.
- Added extensive documentation and testing tools to verify subscription stability and performance.
- Bumped version to v0.26.2 to reflect these significant improvements.
2025-11-06 18:21:00 +00:00
d604341a27 Add comprehensive documentation for CLAUDE and Nostr WebSocket skills
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced CLAUDE.md to provide guidance for working with the Claude Code repository, including project overview, build commands, testing guidelines, and performance considerations.
- Added INDEX.md for a structured overview of the strfry WebSocket implementation analysis, detailing document contents and usage.
- Created SKILL.md for the nostr-websocket skill, covering WebSocket protocol fundamentals, connection management, and performance optimization techniques.
- Included multiple reference documents for Go, C++, and Rust implementations of WebSocket patterns, enhancing the knowledge base for developers.
- Updated deployment and build documentation to reflect new multi-platform capabilities and pure Go build processes.
- Bumped version to reflect the addition of extensive documentation and resources for developers working with Nostr relays and WebSocket connections.
2025-11-06 16:18:09 +00:00
27f92336ae Add NDK skill documentation and examples
- Introduced comprehensive documentation for the Nostr Development Kit (NDK) including an overview, quick reference, and troubleshooting guide.
- Added detailed examples covering initialization, authentication, event publishing, querying, and user profile management.
- Structured the documentation to facilitate quick lookups and deep learning, based on real-world usage patterns from the Plebeian Market application.
- Created an index for examples to enhance usability and navigation.
- Bumped version to 1.0.0 to reflect the addition of this new skill set.
2025-11-06 14:34:06 +00:00
29ab350eed Implement advanced filtering capabilities in the search interface
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Added a FilterBuilder component to allow users to create complex search filters based on various criteria such as event kinds, authors, and tags.
- Introduced a FilterDisplay component to show active filters and provide an option to clear them.
- Updated the App.svelte to integrate the new filtering features, including handling filter application and clearing.
- Enhanced search functionality to utilize the new filter structure, improving the search results experience.
- Bumped version to v0.26.0 to reflect these changes.
2025-11-06 09:20:18 +00:00
88d3e3f73e Refactor authentication challenge logic in HandleCount and HandleReq
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated the authentication challenge conditions in both HandleCount and HandleReq functions to check for the presence of an authenticated public key.
- Introduced the schnorr package to handle public key length validation.
- Bumped version to v0.25.7 to reflect these changes.
2025-11-05 19:23:59 +00:00
eaac3cdc19 logs libsecp256k1 loading version 2025-11-05 15:28:23 +00:00
36fc05b1c2 Update Go workflow to include libsecp256k1 for tests and bump version to v0.25.5
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Modified the CI workflow to copy the `libsecp256k1.so` file to the root directory for test accessibility.
- Enhanced the `InitPub` method in the `FallbackSigner` to parse the x-only public key for verification.
- Bumped version to v0.25.6 to reflect these updates.
2025-11-05 15:22:39 +00:00
c753049cfd Implement pure Go fallback for P8K signer
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Enhanced the Signer struct to include a FallbackSigner for scenarios where the libsecp256k1 implementation is unavailable.
- Updated the New function to initialize the fallback signer when the context creation fails.
- Refactored methods in the Signer to delegate operations to the FallbackSigner when applicable, ensuring compatibility with pure Go implementations.
- Added comprehensive method implementations for the FallbackSigner, including key generation, signing, and verification.
- Improved error handling and memory management in the new implementation.
2025-11-05 13:57:53 +00:00
ae170fc069 Update Go workflow and bump version to v0.25.4
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Removed installation steps for `libsecp256k1` from the CI workflow, streamlining the build process.
- Adjusted the build steps for release binaries to utilize pre-compiled `libsecp256k1.so` for Linux AMD64.
- Bumped version to v0.25.4 to reflect these changes.
2025-11-05 13:41:20 +00:00
7af08f9fd2 Remove deprecated test files and optimize encryption functions
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Deleted `testresults.txt` and `testmain_test.go` as they were no longer needed.
- Updated the Go workflow to streamline the build process by removing commented-out build steps for various platforms.
- Refactored encryption benchmarks to improve performance and clarity in the `benchmark_test.go` file.
- Introduced a new LICENSE file for the encryption package, specifying the MIT License.
- Enhanced the README with usage instructions and links to the NIP-44 specification.
- Bumped version to v0.25.3 to reflect these changes.
2025-11-05 13:28:17 +00:00
256537ba86 Update dependencies and refactor conversation key generation
- Added `github.com/ebitengine/purego` as a direct dependency to the project.
- Removed the unused `p8k.mleku.dev` dependency from the `go.mod` file.
- Refactored the `GenerateConversationKeyFromHex` function to clarify parameter order, aligning with the NIP-44 specification.
- Enhanced test cases for conversation key generation to ensure proper handling of public key formats and improved error messages.
- Updated the `Signer` interface to include methods for extracting and serializing public keys in compressed format.
2025-11-05 09:33:01 +00:00
f35440ed1d Refactor delete handling and improve logging in event processing
- Updated the HandleDelete function to allow delete events to be stored even if no valid targets are found, enhancing flexibility in event management.
- Improved logging to provide clearer insights when no deletions are processed, ensuring that issues are logged without blocking event acceptance.
- Refactored SHA256 validation tests and adjusted expected values to ensure accuracy in hash comparisons.
- Enhanced various test cases to improve coverage and reliability across the application.
2025-11-05 08:48:21 +00:00
9d13811f6b Add test files and enhance logging in various components
- Introduced test files for the blossom and database packages to improve test coverage and ensure functionality.
- Updated logging practices by suppressing unnecessary log outputs during tests to enhance clarity and focus on relevant information.
- Refactored error handling in the `handle-message` and `handle-req` functions to avoid logging expected context cancellation errors during shutdown.
- Bumped version to v0.25.2 to reflect these updates.
2025-11-05 08:15:02 +00:00
1d12099f1c Enhance WebSocket connection management and signer implementation
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Added a pong handler to extend the read deadline upon receiving PONG messages in WebSocket connections, improving connection stability.
- Updated the signer implementation to serialize the x-only public key to 32 bytes, ensuring compatibility with the internal format.
- Refactored tests to utilize MustNew() for signer initialization, enhancing error handling during key generation.
- Bumped version to v0.25.1 to reflect these updates.
2025-11-05 06:19:28 +00:00
4944bfad91 Add closing section to RELAY_TESTING_GUIDE.md for improved clarity
- Included an additional line to summarize the guide's purpose and its support for testing complex Nostr protocol features.
- Enhanced documentation to provide clearer guidance for users.
2025-11-05 05:54:47 +00:00
202d3171f9 Implement multi-platform build system with pure Go support
- Introduced a comprehensive build system that supports multiple platforms (Linux, macOS, Windows, Android) using pure Go builds (`CGO_ENABLED=0`).
- Updated all build and test scripts to ensure compatibility with the new purego approach, allowing for dynamic loading of `libsecp256k1` at runtime.
- Added detailed documentation on the build process, platform detection, and deployment options.
- Enhanced CI/CD workflows to automate builds for all supported platforms and include necessary libraries in releases.
- Updated `.gitignore` to exclude build output files.
- Created new documentation files for deployment and multi-platform build summaries.
2025-11-04 20:29:19 +00:00
e0a95ca1cd Refactor signer implementation to use p8k package
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Replaced all instances of p256k1signer with the new p8k.Signer across various modules, including event creation, policy handling, and database interactions.
- Updated related test cases and benchmarks to ensure compatibility with the new signer interface.
- Bumped version to v0.25.0 to reflect these significant changes and improvements in cryptographic operations.
2025-11-04 20:05:19 +00:00
effb3fafc1 Add Nostr Protocol Skill and Reference Materials
- Introduced a comprehensive skill for the Nostr protocol, covering client and relay implementation, event structure, cryptographic operations, and best practices.
- Added detailed reference files including an overview of NIPs, event kinds, common mistakes, and a complete guide to the Nostr protocol.
- Included quick start examples and a checklist for implementing Nostr features effectively.
- Ensured documentation is aligned with the latest standards and practices for Nostr development.
2025-11-04 12:54:41 +00:00
f1c636db41 Bump version to v0.24.7
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-04 12:43:18 +00:00
fa71e9e334 Add Golang skill and reference materials
- Introduced a new skill for Golang, providing comprehensive guidance on writing, debugging, and best practices for Go programming.
- Added reference materials including effective Go guidelines, common patterns, and a quick reference cheat sheet to support users in Go development.
- Created a skill creator guide to assist in developing new skills with structured templates and resource management.
- Implemented scripts for skill initialization and packaging to streamline the skill creation process.
2025-11-04 12:36:36 +00:00
cefd0a98e7 Remove unused pong handler in WebSocket connection management and bump version to v0.24.6
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-04 10:44:45 +00:00
215c389ac2 bump to v0.24.5
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-04 10:33:33 +00:00
e50d860c0b Update dependencies and documentation
- Bumped p256k1.mleku.dev version from v1.0.1 to v1.0.3 in go.mod and updated go.sum accordingly.
- Updated deployment script to use Go version 1.25.3 and adjusted CGO settings for building the binary.
- Added new lines to POLICY_USAGE_GUIDE.md and RELAY_TESTING_GUIDE.md for improved documentation clarity.
2025-11-04 10:32:49 +00:00
ce573a50b3 Update documentation for policy and relay testing guides
- Added a closing section to the POLICY_USAGE_GUIDE.md to emphasize the benefits of the policy system for implementing complex relay behavior.
- Included a closing section in the RELAY_TESTING_GUIDE.md to summarize the guide's purpose and its support for testing complex Nostr protocol features.
2025-11-04 06:56:46 +00:00
4b6d0ab30c Remove commented-out test code for dumb WebSocket client workaround and bump version to v0.24.4
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-03 20:05:07 +00:00
4b0dcfdf94 Add cluster replication configuration and enhance event handling
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced support for cluster replication in the ORLY system, allowing for distributed relay clusters with active replication.
- Updated the configuration to include a new option for propagating privileged events to relay peers.
- Enhanced the `ClusterManager` to manage event propagation based on the new configuration setting.
- Improved the handling of event fetching to respect the propagation settings, ensuring better privacy for privileged events.
- Updated documentation to reflect the new cluster replication features and privacy considerations.
- Bumped version to v0.24.3 to reflect these changes.
2025-11-03 19:55:14 +00:00
32dffdbb7e Bump version to v0.24.2
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-11-03 19:02:57 +00:00
b1f1334e39 Add cluster replication features and membership management
- Introduced a new `ClusterManager` to handle cluster membership events and facilitate event replication across relay peers.
- Implemented HTTP endpoints for retrieving the latest serial and fetching events within a specified range.
- Enhanced event handling to process cluster membership events (Kind 39108) and update relay lists accordingly.
- Updated configuration to support cluster administrators and their management capabilities.
- Added comprehensive tests to validate the new cluster replication functionalities.
- Documented the cluster replication protocol in a new specification file.
- Bumped version to reflect these changes.
2025-11-03 19:02:20 +00:00
e56bf76257 Add NIP-11 relay synchronization and group management features
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced a new `sync` package for managing NIP-11 relay information and relay group configurations.
- Implemented a cache for NIP-11 documents, allowing retrieval of relay public keys and authoritative configurations.
- Enhanced the sync manager to update peer lists based on authoritative configurations from relay group events.
- Updated event handling to incorporate policy checks during event imports, ensuring compliance with relay rules.
- Refactored various components to utilize the new `sha256-simd` package for improved performance.
- Added comprehensive tests to validate the new synchronization and group management functionalities.
- Bumped version to v0.24.1 to reflect these changes.
2025-11-03 18:17:15 +00:00
e161d0e4be Implement distributed synchronization features
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Added a sync manager to handle distributed synchronization across relay peers, initialized in the main application run function.
- Enhanced the event handling to update the serial number for synchronization when events are processed.
- Introduced new API endpoints for synchronization, allowing peers to fetch the current serial number and events within a specified range.
- Implemented peer request validation for synchronization endpoints to ensure authorized access based on NIP-98 authentication.
- Updated configuration to support relay peers for synchronization.
- Bumped version to v0.24.0 to reflect these changes.
2025-11-03 15:54:51 +00:00
ed412dcb7e Add WebSocket workaround test and enhance connection handling
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced a new test file `workaround_test.go` to validate the behavior of a "dumb" WebSocket client that does not handle ping/pong messages correctly, ensuring the connection remains alive through server-side workarounds.
- Updated the `handle-websocket.go` file to improve message size handling and refactor ping/pong logic, allowing for direct message sending and better error management.
- Enhanced the `listener.go` file to support a more robust write channel mechanism, allowing pings to interrupt writes and improving overall connection management.
- Bumped version to v0.23.4 to reflect these changes.
2025-11-03 13:49:14 +00:00
2614b51068 Refactor crypto package to use p256k1 signer
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Replaced the p256k package with p256k1.mleku.dev/signer across the codebase, updating all instances where the previous signer was utilized.
- Removed the deprecated p256k package, including all related files and tests, to streamline the codebase and improve maintainability.
- Updated various components, including event handling, database interactions, and protocol implementations, to ensure compatibility with the new signer interface.
- Enhanced tests to validate the new signing functionality and ensure robustness across the application.
- Bumped version to v0.23.3 to reflect these changes.
2025-11-03 10:21:31 +00:00
edcdec9c7e Add Blossom blob storage server and subscription management
- Introduced the `initializeBlossomServer` function to set up the Blossom blob storage server with dynamic base URL handling and ACL configuration.
- Implemented the `blossomHandler` method to manage incoming requests to the Blossom API, ensuring proper URL handling and context management.
- Enhanced the `PaymentProcessor` to support Blossom service levels, allowing for subscription extensions based on payment metadata.
- Added methods for parsing and validating Blossom service levels, including storage quota management and subscription extension logic.
- Updated the configuration to include Blossom service level settings, facilitating dynamic service level management.
- Integrated storage quota checks in the blob upload process to prevent exceeding allocated limits.
- Refactored existing code to improve organization and maintainability, including the removal of unused blob directory configurations.
- Added tests to ensure the robustness of new functionalities and maintain existing behavior across blob operations.
2025-11-02 22:23:01 +00:00
3567bb26a4 Enhance blob storage functionality with file extension support
- Added an `Extension` field to `BlobMetadata` to store file extensions alongside existing metadata.
- Updated the `SaveBlob` method to handle file extensions, ensuring they are stored and retrieved correctly.
- Modified the `GetBlob` method to read blob data from the filesystem based on the stored extension.
- Enhanced the `Storage` struct to manage blob files in a specified directory, improving organization and access.
- Introduced utility functions for determining file extensions from MIME types, facilitating better file handling.
- Added comprehensive tests for new functionalities, ensuring robust behavior across blob operations.
2025-11-02 21:55:50 +00:00
9082481129 Add Blossom package with core functionalities for blob storage and authorization
- Introduced the Blossom package, implementing essential features for handling blob storage, including upload, retrieval, and deletion of blobs.
- Added authorization mechanisms for secure access to blob operations, validating authorization events based on Nostr standards.
- Implemented various HTTP handlers for managing blob interactions, including GET, HEAD, PUT, and DELETE requests.
- Developed utility functions for SHA256 hash calculations, MIME type detection, and range request handling.
- Established a storage layer using Badger database for efficient blob data management and metadata storage.
- Included placeholder implementations for media optimization and payment handling, setting the groundwork for future enhancements.
- Documented the new functionalities and usage patterns in the codebase for better maintainability and understanding.
2025-11-02 21:09:18 +00:00
8d131b6137 Add benchmark tests and optimize database performance
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced benchmark tests for various database operations, including event saving, querying, and fetching by serials, to assess performance.
- Implemented optimizations to reduce memory allocations and improve efficiency by pre-allocating slices and maps in critical functions.
- Enhanced the `FetchEventsBySerials`, `GetFullIdPubkeyBySerials`, and `QueryForIds` methods with pre-allocation strategies to minimize reallocations.
- Documented performance improvements in the new PERFORMANCE_REPORT.md file, highlighting significant reductions in execution time and memory usage.
- Bumped version to v0.23.1 to reflect these changes.
2025-11-02 18:19:52 +00:00
d7ea462642 Add benchmark tests and optimize tag encoding performance
- Introduced benchmark tests for tag marshaling, unmarshaling, and conversion operations, assessing performance across various scenarios.
- Implemented optimizations to reduce memory allocations and CPU processing time in tag encoding functions, focusing on pre-allocating buffers and minimizing reallocations.
- Enhanced the `Marshal`, `Unmarshal`, and conversion methods with pre-allocation strategies to improve efficiency.
- Documented performance improvements in the new PERFORMANCE_REPORT.md file, highlighting significant reductions in execution time and memory usage.
2025-11-02 18:15:31 +00:00
53fb12443e Add benchmark tests and optimize encryption performance
- Introduced comprehensive benchmark tests for NIP-44 and NIP-4 encryption/decryption, including various message sizes and round-trip operations.
- Implemented optimizations to reduce memory allocations and CPU processing time in encryption functions, focusing on pre-allocating buffers and minimizing reallocations.
- Enhanced error handling in encryption and decryption processes to ensure robustness.
- Documented performance improvements in the new PERFORMANCE_REPORT.md file, highlighting significant reductions in execution time and memory usage.
2025-11-02 18:08:11 +00:00
b47a40bc59 Implement EstimateSize method for filter marshaling and optimize Marshal function
- Added EstimateSize method to calculate the estimated size for marshaling the filter to JSON, accounting for various fields including IDs, Kinds, Authors, Tags, and timestamps.
- Enhanced the Marshal function to pre-allocate the buffer based on the estimated size, reducing memory reallocations during JSON encoding.
- Improved handling of nil tags and optimized key slice reuse in the Unmarshal function to minimize allocations.
2025-11-02 17:52:16 +00:00
509eb8f901 Add benchmark tests for event encoders and optimize performance
- Introduced benchmark tests for JSON and binary marshaling/unmarshaling, canonical encoding, and ID generation to assess performance.
- Implemented optimizations to reduce memory allocations and CPU processing time across various encoding methods.
- Enhanced `Marshal`, `ToCanonical`, and `MarshalBinary` methods with pre-allocation strategies to minimize reallocations.
- Added handling for nil tags to avoid unnecessary allocations during binary encoding.
- Documented performance improvements in the new PERFORMANCE_REPORT.md file, highlighting significant reductions in execution time and memory usage.
2025-11-02 17:47:40 +00:00
354a2f1cda Enhance WebSocket write handling and connection management
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced a buffered write channel and a dedicated write worker goroutine to serialize write operations, preventing concurrent write panics.
- Updated the Write and WriteControl methods to send messages through the write channel, improving error handling and connection stability.
- Refactored ping and pong handlers to utilize the new write channel for sending control messages.
- Enhanced publisher logic to manage write channels for WebSocket connections, ensuring efficient message delivery and error handling.
- Bumped version to v0.23.0 to reflect these changes.
2025-11-02 17:02:28 +00:00
0123c2d6f5 Update dependencies and refactor p256k crypto package
- Bumped version of lol.mleku.dev from v1.0.4 to v1.0.5.
- Added new dependencies: p256k1.mleku.dev and several indirect dependencies for improved cryptographic functionality.
- Refactored p256k package to utilize p256k1.mleku.dev/signer for signature operations, replacing the previous btcec implementation.
- Removed the secp256k1.go file, consolidating the crypto logic under the new p256k1 library.
- Updated documentation to reflect changes in the signer interface and usage.
2025-11-02 16:43:58 +00:00
f092d817c9 Update Go build flags and bump version to v0.21.4
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Modified the Go build command in the GitHub Actions workflow to include linker flags for reduced binary size.
- Updated version from v0.21.3 to v0.21.4 to reflect the latest changes.
2025-11-01 13:41:36 +00:00
c7eb532443 Update Go build configurations and bump version to v0.21.3
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Commented out unused build commands for different platforms in the GitHub Actions workflow to streamline the build process.
- Updated version from v0.21.2 to v0.21.3 to reflect recent changes.
2025-11-01 13:17:09 +00:00
e56b3f0083 Refactor event handling and policy script error management
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Removed redundant log statement in HandleEvent for cleaner output.
- Enhanced policy script handling to check for script existence before execution, improving error handling and fallback logic.
- Updated error messages to provide clearer feedback when policy scripts are missing or fail to start.
- Bumped version to v0.21.2 to reflect these changes.
2025-11-01 12:55:42 +00:00
daniyal
9064b3ab5f Fix deployment script issues (#1)
- Fix Go installation by extracting to /tmp first then moving to final destination
- Return to original directory after Go installation
- Add attempt to install secp256k1 from package manager before building from source
- Add missing automake package for autoreconf
- Fix binary build by running go build after embedded web update

Co-authored-by: mleku <me@mleku.dev>
Reviewed-on: https://git.nostrdev.com/mleku/next.orly.dev/pulls/1
Co-authored-by: daniyal <daniyal@nostrdev.com>
Co-committed-by: daniyal <daniyal@nostrdev.com>
2025-10-30 20:05:22 +00:00
3486d3d4ab added simple websocket test
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- bump to v0.21.1
2025-10-30 19:32:45 +00:00
0ba555c6a8 Update version to v0.21.0 and enhance relay client functionality
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Bumped version from v0.20.6 to v0.21.0.
- Added a `complete` map in the Client struct to track subscription completion status.
- Improved event handling in the read loop to manage EOSE messages and subscription closures.
- Introduced new tests for filtering, event ordering, and subscription behaviors, enhancing test coverage and reliability.
2025-10-30 19:26:42 +00:00
54f65d8740 Enhance relay testing and event handling
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated TestRelay to include a wait mechanism for relay readiness, improving test reliability.
- Refactored startTestRelay to return the assigned port, allowing dynamic port assignment.
- Added timestamp validation in HandleEvent to reject events with timestamps more than one hour in the future.
- Introduced channels for handling OK and COUNT messages in the Client struct, improving message processing.
- Updated tests to reflect changes in event timestamp handling and increased wait times for event processing.
- Bumped version to v0.20.6 to reflect these enhancements.
2025-10-30 19:12:11 +00:00
2ff8b47410 bump to v0.20.5
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-30 18:37:30 +00:00
ba2d35012c Enhance WebSocket connection management and error handling
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Set initial read deadline for connections to prevent premature timeouts on idle connections.
- Improved pong and ping handlers to extend read deadlines and handle timeout errors more effectively.
- Refined error logging for connection issues, distinguishing between timeouts and connection errors to enhance debugging.
- Updated subscriber delivery logic to handle timeouts gracefully, allowing for potential recovery without immediate disconnection.
2025-10-30 18:32:03 +00:00
b70f03bce0 Refactor policy script handling and improve fallback logic
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Renamed test functions for clarity, changing "NotRunning" to "Disabled" to better reflect the policy state.
- Updated policy checks to ensure that if the policy is disabled, it falls back to the default policy immediately.
- Enhanced error handling in the policy manager to ensure proper startup and running state management.
- Introduced a new method to ensure the policy is running, with timeout handling for startup completion.
- Bumped version to v0.20.3 to reflect these changes.
2025-10-30 18:22:56 +00:00
8954846864 Add relay testing framework and utilities
- Introduced a new `relaytester` package to facilitate testing of relay functionalities.
- Implemented a `TestSuite` structure to manage and execute various test cases against the relay.
- Added multiple test cases for event publishing, retrieval, and validation, ensuring comprehensive coverage of relay behavior.
- Created utility functions for generating key pairs and events, enhancing test reliability and maintainability.
- Established a WebSocket client for interacting with the relay during tests, including subscription and message handling.
- Included JSON formatting for test results to improve output readability.
- This commit lays the groundwork for robust integration testing of relay features.
2025-10-30 18:14:22 +00:00
5e6c0b80aa Add Relay functionality for managing startup and shutdown processes
- Introduced a new package `run` with a `Relay` struct to manage the lifecycle of a relay instance.
- Implemented `Start` and `Stop` methods for initializing and gracefully shutting down the relay, including options for log capturing and data directory cleanup.
- Added methods to retrieve captured stdout and stderr logs.
- Enhanced configuration handling for data directory and logging based on user-defined options.
2025-10-30 17:57:57 +00:00
80ab3caa5f Implement policy-based event filtering and add integration tests
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Enhanced the HandleReq function to incorporate policy checks for privileged events, ensuring only authorized users can access sensitive data.
- Introduced a new integration test suite for policy filtering, validating the behavior of event access based on user authentication and policy rules.
- Added a script to automate the policy filter integration tests, improving testing efficiency and reliability.
- Updated version to v0.20.2 to reflect the new features and improvements.
2025-10-30 17:51:15 +00:00
62f244d114 Refactor event handling and testing utilities
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated the HandleReq function to improve event filtering logic, ensuring that privileged events are consistently checked against user access levels.
- Refactored event deduplication to utilize filtered events instead of all events, enhancing performance and clarity.
- Enhanced test utilities by generating keypairs for event creation, ensuring proper signing and validation in tests.
- Updated various test cases to use the new event creation methods, improving reliability and maintainability of tests.
- Bumped version to reflect changes made.
2025-10-30 15:53:02 +00:00
88ebf6eccc Update WebSocket implementation to use Gorilla WebSocket library
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Replaced the existing `github.com/coder/websocket` package with `github.com/gorilla/websocket` for improved functionality and compatibility.
- Adjusted WebSocket connection handling, including message reading and writing, to align with the new library's API.
- Enhanced error handling and logging for WebSocket operations.
- Bumped version to v0.20.0 to reflect the changes made.
2025-10-30 15:20:39 +00:00
4f97cb9a42 Improve error handling and logging in message processing
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated HandleMessage function to include the actual message content in error notices for invalid messages, enhancing clarity for debugging.
- Added nil check in Value method of tag encoder to prevent potential nil pointer dereference.
- Refactored tag handling in spider to utilize hex-encoded pubkeys for improved compatibility and clarity in filter creation.
- bump to v0.19.10
2025-10-28 20:26:09 +00:00
df67538af2 Refactor Marshal function in filter encoder
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Simplified the key-value appending logic in the Marshal function by replacing manual string appending with the text.AppendQuote method for better readability and maintainability.
- Updated version number to v0.19.9 to reflect the changes made.
2025-10-28 19:23:27 +00:00
f5d13a6807 Update error handling and logging in message processing
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Modified the HandleMessage function to avoid logging actual message content and instead send generic error notices to clients for invalid or malformed messages, enhancing security by preventing exposure of binary data.
- Updated the NostrEscape function to escape all control characters to ensure valid JSON, preventing parsing errors with binary data.
- Adjusted policy checks to handle hex-encoded pubkeys correctly, ensuring compatibility with the updated encoding scheme.
- Introduced blackout period for relay connections after exceeding maximum reconnection delays, improving connection stability and management.
2025-10-28 19:12:02 +00:00
a735bd3d5e bump to v0.19.7
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-28 18:43:31 +00:00
0a32cc3125 bump to v0.19.6 2025-10-28 18:42:25 +00:00
7906bb2295 Add WebSocket Connection Testing Scripts
- Introduced two new test scripts: `test-relay-connection.js` and `test-websocket-close.js` to verify WebSocket connection stability and closure behavior.
- `test-relay-connection.js` tests multiple connections, monitors their open/close events, and analyzes premature closures.
- `test-websocket-close.js` focuses on connection closure issues with concurrent connections and logs results for connected, closed, and error states.
- Both scripts utilize the NostrWebSocket from the @nostr-dev-kit/ndk package for testing purposes.
2025-10-28 18:42:18 +00:00
50a8b39ea3 fix broken pprof web by removing for now 2025-10-28 13:32:30 +00:00
45cfd04214 Refactor Go Build Process to Focus on Orly Binary
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated the Go workflow to only build the orly binary as requested, simplifying the build process.
- Removed the building of additional command-line utilities, which are designated as development tools.
- Added comments for clarity regarding the changes made to the build process.
2025-10-27 19:48:55 +00:00
ced06a9175 Update Go Installation Script and Adjust Directory Structure
- Changed GOROOT path from "$HOME/.local/go" to "$HOME/go" for improved clarity and consistency.
- Removed unnecessary creation of the "$HOME/.local" directory, simplifying the installation process.
- Updated the download and extraction process for Go to use the home directory, enhancing usability.
- Cleaned up the script by removing redundant commands and improving overall readability.
2025-10-27 19:13:22 +00:00
f4358eeee0 Enhance Event Loading Logic and Update Permissions
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated event loading logic in App.svelte to reset the attempt flag when toggling filters or switching tabs, preventing infinite load loops.
- Modified role-based permissions to include "read" access for viewing events, ensuring users with "read", "write", "admin", or "owner" roles can access the event list.
- Adjusted alert messages to reflect the updated permission structure.
- Incremented the version number to v0.19.5 to reflect these changes.
2025-10-27 13:25:18 +00:00
ebb11686d5 Update Role-Based Permissions and Increment Version
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Modified role-based permissions in App.svelte and EventsView.svelte to include "read" access for viewing events, enhancing user access control.
- Updated conditional rendering logic to reflect the new permission structure, ensuring users with "read", "write", "admin", or "owner" roles can view events.
- Incremented the version number to v0.19.4 to reflect these changes.
2025-10-27 11:59:54 +00:00
d4f4f2a186 Implement New Views and Refactor App Structure
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Added new components: Header, Sidebar, ExportView, ImportView, EventsView, ComposeView, RecoveryView, SprocketView, and SearchResultsView to enhance the application's functionality and user experience.
- Updated App.svelte to integrate the new views and improve the overall layout.
- Refactored existing components for better organization and maintainability.
- Adjusted CSS styles for improved visual consistency across the application.
- Incremented version number to v0.19.3 to reflect the latest changes and additions.
2025-10-25 23:03:34 +01:00
9abcb32030 Refactor Event Signing and Update Test Cases
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Changed the event signing method in App.svelte from `sign` to `signEvent` for clarity and consistency.
- Updated the privileged events test to replace `PrivateDirectMessage` with `DirectMessage`, aligning with the latest event kind definitions.
- Adjusted the bundle.js and bundle.js.map files to reflect the latest build changes and optimizations.
2025-10-25 20:45:36 +01:00
fe0ed11ce4 Enhance App Functionality with Role-Based Permissions and UI Improvements
- Introduced a new permission menu and role view settings to manage user access more effectively.
- Updated tab filtering logic to accommodate role-based permissions, ensuring users see only relevant options based on their roles.
- Enhanced the App.svelte component with new CSS styles for the view-as section, improving user experience and visual consistency.
- Added debug logging for tab filtering to assist in development and troubleshooting.
- Incremented the version number to reflect the latest changes and improvements.
2025-10-25 20:37:18 +01:00
5452da6ecc Update IndexedDB Handling and Enhance App Functionality
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Modified the NostrClient's publish method to allow for specific relay usage and added event storage in IndexedDB.
- Introduced a debug function for IndexedDB to inspect stored events and their counts by kind.
- Updated the App.svelte component to expose the debug function globally for easier access during development.
- Enhanced the CSS styles for better user feedback on export results and status messages.
- Incremented the IndexedDB version to accommodate new indexes and improve event storage management.
- Updated the version number to v0.19.1.
2025-10-25 19:26:31 +01:00
c5ff2c648c Refactor Nostr Client and Update Dependencies
- Replaced NDKPrivateKeySigner with PrivateKeySigner from applesauce-signers for improved signing functionality.
- Updated the Nostr client implementation to utilize nostr-tools for event management and connection pooling.
- Enhanced event fetching logic to support multiple versions of replaceable events based on limit parameters.
- Updated package dependencies in package.json and bun.lock, including the addition of applesauce-core and applesauce-signers.
- Refined event kind definitions and improved documentation for clarity and consistency with NIP specifications.
- Adjusted CSS styles in bundle.css for better visual consistency across components.
2025-10-25 17:27:25 +01:00
badac55813 Add Comprehensive Tests for Directory Protocol
- Introduced a new test suite in `directory_test.go` covering various aspects of the NIP-XX protocol, including relay identity announcements, trust acts, group tag acts, and public key advertisements.
- Implemented tests for event creation, signing, verification, and parsing, ensuring robust handling of protocol messages.
- Enhanced validation checks for trust levels and group tag names, ensuring compliance with defined standards.
- Created a detailed `TEST_SUMMARY.md` to document test coverage, execution instructions, and results, highlighting the stability and readiness of the protocol implementation.
- Removed the deprecated NIP-11 URL from relay identity announcements, streamlining the event structure and improving clarity in the protocol documentation.
- Updated the `types.go` file to reflect changes in trust level definitions and event kind descriptions, enhancing overall documentation quality.
2025-10-25 14:37:34 +01:00
8e15ca7e2f Enhance Directory Client Library for NIP-XX Protocol
- Introduced a TypeScript client library for the Distributed Directory Consensus Protocol (NIP-XX), providing a high-level API for managing directory events, identity resolution, and trust calculations.
- Implemented core functionalities including event parsing, trust score aggregation, and replication filtering, mirroring the Go implementation.
- Added comprehensive documentation and development guides for ease of use and integration.
- Updated the `.gitignore` to include additional dependencies and build artifacts for the TypeScript client.
- Enhanced validation mechanisms for group tag names and trust levels, ensuring robust input handling and security.
- Created a new `bun.lock` file to manage package dependencies effectively.
2025-10-25 14:12:09 +01:00
5652cec845 Refactor NIP-XX Document and Protocol Implementation for Directory Consensus
- Updated the NIP-XX document to clarify terminology, replacing "attestations" with "acts" for consistency.
- Enhanced the protocol by introducing new event kinds: Trust Act (Kind 39101) and Group Tag Act (Kind 39102), with detailed specifications for their structure and usage.
- Modified the signature generation process to include the canonical WebSocket URL, ensuring proper binding and verification.
- Improved validation mechanisms for identity tags and event replication requests, reinforcing security and integrity within the directory consensus protocol.
- Added comprehensive documentation for new event types and their respective validation processes, ensuring clarity for developers and users.
- Introduced new helper functions and structures to facilitate the creation and management of directory events and acts.
2025-10-25 12:33:47 +01:00
f0e89c84bd Update NIP-XX Document to Reflect Changes in Key Management and Terminology
- Renamed "Replica Identity Keys" to "Relay Identity Keys" for clarity.
- Enhanced specifications for signing and encryption keys, detailing their usage with secp256k1.
- Introduced a new section on Hierarchical Deterministic Key Derivation, outlining key management protocols and derivation paths.
- Expanded guidelines for key pool management, consumption tracking, and cross-client synchronization to improve security and usability.
- Updated key advertisement structure to include derivation paths and key indices for better tracking and management.
2025-10-25 11:28:15 +01:00
25f8424320 Update NIP-XX Document to Include Relay Address in Signature Process
- Modified the signature generation and verification processes to include the relay address, ensuring cryptographic binding between the relay identity and its network location.
- Enhanced the description of the address binding mechanism to clarify its role in preventing relay impersonation attacks.
- Updated related sections to reflect changes in the concatenation process for signature generation and verification, reinforcing security measures for NIP-11 documents.
2025-10-25 09:45:27 +01:00
7812d9b0b6 Enhance NIP-XX Document with Event Kind Replication and Identity Tag Features
- Added new `K` and `I` tags to support event kind replication and identity verification in trust attestations.
- Expanded trust level definitions to include replication of custom event kinds specified in the `K` tag.
- Introduced detailed specifications for the `I` tag, including identity proof-of-control signature requirements and usage guidelines.
- Updated event synchronization and conflict resolution processes to incorporate identity verification and key delegation management.
- Enhanced security considerations to address identity tag verification and delegation limits, ensuring robust trust management among relays.
2025-10-25 05:21:11 +01:00
dfc3429e14 Add NIP-XX: Distributed Directory Consensus Protocol
- Introduced a new document defining a protocol for distributed consensus among Nostr relays using replica identity keys and a web of trust.
- Addressed issues of data availability, synchronization overhead, discovery challenges, and trust management among relay operators.
- Specified relay identity keys, trust attestations, and new event kinds for directory event replication.
- Detailed mechanisms for identity verification, event synchronization, and conflict resolution to ensure Byzantine fault tolerance.
- Ensured backwards compatibility with existing Nostr implementations while enhancing directory event management.
2025-10-25 04:37:17 +01:00
44d22a383e Update dependencies and enhance deployment scripts
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Bumped versions of several dependencies in go.mod, including golang.org/x/crypto to v0.43.0 and golang.org/x/net to v0.46.0.
- Added new indirect dependencies for improved functionality.
- Removed outdated files: package.json, POLICY_TESTS_SUCCESS.md, and POLICY_TESTS_SUMMARY.md.
- Introduced a comprehensive deployment script for automated setup and configuration.
- Added testing scripts for deployment validation and policy system tests.
- Bumped version to v0.19.0.
2025-10-24 21:03:44 +01:00
eaf8f584ed bump version properly
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-24 20:18:00 +01:00
75f2f379ec Enhance authentication handling in request processing
- Updated HandleCount, HandleEvent, and HandleReq functions to improve authentication checks based on new configuration options.
- Introduced `AuthToWrite` configuration to allow unauthenticated access for COUNT and REQ operations while still enforcing ACL checks.
- Enhanced comments for clarity on authentication requirements and access control logic.
- Bumped version to v0.17.18.
2025-10-24 20:16:03 +01:00
28ab665285 Implement privileged event filtering and add comprehensive tests
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Refactored the HandleReq function to improve the handling of privileged events, ensuring that only authorized users can access them based on their authentication status and associated tags.
- Introduced a new test suite for privileged event filtering, covering various scenarios including authorized access, unauthorized access, and edge cases with malformed tags.
- Enhanced the publisher logic to deny delivery of privileged events to unauthenticated subscribers.
- Bumped version to v0.17.18.
2025-10-24 19:53:34 +01:00
bc8a557f07 Refactor context handling in HandleCount and HandleReq functions
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated context creation in HandleCount and HandleReq to use context.Background() instead of the connection context, isolating timeouts to prevent affecting long-lived websocket connections.
- Improved comments for clarity on the purpose of the context changes.
- bump version to v0.17.17
2025-10-24 16:55:15 +01:00
da1119db7c Enhance aggregator functionality for Nostr event collection
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated the aggregator to support both public (npub) and private (nsec) key inputs for event searching, enabling authentication for relays that require it.
- Implemented bloom filter loading and appending capabilities for efficient incremental data collection.
- Added timeout parameters for maximum runtime and stuck progress detection to improve reliability.
- Enhanced README with detailed usage instructions, authentication behavior, and examples for incremental collection.
- Bumped version to v0.17.16.
2025-10-23 13:00:01 +01:00
4c53709e2d Add aggregator functionality for Nostr event collection
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced a new `aggregator` package to search for events related to a specific npub across multiple Nostr relays.
- Implemented dynamic relay discovery from relay list events and progressive backward time-based fetching for comprehensive historical data collection.
- Added a bloom filter for memory-efficient event deduplication with a low false positive rate.
- Enhanced memory management with real-time monitoring and automatic garbage collection.
- Updated README with usage instructions, features, and detailed explanations of event discovery and memory management strategies.
- Bumped version to v0.17.15.
2025-10-23 12:17:50 +01:00
a4fc3d8d9b Implement spider functionality for event synchronization
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Introduced a new `spider` package to manage connections to admin relays and synchronize events for followed pubkeys.
- Added configuration options for spider mode in the application settings, allowing for different operational modes (e.g., follows).
- Implemented callback mechanisms to dynamically retrieve admin relays and follow lists.
- Enhanced the main application to initialize and manage the spider, including starting and stopping its operation.
- Added tests to validate spider creation, callbacks, and operational behavior.
- Bumped version to v0.17.14.
2025-10-22 22:24:21 +01:00
cd6a53a7b7 Remove spider functionality and related configurations
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Deleted the spider package and its associated functionality from the main application.
- Removed references to spider mode and frequency from configuration files and documentation.
- Updated the main application to eliminate spider initialization and related logic.
- Cleaned up import statements and configuration options to reflect the removal of spider features.
2025-10-22 11:56:59 +01:00
117e5924fd Remove deprecated JSON encoder/decoder and update dependencies
- Deleted the custom JSON encoder/decoder implementation in favor of the standard library's `encoding/json` package.
- Removed the `next.orly.dev/pkg/encoders/json` package and its associated files.
- Updated `go.mod` to remove the `github.com/nostr-dev-kit/ndk` dependency and bump the version of `lol.mleku.dev` from v1.0.3 to v1.0.4.
- Cleaned up import statements across various files to reflect the removal of the custom JSON package.
- Ensured that all references to the old JSON encoding/decoding methods are replaced with the standard library equivalents.
2025-10-22 11:37:39 +01:00
6cff006e54 Update policy management and documentation
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Bumped the version of the `lol.mleku.dev` dependency from v1.0.3 to v1.0.4.
- Removed redundant checks for disabled policy in event handling.
- Introduced a `default_policy` field in the policy configuration, allowing for more flexible event handling based on default behaviors.
- Enhanced documentation to clarify the default policy logic and its implications on event processing.
- Updated tests to validate the new default policy behavior and ensure correct fallback mechanisms are in place.
2025-10-21 21:46:07 +01:00
7f5bd3960c Update user tagging in payment processor and bump version to v0.17.11
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Modified the user tagging logic in the payment processor to include a mention in the third field of the "p" tag.
- Incremented the version number to v0.17.11 for the release.
2025-10-21 20:05:45 +01:00
8287035920 version bump to v0.17.10
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-21 19:37:22 +01:00
54a01e1255 Enhance welcome note personalization and privacy
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated the welcome note content to include a personalized greeting using the user's npub.
- Added a privacy notice to ensure users are aware that the note is only visible to them.
- Simplified the addition of the user npub to the authorized list by removing redundant encoding logic.
2025-10-21 19:35:16 +01:00
0bcd83bde3 version bump
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-21 19:22:11 +01:00
26c754bb2e Add protected tag to event processing
- Introduced a new protected tag "-" to mark events as protected in the payment processor.
- Updated event tagging logic to include this new tag for events with authorized public keys, enhancing event categorization and security.
- Ensured consistency in tagging across multiple event handling functions.
2025-10-21 19:22:01 +01:00
da66e26614 Implement private tag filtering for event visibility
- Added functionality to filter events based on "private" tags, allowing only authorized users to see private events.
- Introduced a new method `canSeePrivateEvent` to check user permissions against private tags.
- Updated event delivery logic to deny access to unauthorized users for private events, enhancing security and user experience.
- Bumped version to v0.17.7.
2025-10-21 19:17:16 +01:00
8609e9dc22 bumping version to v0.17.6
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-21 18:45:55 +01:00
3cb05a451c Enhance profiling management and interrupt handling in main application
- Introduced a mechanism to ensure profiling is stopped on interrupts (SIGINT/SIGTERM) and normal exit using a sync.Once pattern.
- Added a new script for running the relay with CPU profiling, including automated cleanup and error handling.
- Updated signal handling to include SIGTERM for improved shutdown behavior.
- Enhanced logging for profiling stop events to improve traceability during application termination.
2025-10-21 18:45:14 +01:00
4e3f391c3f bumping version to cat pkg/version/version
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-21 17:23:01 +01:00
9aa1e7fab3 Enhance graceful shutdown and logging for HTTP server
- Implemented graceful shutdown for the HTTP server, allowing for a controlled shutdown process with a timeout.
- Added logging for shutdown signals and server exit to improve traceability during application termination.
- Introduced IP blacklist configuration to enhance security by blocking specified IP addresses.
- Updated database cache configurations to allow dynamic adjustment via environment variables for better performance tuning.
2025-10-21 17:20:48 +01:00
15e2988222 Refactor self-connection handling and improve message processing
- Removed self-connection detection logic from the Listener and Server, simplifying the message handling process.
- Updated the HandleMessage and handle-websocket functions to eliminate checks for self-connections, enhancing clarity and maintainability.
- Adjusted AUTH challenge logic to focus solely on blacklisted IPs, streamlining connection management.
2025-10-21 16:31:28 +01:00
95c6082564 Implement blacklisting for IPs and enhance follow list fetching
- Added functionality to handle blacklisted IPs, allowing connections to remain open until a timeout is reached.
- Introduced periodic fetching of admin follow lists to improve synchronization with relay data.
- Updated WebSocket message size limits to accommodate larger payloads.
- Enhanced logging for better traceability during follow list fetching and event processing.
- Refactored event subscription logic to improve clarity and maintainability.
2025-10-21 16:31:17 +01:00
384b6113bc fixes memory problem with many pubkeys in query
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-20 22:20:21 +01:00
465de549d0 bump to v0.17.3
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-20 21:09:35 +01:00
c7dcbdec9f make replacement not delete, and filter query results respect limits
- Added detailed logging in GetSerialsByRange, CheckForDeleted, and SaveEvent functions to improve traceability during event processing.
- Implemented safety limits in GetSerialsByRange to prevent infinite loops during debugging.
- Updated event deletion logic to ensure only specific events are marked as deleted, improving clarity in event management.
- Refactored WouldReplaceEvent to maintain compatibility while simplifying the return values.
- Adjusted test cases to verify the correct behavior of replaced events and their deletion status.
2025-10-20 21:08:48 +01:00
65e8ab4fbe Enhance logging and add policy test client
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Refactored logOutput function to trace-log stdout and stderr lines for better debugging.
- Introduced a new policy test client in cmd/policytest/main.go to publish events and check policy responses.
- Added a script to run the policy test, ensuring proper cleanup and error handling.
- Updated policy event processing to ensure newline-terminated JSON for compatibility with shell-readers.
2025-10-20 16:30:39 +01:00
105e372712 Refactor and enhance UI components for improved styling and functionality
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated CSS styles for modal and tab components to enhance visual consistency and responsiveness.
- Refactored JavaScript imports in App.svelte for better readability and organization.
- Improved layout and padding in ManagedACL.svelte for a more user-friendly interface.
- Adjusted event handling and state management in App.svelte to streamline user interactions.
- Updated bundle files to reflect the latest changes in the application structure and styling.
- bump to v0.17.1
2025-10-16 18:02:39 +01:00
bcd79aa967 implemented nip-86 relay management API and added to relay client
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
2025-10-16 17:20:04 +01:00
a4c4f14b87 Refactor event processing in tests for chronological order
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated test files to collect events first and sort them by their CreatedAt timestamp before processing, ensuring events are handled in chronological order.
- Removed redundant error checks for scanner errors after event processing.
- Enhanced clarity and maintainability of event handling in tests across multiple files, including export, fetch, and query tests.
- Adjusted expected index counts in tests to reflect changes in event structure and processing logic.
2025-10-16 15:05:19 +01:00
db941a18ea Enhance policy system with global rules and age validation
Some checks failed
Go / build (push) Has been cancelled
Go / release (push) Has been cancelled
- Updated policy configuration to include global rules applicable to all events, allowing for site-wide security policies.
- Introduced age validation features to prevent replay and clock skew attacks, with configurable maximum age limits for events.
- Enhanced example policy and README documentation to reflect new global rules and age validation capabilities.
- Added comprehensive tests for global rule checks and age validation scenarios.
- Bumped version to v0.16.2.
2025-10-16 12:41:32 +01:00
585ce11f71 enable release workflow 2025-10-16 11:47:20 +01:00
a99c004ee8 bumping version
Some checks failed
Go / build (push) Has been cancelled
2025-10-16 11:42:37 +01:00
1cfd4a6dbe Include app/web/dist in repository
- Remove app/web/dist/** from .gitignore
- Add built web assets to repository for deployment
- Includes bundle.css, bundle.js, index.html and other static assets
2025-10-16 11:41:57 +01:00
a84782bd52 Implement policy system with comprehensive testing and configuration
Some checks failed
Go / build (push) Has been cancelled
- Introduced a new policy system for event processing, allowing fine-grained control over event storage and retrieval based on various criteria.
- Added support for policy configuration via JSON files, including whitelists, blacklists, and custom scripts.
- Implemented a test suite for the policy system, ensuring 100% test coverage of core functionality and edge cases.
- Created benchmark tests to evaluate policy performance under various conditions.
- Updated event handling to integrate policy checks for both read and write access.
- Enhanced documentation with examples and usage instructions for the policy system.
- Bumped version to v0.16.0.
2025-10-16 11:37:30 +01:00
f19dc4e5c8 Implement compose tab for event creation and management
Some checks failed
Go / build (push) Has been cancelled
- Added a new compose tab for users to create, sign, and publish Nostr events.
- Introduced functions for JSON reformatting, event signing, and publishing with WebSocket authentication.
- Implemented clipboard functionality to copy event JSON directly from the UI.
- Enhanced UI with buttons for reformatting, signing, and publishing events, improving user experience.
- Created a new websocket-auth.js module for handling WebSocket authentication with Nostr relays.
- Bumped version to v0.14.5.
2025-10-11 10:30:38 +01:00
9064717efa bump version to v0.14.4 2025-10-11 09:28:36 +01:00
49619f74c7 fix user button and images
Some checks failed
Go / build (push) Has been cancelled
2025-10-11 09:23:01 +01:00
5952c7e657 Revert "Update project structure and enhance build process"
This reverts commit 4cf3d9cfb5.
2025-10-11 08:46:30 +01:00
4cf3d9cfb5 Update project structure and enhance build process
Some checks failed
Go / build (push) Has been cancelled
- Removed unnecessary entries from .gitignore related to the app/web/dist directory.
- Updated app/web/.gitignore to exclude the dist directory.
- Added rollup-plugin-copy to package.json and updated rollup.config.js to copy static assets during the build process.
- Introduced new CSS and JS files in the dist directory for improved styling and functionality.
- Updated index.html to reference new global CSS and JS files, enhancing the overall user interface.
- Added favicon and other image assets to the dist directory for branding consistency.
2025-10-11 08:05:50 +01:00
506ad66aeb bump v0.14.2
Some checks failed
Go / build (push) Has been cancelled
2025-10-11 07:53:32 +01:00
b0f919cd5a Enhance event display and UI in App.svelte
- Added a formatTimestamp function to display event creation timestamps.
- Refactored event rendering to include timestamps and improved content truncation.
- Updated the events view header and buttons for better layout and usability.
- Adjusted styles for event items and timestamps for improved readability.
- Implemented responsive design adjustments for smaller screens.
2025-10-11 07:52:48 +01:00
4a835a8b43 fix workflow 2025-10-10 22:36:04 +01:00
3c11aa6f01 refactored SaveEvent to return if it is replacing an event
Some checks failed
Go / build (push) Has been cancelled
2025-10-10 22:18:53 +01:00
bc5177e0ec refactor save event method to expose whether it replaced an event 2025-10-10 22:16:07 +01:00
0cdf44c2c9 fully working deletes bump to v0.14.0
Some checks failed
Go / build (push) Has been cancelled
2025-10-10 21:52:32 +01:00
40f3cb6f6e Enhance delete event functionality and UI updates
- Improved logging in handle-delete.go for admin and owner checks during delete operations.
- Updated handle-event.go to ensure delete events are saved before processing, with enhanced error handling.
- Added fetchDeleteEventsByTarget function in nostr.js to retrieve delete events targeting specific event IDs.
- Modified App.svelte to include delete event verification and improved event sorting by creation timestamp.
- Enhanced UI to display delete event information and added loading indicators for event refresh actions.
2025-10-10 21:23:36 +01:00
67a74980f9 Enhance delete event handling and logging
- Improved logging for delete events in handle-delete.go, including detailed information about the event and its tags.
- Added checks for admin and owner deletions, with appropriate logging for each case.
- Updated HandleEvent to process delete events more robustly, including success and error logging.
- Introduced a new fetchEventById function in nostr.js to verify event deletion.
- Updated App.svelte to handle event deletion verification and state management.
- Changed favicon references in HTML files to use the new orly-favicon.png.
- Added orly-favicon.png to the public and docs directories for consistent branding.
2025-10-10 20:36:53 +01:00
dc184d7ff5 Revert ephemeral event handling changes that broke relaytester
- Remove ephemeral event handling in handle-event.go
- Remove ephemeral event rejection in save-event.go
- Remove formatTimestamp function and title attributes in App.svelte
- Remove TestEphemeralEventRejection test
- Fix slice bounds bug in get-serials-by-range.go
- Restore correct error message format for existing events
- Revert version from v0.13.2 to v0.12.3

This reverts commit 075838150d which introduced
a critical bug causing runtime panics in the relaytester.
2025-10-10 19:55:39 +01:00
c31cada271 small cleanups 2025-10-10 17:42:53 +01:00
075dc6b545 Integrate NDK for Nostr client functionality and update dependencies
This commit introduces the Nostr Development Kit (NDK) to enhance the Nostr client functionality. Key changes include:

- Added `NDKPrivateKeySigner` for improved authentication methods in `LoginModal.svelte` and `App.svelte`.
- Refactored the Nostr client to utilize NDK for connection and event fetching, streamlining the connection process and event handling.
- Updated `go.mod` and `package.json` to include `@nostr-dev-kit/ndk` as a dependency.
- Created a new `package-lock.json` to reflect the updated dependency tree.

These changes improve the overall architecture and maintainability of the Nostr client.
2025-10-10 17:37:43 +01:00
919747c910 Remove fallback external relays from DEFAULT_RELAYS in constants.js to streamline relay connection handling. 2025-10-10 17:11:21 +01:00
0acf51baba fix favicon 2025-10-10 10:38:47 +01:00
e75d0deb7d Add orly.png image to web and docs directories 2025-10-10 10:30:48 +01:00
96276f2fc4 putting a proper favicon in
Some checks failed
Go / build (push) Has been cancelled
2025-10-10 10:25:50 +01:00
14a94feed6 add favicon 2025-10-10 09:44:25 +01:00
075838150d fix ephemeral handling to not save
Some checks failed
Go / build (push) Has been cancelled
2025-10-10 09:35:15 +01:00
2637f4b85c add simple fulltext search
Some checks failed
Go / build (push) Has been cancelled
2025-10-10 09:17:53 +01:00
27af174753 Implement event deletion logic with relay handling in App.svelte and add connectToRelay method in NostrClient
Some checks failed
Go / build (push) Has been cancelled
This commit enhances the event deletion process by introducing conditional publishing to external relays based on user roles and ownership. It also adds a new method in the NostrClient class to connect to a single relay, improving the flexibility of relay management. The version is bumped to v0.12.3 to reflect these changes.
2025-10-10 09:07:43 +01:00
cad366795a bump to v0.12.2 for sprocket failure handling fix
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 19:56:25 +01:00
e14b89bc8b Enhance Sprocket functionality and error handling
This commit introduces significant improvements to the Sprocket system, including:

- Detailed documentation in `readme.adoc` for manual updates and failure handling.
- Implementation of automatic disablement of Sprocket on failure, with periodic checks for recovery.
- Enhanced logging for event rejection when Sprocket is disabled or not running.

These changes ensure better user guidance and system resilience during Sprocket failures.
2025-10-09 19:55:20 +01:00
5b4dd9ea60 bump for better documentation
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 19:34:25 +01:00
bae1d09f8d Add Sprocket Test Suite and Integration Scripts
This commit introduces a comprehensive test suite for the Sprocket integration, including various test scripts to validate functionality. Key additions include:

- `run-sprocket-test.sh`: An automated test runner for Sprocket integration tests.
- `SPROCKET_TEST_README.md`: Documentation detailing the test suite, criteria, and usage instructions.
- `test-sprocket-complete.sh`: A complete test suite that sets up the relay and runs all tests.
- `test-sprocket-manual.sh`: A manual testing script for interactive event testing.
- `test-sprocket-demo.sh`: A demonstration script showcasing Sprocket functionality.
- Additional test scripts for various scenarios, including normal events, spam detection, and blocked hashtags.

These changes enhance the testing framework for the Sprocket system, ensuring robust validation of event processing capabilities.
2025-10-09 19:33:42 +01:00
f1f3236196 revise readme.adoc 2025-10-09 19:31:38 +01:00
f01cd562f8 added sprocket script capability
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 19:11:29 +01:00
d2d0821d19 implement first draft of sprockets 2025-10-09 19:09:37 +01:00
09b00c76ed bump to v0.11.3
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 18:10:46 +01:00
de57fd7bc4 Revert "fixing app icon"
This reverts commit b7c2e609f6.
2025-10-09 18:00:44 +01:00
b7c2e609f6 fixing app icon
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 17:52:14 +01:00
cc63fe751a bumping to v0.11.1
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 17:46:48 +01:00
d96d10723a events view works with infinite scroll and load more button, filter switch to show only user's events
Some checks failed
Go / build (push) Has been cancelled
2025-10-09 17:41:10 +01:00
ec50afdec0 Enhance event management in App.svelte by implementing pagination and caching for user and all events. Introduce new functions for loading events with timestamp-based pagination, and update UI components to support event expansion and deletion. Refactor event fetching logic in nostr.js to utilize WebSocket REQ envelopes for improved performance. Update default relay settings in constants.js to include local WebSocket endpoint. 2025-10-09 16:14:18 +01:00
ade987c9ac working export my/all events 2025-10-09 15:01:14 +01:00
9f39ca8a62 Refactor export functionality in App.svelte to support both GET and POST methods for event exports, enhancing flexibility in user permissions. Update server-side handling to accommodate pubkey filtering and improve response handling for file downloads. Adjust UI components to reflect these changes, ensuring a seamless user experience. 2025-10-09 14:55:29 +01:00
f85a8b99a3 Update export functionality in App.svelte to allow both admin and owner roles to export all events. Adjust permission checks and UI components to reflect new role-based access for exporting events, enhancing user experience and security. 2025-10-09 14:30:32 +01:00
d7bda40e18 Refactor authentication handling to use WebSocket URLs instead of Service URLs for improved connection management. Introduce WebSocketURL method in the Server struct to dynamically generate WebSocket URLs based on request headers. Clean up whitespace in handle-auth.go for better code readability. 2025-10-08 21:31:04 +01:00
b67961773d Refactor login and logout button styles in App.svelte for improved UI consistency. Update button text from icons to labels for better accessibility. Introduce a floating logout button in the profile banner for enhanced user experience. 2025-10-08 21:15:13 +01:00
5fd58681c9 Increase WebSocket message size limit to 100MB and implement handling for oversized messages. Introduce optimal chunk size calculation in Spider for efficient pubkey processing, ensuring compliance with WebSocket constraints. Enhance logging for message sizes and connection events for better debugging. 2025-10-08 20:40:46 +01:00
2bdc1b7bc0 Implement NIP-98 authentication for HTTP requests, enhancing security for event export and import functionalities. Update server methods to validate authentication and permissions, and refactor event handling in the Svelte app to support new export and import features. Add UI components for exporting and importing events with appropriate permission checks. 2025-10-08 20:06:58 +01:00
332b9b05f7 Enhance user role management in App.svelte by adding fetchUserRole function; update UI to display user role badge upon login. Modify Follows struct to include owners and adjust access level logic in acl package for improved permission handling. 2025-10-08 18:47:29 +01:00
c43ddb77e0 Add App.svelte and LoginModal.svelte components for user authentication; update .gitignore to include Svelte files 2025-10-08 17:56:38 +01:00
e90fc619f2 Update title in index.html from 'Svelte app' to 'ORLY?' 2025-10-08 17:40:40 +01:00
29e5444545 Refactor logging in event handling and message processing to use trace-level logs, enhancing clarity and consistency across the application. Update web application structure to utilize Svelte and remove unused React components, streamlining the project. Additionally, clean up .gitignore and update package dependencies for improved performance. 2025-10-08 16:10:51 +01:00
7ee613bb0e Add initial project structure with Svelte, TypeScript support, and basic Nostr client implementation 2025-10-08 16:09:37 +01:00
23985719ba Move Docker-related files to contrib/stella directory and update paths accordingly 2025-10-07 20:06:12 +01:00
3314a2a892 Refactor event replacement logic into WouldReplaceEvent and bump version to v0.10.5
Some checks failed
Go / build (push) Has been cancelled
2025-10-07 18:18:36 +01:00
7c14c72e9d Remove error return from ToSliceOfSliceOfStrings method in tag encoder and bump version to v0.10.4
Some checks failed
Go / build (push) Has been cancelled
2025-10-07 15:38:46 +01:00
dbdc5d703e Add ToSliceOfSliceOfStrings method to tag encoder and bump version to v0.10.3
Some checks failed
Go / build (push) Has been cancelled
2025-10-07 15:36:26 +01:00
c1acf0deaa Add ToSliceOfStrings method to tag encoder and bump version to v0.10.2
Some checks failed
Go / build (push) Has been cancelled
2025-10-07 15:32:07 +01:00
ccffeb902c Bump dependency versions in go.mod and go.sum
Some checks failed
Go / build (push) Has been cancelled
2025-10-06 13:38:58 +01:00
35201490a0 Add retract v1.0.3 to go.mod 2025-10-06 12:57:20 +01:00
3afd6131d5 add count (NIP-45) envelope support, bump to version v0.10.0
Some checks failed
Go / build (push) Has been cancelled
2025-10-06 12:21:34 +01:00
386878fec8 Bump version to v0.9.3.
Some checks failed
Go / build (push) Has been cancelled
2025-10-03 17:42:26 +01:00
474e16c315 Merge pull request #2 from Silberengel/main
Fixe the startup and make proxying smoother.
2025-10-03 17:38:32 +01:00
Silberengel
47e94c5ff6 fixed configuration 2025-10-03 18:23:56 +02:00
Silberengel
c62fdc96d5 fixed initialization and syncing 2025-10-03 17:51:44 +02:00
Silberengel
4c66eda10e implement bootstrap relays 2025-10-03 17:01:34 +02:00
Silberengel
9fdef77e02 update docker and apache stuff to new next-orly and make it all more proxy-friendly 2025-10-03 11:44:02 +02:00
e8a69077b3 Remove generated JavaScript bundle from the project. 2025-10-02 08:01:59 +01:00
128bc60726 Bump version to v0.9.2.
Some checks failed
Go / build (push) Has been cancelled
2025-10-02 07:08:50 +01:00
6c6f9e8874 Refine event filtering logic to enforce combined match criteria for Authors, Kinds, Tags, and search terms. 2025-10-02 07:08:26 +01:00
01131f252e Rank search results by match relevance and recency, update deduplication, and bump version to v0.9.1.
Some checks failed
Go / build (push) Has been cancelled
2025-10-01 17:52:20 +01:00
02333b74ae completed fulltext index migration
Some checks failed
Go / build (push) Has been cancelled
2025-10-01 17:24:50 +01:00
86ac7b7897 Add full-text search indexing for word tokens and update tokenization logic
- Introduced word index (`WordPrefix`) for tokenized search terms.
- Added word token extraction in event and filter processing.
- Implemented Unicode-aware, case-insensitive tokenizer with URL, mention, and hex filters.
- Extended full-text indexing to include tags and content.
2025-10-01 15:03:41 +01:00
7e6adf9fba Adjust BadgerDB memory configurations to optimize resource usage and bump version to v0.8.9.
Some checks failed
Go / build (push) Has been cancelled
2025-10-01 12:52:45 +01:00
7d5ebd5ccd Adjust BadgerDB memory settings to prevent OOM issues and update version to v0.8.8. 2025-10-01 12:40:34 +01:00
f8a321eaee Add connection start time tracking and improve logging for WebSocket lifecycle
Some checks failed
Go / build (push) Has been cancelled
- Introduced `startTime` field in `Listener` to log connection duration.
- Enhanced diagnostics with detailed connection statistics on close.
- Improved logging and error handling for PING/PONG and message lifecycle.
- Updated version to v0.8.7.
2025-10-01 08:40:04 +01:00
48c7fab795 Improve logging and handling for WebSocket message processing, delivery, and diagnostics.
Some checks failed
Go / build (push) Has been cancelled
- Enhanced logging for WebSocket writes, message handling, and delivery timing.
- Added diagnostics for slow deliveries, failures, and context timeouts.
- Incorporated extensive error handling for malformed messages and client notifications.
- Enabled command results and refined subscription management.
- Introduced detailed connection state tracking and metrics for messages, requests, and events.
- Added new `run-market-probe.sh` script for relay testing and Market seeding.
2025-10-01 08:27:22 +01:00
f6054f3c37 Add run-relay-and-seed.sh script, remove redundant JS library mappings, and improve logging consistency.
- Introduced `scripts/run-relay-and-seed.sh` to simplify relay testing and Market seeding.
- Removed `.idea/jsLibraryMappings.xml` as it is no longer required.
- Enhanced consistency by reintroducing relevant debug logs and removing redundant comments.
2025-09-30 18:39:53 +01:00
e1da199858 Bump version to v0.8.5.
Some checks failed
Go / build (push) Has been cancelled
2025-09-30 18:08:57 +01:00
45b4f82995 Enable additional NIP support, improve tag handling validation, and simplify WebSocket message processing. 2025-09-30 18:07:42 +01:00
e58eb1d3e3 Remove commented-out debug logs and update rules for Go version and Nostr protocol documentation. 2025-09-30 13:11:41 +01:00
72d6ddff15 Merge remote-tracking branch 'origin/main' 2025-09-30 13:11:00 +01:00
a50ef55d8e Remove commented-out debug logs and update rules for Go version and Nostr protocol documentation. 2025-09-30 13:10:45 +01:00
c2d5d2a165 Merge pull request #1 from Silberengel/docker-deployment-setup
Add Docker deployment and Apache reverse proxy setup

lgtm 👍
2025-09-25 19:44:07 +01:00
05b13399e3 Expand README with follows ACL and relay sync spider documentation. 2025-09-23 16:05:32 +01:00
0dea0ca791 Expand README with detailed build instructions, dependency setup, stress testing, and performance benchmarking. 2025-09-23 16:00:30 +01:00
ff017b45d2 Add relay identity pubkey and subscription-based profile updates; bump version to v0.8.4.
Some checks failed
Go / build (push) Has been cancelled
- Included relay identity public key in `relayinfo` response.
- Added `UpdateRelayProfile` function to dynamically create/update relay's subscription profile.
- Incremented version from v0.8.3 to v0.8.4.
2025-09-23 15:08:30 +01:00
50179e44ed Add dashboard URL to relay description and bump version to v0.8.3.
Some checks failed
Go / build (push) Has been cancelled
- Updated relay description to include a dynamically constructed dashboard URL.
- Incremented version from v0.8.2 to v0.8.3.
2025-09-23 14:55:25 +01:00
34a3b1ba69 Add dynamic relay dashboard URL support and version increment to v0.8.2.
Some checks failed
Go / build (push) Has been cancelled
- Introduced configuration option `RelayURL` for relay dashboard base URL.
- Added dynamic dashboard URL functionality in `PaymentProcessor`.
- Updated payment notifications to include dashboard access link.
- Incremented version to v0.8.2.
2025-09-23 14:49:08 +01:00
093a19db29 Expand relay features and update version to v0.8.1.
Some checks failed
Go / build (push) Has been cancelled
- Enabled support for additional relay NIPs: Authentication, GenericTagQueries, ParameterizedReplaceableEvents, ExpirationTimestamp.
- Added `PaymentRequired` limitation based on configuration.
- Incremented version to v0.8.1.
2025-09-23 14:26:50 +01:00
2ba361c915 Add relay identity management and subscription enhancements.
Some checks failed
Go / build (push) Has been cancelled
- Introduced relay identity management for subscriptions and follow-list sync.
- Added `IdentityRequested` function to handle the `identity` subcommand.
- Implemented periodic follow-list synchronization for active subscribers.
- Enhanced payment handling to include payer pubkey and subscription updates.
- Added trial expiry and subscription expiry notifications.
2025-09-23 14:22:24 +01:00
7736bb7640 Add payment processing with NWC and subscription-based access control.
- Implemented `PaymentProcessor` to handle NWC payments and extend user subscriptions.
- Added configuration options for NWC URI, subscription pricing, and enablement.
- Updated server to initialize and manage the payment processor.
2025-09-22 17:36:05 +01:00
804e1c9649 Add NWC protocol handling and NIP-44 encryption and decryption functions. 2025-09-22 17:18:47 +01:00
81a6aade4e Bump version to v0.7.1; update relay icon URL.
Some checks failed
Go / build (push) Has been cancelled
2025-09-22 09:38:01 +01:00
fc9600f99d Bump version to v0.7.0; update docs image. 2025-09-22 09:33:04 +01:00
199f922208 Refactor deletion checks and error handling; bump version to v0.6.4.
Some checks failed
Go / build (push) Has been cancelled
2025-09-21 18:15:27 +01:00
405e223aa6 implement delete events 2025-09-21 18:06:11 +01:00
fc3a89a309 Remove unused JavaScript file index-tha189jf.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 17:17:31 +01:00
ba8166da07 Remove unused JavaScript file index-wnwvj11w.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 17:17:15 +01:00
3e3af08644 Remove unused JavaScript file index-wnwvj11w.js from dist directory.
Some checks failed
Go / build (push) Has been cancelled
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 16:39:45 +01:00
fbdf565bf7 Remove unused JavaScript file index-sskmjaqz.js from dist directory.
- Cleaned up the `app/web/dist/` directory by deleting an unreferenced and outdated build artifact.
- Maintained a lean and organized repository structure.
2025-09-21 16:33:23 +01:00
14b6960070 Add admin-only "All Events Log" feature with WebSocket integration.
Some checks failed
Go / build (push) Has been cancelled
- Implemented an "All Events Log" section accessible only to admin users.
- Added WebSocket-based data fetching to retrieve all events from the relay.
- Included profile caching and metadata fetching for event authors.
- Updated UI components to display events with expandable raw JSON details.
- Adjusted CSS for avatar sizes and improved layout.
- Refactored logout logic to reset all event states.
2025-09-21 16:31:06 +01:00
f9896e52ea use websockets for events log 2025-09-21 16:12:10 +01:00
ad7ca69964 Bump version to v0.6.1 for patch release.
Some checks failed
Go / build (push) Has been cancelled
2025-09-21 14:39:23 +01:00
facf03783f Remove outdated CSS and JavaScript files from dist directory.
- Deleted `index-zhtd763e.css` and `index-zqddcpy5.js` to streamline the build artifacts.
- Simplified repository by removing unused generated files to maintain a clean structure.
2025-09-21 14:36:49 +01:00
Silberengel
42273ab2fa Add Docker deployment and Apache reverse proxy setup
🐳 Docker Implementation:
- Add Dockerfile with Alpine Linux (46MB image)
- Add docker-compose.yml with production-ready config
- Add manage-relay.sh for easy local management
- Add stella-relay.service for systemd auto-start
- Published images: silberengel/orly-relay:latest, :v1, :v2

🔧 Apache Reverse Proxy:
- Add comprehensive Apache proxy guide for Plesk and standard Apache
- Add working WebSocket proxy configuration (ws:// not http://)
- Add troubleshooting guide based on real deployment experience
- Add debug-websocket.sh script for systematic diagnosis
2025-09-21 08:57:27 +02:00
543 changed files with 116928 additions and 24044 deletions

View File

@@ -38,7 +38,7 @@ describing how the item is used.
For documentation on package, summarise in up to 3 sentences the functions and
purpose of the package
Do not use markdown ** or __ or any similar things in initial words of a bullet
Do not use markdown \*\* or \_\_ or any similar things in initial words of a bullet
point, instead use standard godoc style # prefix for header sections
ALWAYS separate each bullet point with an empty line, and ALWAYS indent them
@@ -90,8 +90,10 @@ A good typical example:
```
use the source of the relay-tester to help guide what expectations the test has,
and use context7 for information about the nostr protocol, and use additional
use the source of the relay-tester to help guide what expectations the test has,
and use context7 for information about the nostr protocol, and use additional
log statements to help locate the cause of bugs
always use Go v1.25.1 for everything involving Go
always use Go v1.25.1 for everything involving Go
always use the nips repository also for information, found at ../github.com/nostr-protocol/nips attached to the project

View File

@@ -0,0 +1,65 @@
{
"permissions": {
"allow": [
"Skill(skill-creator)",
"Bash(cat:*)",
"Bash(python3:*)",
"Bash(find:*)",
"Skill(nostr-websocket)",
"Bash(go build:*)",
"Bash(chmod:*)",
"Bash(journalctl:*)",
"Bash(timeout 5 bash -c 'echo [\"\"REQ\"\",\"\"test123\"\",{\"\"kinds\"\":[1],\"\"limit\"\":1}] | websocat ws://localhost:3334':*)",
"Bash(pkill:*)",
"Bash(timeout 5 bash:*)",
"Bash(md5sum:*)",
"Bash(timeout 3 bash -c 'echo [\\\"\"REQ\\\"\",\\\"\"test456\\\"\",{\\\"\"kinds\\\"\":[1],\\\"\"limit\\\"\":10}] | websocat ws://localhost:3334')",
"Bash(printf:*)",
"Bash(websocat:*)",
"Bash(go test:*)",
"Bash(timeout 180 go test:*)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(/tmp/find help)",
"Bash(/tmp/find verify-name example.com)",
"Skill(golang)",
"Bash(/tmp/find verify-name Bitcoin.Nostr)",
"Bash(/tmp/find generate-key)",
"Bash(git ls-tree:*)",
"Bash(CGO_ENABLED=0 go build:*)",
"Bash(CGO_ENABLED=0 go test:*)",
"Bash(app/web/dist/index.html)",
"Bash(export CGO_ENABLED=0)",
"Bash(bash:*)",
"Bash(CGO_ENABLED=0 ORLY_LOG_LEVEL=debug go test:*)",
"Bash(/tmp/test-policy-script.sh)",
"Bash(docker --version:*)",
"Bash(mkdir:*)",
"Bash(./test-docker-policy/test-policy.sh:*)",
"Bash(docker-compose:*)",
"Bash(tee:*)",
"Bash(docker logs:*)",
"Bash(timeout 5 websocat:*)",
"Bash(docker exec:*)",
"Bash(TESTSIG=\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\":*)",
"Bash(echo:*)",
"Bash(git rm:*)",
"Bash(git add:*)",
"Bash(./test-policy.sh:*)",
"Bash(docker rm:*)",
"Bash(./scripts/docker-policy/test-policy.sh:*)",
"Bash(./policytest:*)",
"WebSearch",
"WebFetch(domain:blog.scottlogic.com)",
"WebFetch(domain:eli.thegreenplace.net)",
"WebFetch(domain:learn-wasm.dev)",
"Bash(curl:*)",
"Bash(./build.sh)",
"Bash(./pkg/wasm/shell/run.sh:*)",
"Bash(./run.sh echo.wasm)",
"Bash(./test.sh)"
],
"deny": [],
"ask": []
}
}

View File

@@ -0,0 +1,205 @@
---
name: golang
description: This skill should be used when writing, debugging, reviewing, or discussing Go (Golang) code. Provides comprehensive Go programming expertise including idiomatic patterns, standard library, concurrency, error handling, testing, and best practices based on official go.dev documentation.
---
# Go Programming Expert
## Purpose
This skill provides expert-level assistance with Go programming language development, covering language fundamentals, idiomatic patterns, concurrency, error handling, standard library usage, testing, and best practices.
## When to Use
Activate this skill when:
- Writing Go code
- Debugging Go programs
- Reviewing Go code for best practices
- Answering questions about Go language features
- Implementing Go-specific patterns (goroutines, channels, interfaces)
- Setting up Go projects and modules
- Writing Go tests
## Core Principles
When writing Go code, always follow these principles:
1. **Named Return Variables**: ALWAYS use named return variables and prefer naked returns for cleaner code
2. **Error Handling**: Use `lol.mleku.dev/log` and the `chk/errorf` for error checking and creating new errors
3. **Idiomatic Code**: Write clear, idiomatic Go code following Effective Go guidelines
4. **Simplicity**: Favor simplicity and clarity over cleverness
5. **Composition**: Prefer composition over inheritance
6. **Explicit**: Be explicit rather than implicit
## Key Go Concepts
### Functions with Named Returns
Always use named return values:
```go
func divide(a, b float64) (result float64, err error) {
if b == 0 {
err = errorf.New("division by zero")
return
}
result = a / b
return
}
```
### Error Handling
Use the specified error handling packages:
```go
import "lol.mleku.dev/log"
// Error checking with chk
if err := doSomething(); chk.E(err) {
return
}
// Creating errors with errorf
err := errorf.New("something went wrong")
err := errorf.Errorf("failed to process: %v", value)
```
### Interfaces and Composition
Go uses implicit interface implementation:
```go
type Reader interface {
Read(p []byte) (n int, err error)
}
// Any type with a Read method implements Reader
type File struct {
name string
}
func (f *File) Read(p []byte) (n int, err error) {
// Implementation
return
}
```
### Concurrency
Use goroutines and channels for concurrent programming:
```go
// Launch goroutine
go doWork()
// Channels
ch := make(chan int, 10)
ch <- 42
value := <-ch
// Select statement
select {
case msg := <-ch1:
// Handle
case <-time.After(time.Second):
// Timeout
}
// Sync primitives
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
```
### Testing
Use table-driven tests as the default pattern:
```go
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -1, -1, -2},
{"zero", 0, 5, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
```
## Reference Materials
For detailed information, consult the reference files:
- **references/effective-go-summary.md** - Key points from Effective Go including formatting, naming, control structures, functions, data allocation, methods, interfaces, concurrency principles, and error handling philosophy
- **references/common-patterns.md** - Practical Go patterns including:
- Design patterns (Functional Options, Builder, Singleton, Factory, Strategy)
- Concurrency patterns (Worker Pool, Pipeline, Fan-Out/Fan-In, Timeout, Rate Limiting, Circuit Breaker)
- Error handling patterns (Error Wrapping, Sentinel Errors, Custom Error Types)
- Resource management patterns
- Testing patterns
- **references/quick-reference.md** - Quick syntax cheatsheet with common commands, format verbs, standard library snippets, and best practices checklist
## Best Practices Summary
1. **Naming Conventions**
- Use camelCase for variables and functions
- Use PascalCase for exported names
- Keep names short but descriptive
- Interface names often end in -er (Reader, Writer, Handler)
2. **Error Handling**
- Always check errors
- Use named return values
- Use lol.mleku.dev/log and chk/errorf
3. **Code Organization**
- One package per directory
- Use internal/ for non-exported packages
- Use cmd/ for applications
- Use pkg/ for reusable libraries
4. **Concurrency**
- Don't communicate by sharing memory; share memory by communicating
- Always close channels from sender
- Use defer for cleanup
5. **Documentation**
- Comment all exported names
- Start comments with the name being described
- Use godoc format
## Common Commands
```bash
go run main.go # Run program
go build # Compile
go test # Run tests
go test -v # Verbose tests
go test -cover # Test coverage
go test -race # Race detection
go fmt # Format code
go vet # Lint code
go mod tidy # Clean dependencies
go get package # Add dependency
```
## Official Resources
All guidance is based on official Go documentation:
- Go Website: https://go.dev
- Documentation: https://go.dev/doc/
- Effective Go: https://go.dev/doc/effective_go
- Language Specification: https://go.dev/ref/spec
- Standard Library: https://pkg.go.dev/std
- Go Tour: https://go.dev/tour/

View File

@@ -0,0 +1,649 @@
# Go Common Patterns and Idioms
## Design Patterns
### Functional Options Pattern
Used for configuring objects with many optional parameters:
```go
type Server struct {
host string
port int
timeout time.Duration
maxConn int
}
type Option func(*Server)
func WithHost(host string) Option {
return func(s *Server) {
s.host = host
}
}
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeout(timeout time.Duration) Option {
return func(s *Server) {
s.timeout = timeout
}
}
func NewServer(opts ...Option) *Server {
// Set defaults
s := &Server{
host: "localhost",
port: 8080,
timeout: 30 * time.Second,
maxConn: 100,
}
// Apply options
for _, opt := range opts {
opt(s)
}
return s
}
// Usage
srv := NewServer(
WithHost("example.com"),
WithPort(443),
WithTimeout(60 * time.Second),
)
```
### Builder Pattern
For complex object construction:
```go
type HTTPRequest struct {
method string
url string
headers map[string]string
body []byte
}
type RequestBuilder struct {
request *HTTPRequest
}
func NewRequestBuilder() *RequestBuilder {
return &RequestBuilder{
request: &HTTPRequest{
headers: make(map[string]string),
},
}
}
func (b *RequestBuilder) Method(method string) *RequestBuilder {
b.request.method = method
return b
}
func (b *RequestBuilder) URL(url string) *RequestBuilder {
b.request.url = url
return b
}
func (b *RequestBuilder) Header(key, value string) *RequestBuilder {
b.request.headers[key] = value
return b
}
func (b *RequestBuilder) Body(body []byte) *RequestBuilder {
b.request.body = body
return b
}
func (b *RequestBuilder) Build() *HTTPRequest {
return b.request
}
// Usage
req := NewRequestBuilder().
Method("POST").
URL("https://api.example.com").
Header("Content-Type", "application/json").
Body([]byte(`{"key":"value"}`)).
Build()
```
### Singleton Pattern
Thread-safe singleton using sync.Once:
```go
type Database struct {
conn *sql.DB
}
var (
instance *Database
once sync.Once
)
func GetDatabase() *Database {
once.Do(func() {
conn, err := sql.Open("postgres", "connection-string")
if err != nil {
log.Fatal(err)
}
instance = &Database{conn: conn}
})
return instance
}
```
### Factory Pattern
```go
type Animal interface {
Speak() string
}
type Dog struct{}
func (d Dog) Speak() string { return "Woof!" }
type Cat struct{}
func (c Cat) Speak() string { return "Meow!" }
type AnimalFactory struct{}
func (f *AnimalFactory) CreateAnimal(animalType string) Animal {
switch animalType {
case "dog":
return &Dog{}
case "cat":
return &Cat{}
default:
return nil
}
}
```
### Strategy Pattern
```go
type PaymentStrategy interface {
Pay(amount float64) error
}
type CreditCard struct {
number string
}
func (c *CreditCard) Pay(amount float64) error {
fmt.Printf("Paying %.2f using credit card %s\n", amount, c.number)
return nil
}
type PayPal struct {
email string
}
func (p *PayPal) Pay(amount float64) error {
fmt.Printf("Paying %.2f using PayPal account %s\n", amount, p.email)
return nil
}
type PaymentContext struct {
strategy PaymentStrategy
}
func (pc *PaymentContext) SetStrategy(strategy PaymentStrategy) {
pc.strategy = strategy
}
func (pc *PaymentContext) ExecutePayment(amount float64) error {
return pc.strategy.Pay(amount)
}
```
## Concurrency Patterns
### Worker Pool
```go
func worker(id int, jobs <-chan Job, results chan<- Result) {
for job := range jobs {
result := processJob(job)
results <- result
}
}
func WorkerPool(numWorkers int, jobs []Job) []Result {
jobsChan := make(chan Job, len(jobs))
results := make(chan Result, len(jobs))
// Start workers
for w := 1; w <= numWorkers; w++ {
go worker(w, jobsChan, results)
}
// Send jobs
for _, job := range jobs {
jobsChan <- job
}
close(jobsChan)
// Collect results
var output []Result
for range jobs {
output = append(output, <-results)
}
return output
}
```
### Pipeline Pattern
```go
func generator(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Create pipeline
c := generator(2, 3, 4)
out := square(c)
// Consume output
for result := range out {
fmt.Println(result)
}
}
```
### Fan-Out, Fan-In
```go
func fanOut(in <-chan int, n int) []<-chan int {
channels := make([]<-chan int, n)
for i := 0; i < n; i++ {
channels[i] = worker(in)
}
return channels
}
func worker(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- expensiveOperation(n)
}
close(out)
}()
return out
}
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(channels))
for _, c := range channels {
go func(ch <-chan int) {
defer wg.Done()
for n := range ch {
out <- n
}
}(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
```
### Timeout Pattern
```go
func DoWithTimeout(timeout time.Duration) (result string, err error) {
done := make(chan struct{})
go func() {
result = expensiveOperation()
close(done)
}()
select {
case <-done:
return result, nil
case <-time.After(timeout):
return "", fmt.Errorf("operation timed out after %v", timeout)
}
}
```
### Graceful Shutdown
```go
func main() {
server := &http.Server{Addr: ":8080"}
// Start server in goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exiting")
}
```
### Rate Limiting
```go
func rateLimiter(rate time.Duration) <-chan time.Time {
return time.Tick(rate)
}
func main() {
limiter := rateLimiter(200 * time.Millisecond)
for req := range requests {
<-limiter // Wait for rate limiter
go handleRequest(req)
}
}
```
### Circuit Breaker
```go
type CircuitBreaker struct {
maxFailures int
timeout time.Duration
failures int
lastFail time.Time
state string
mu sync.Mutex
}
func (cb *CircuitBreaker) Call(fn func() error) error {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state == "open" {
if time.Since(cb.lastFail) > cb.timeout {
cb.state = "half-open"
} else {
return fmt.Errorf("circuit breaker is open")
}
}
err := fn()
if err != nil {
cb.failures++
cb.lastFail = time.Now()
if cb.failures >= cb.maxFailures {
cb.state = "open"
}
return err
}
cb.failures = 0
cb.state = "closed"
return nil
}
```
## Error Handling Patterns
### Error Wrapping
```go
func processFile(filename string) (err error) {
data, err := readFile(filename)
if err != nil {
return fmt.Errorf("failed to process file %s: %w", filename, err)
}
if err := validate(data); err != nil {
return fmt.Errorf("validation failed for %s: %w", filename, err)
}
return nil
}
```
### Sentinel Errors
```go
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
func FindUser(id int) (*User, error) {
user, exists := users[id]
if !exists {
return nil, ErrNotFound
}
return user, nil
}
// Check error
user, err := FindUser(123)
if errors.Is(err, ErrNotFound) {
// Handle not found
}
```
### Custom Error Types
```go
type ValidationError struct {
Field string
Value interface{}
Err error
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for field %s with value %v: %v",
e.Field, e.Value, e.Err)
}
func (e *ValidationError) Unwrap() error {
return e.Err
}
// Usage
var validErr *ValidationError
if errors.As(err, &validErr) {
fmt.Printf("Field: %s\n", validErr.Field)
}
```
## Resource Management Patterns
### Defer for Cleanup
```go
func processFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
// Process file
return nil
}
```
### Context for Cancellation
```go
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
```
### Sync.Pool for Object Reuse
```go
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func process() {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset()
// Use buffer
}
```
## Testing Patterns
### Table-Driven Tests
```go
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -1, -2},
{"mixed signs", -5, 10, 5},
{"zeros", 0, 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
```
### Mock Interfaces
```go
type Database interface {
Get(key string) (string, error)
Set(key, value string) error
}
type MockDB struct {
data map[string]string
}
func (m *MockDB) Get(key string) (string, error) {
val, ok := m.data[key]
if !ok {
return "", errors.New("not found")
}
return val, nil
}
func (m *MockDB) Set(key, value string) error {
m.data[key] = value
return nil
}
func TestUserService(t *testing.T) {
mockDB := &MockDB{data: make(map[string]string)}
service := NewUserService(mockDB)
// Test service
}
```
### Test Fixtures
```go
func setupTestDB(t *testing.T) (*sql.DB, func()) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
// Setup schema
_, err = db.Exec(schema)
if err != nil {
t.Fatal(err)
}
cleanup := func() {
db.Close()
}
return db, cleanup
}
func TestDatabase(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
// Run tests
}
```

View File

@@ -0,0 +1,423 @@
# Effective Go - Key Points Summary
Source: https://go.dev/doc/effective_go
## Formatting
- Use `gofmt` to automatically format your code
- Indentation: use tabs
- Line length: no strict limit, but keep reasonable
- Parentheses: Go uses fewer parentheses than C/Java
## Commentary
- Every package should have a package comment
- Every exported name should have a doc comment
- Comments should be complete sentences
- Start comments with the name of the element being described
Example:
```go
// Package regexp implements regular expression search.
package regexp
// Compile parses a regular expression and returns, if successful,
// a Regexp object that can be used to match against text.
func Compile(str string) (*Regexp, error) {
```
## Names
### Package Names
- Short, concise, evocative
- Lowercase, single-word
- No underscores or mixedCaps
- Avoid stuttering (e.g., `bytes.Buffer` not `bytes.ByteBuffer`)
### Getters/Setters
- Getter: `Owner()` not `GetOwner()`
- Setter: `SetOwner()`
### Interface Names
- One-method interfaces use method name + -er suffix
- Examples: `Reader`, `Writer`, `Formatter`, `CloseNotifier`
### MixedCaps
- Use `MixedCaps` or `mixedCaps` rather than underscores
## Semicolons
- Lexer automatically inserts semicolons
- Never put opening brace on its own line
## Control Structures
### If
```go
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}
```
### Redeclaration
```go
f, err := os.Open(name)
// err is declared here
d, err := f.Stat()
// err is redeclared here (same scope)
```
### For
```go
// Like a C for
for init; condition; post { }
// Like a C while
for condition { }
// Like a C for(;;)
for { }
// Range over array/slice/map/channel
for key, value := range oldMap {
newMap[key] = value
}
// If you only need the key
for key := range m {
// ...
}
// If you only need the value
for _, value := range array {
// ...
}
```
### Switch
- No automatic fall through
- Cases can be expressions
- Can switch on no value (acts like if-else chain)
```go
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
```
### Type Switch
```go
switch t := value.(type) {
case int:
fmt.Printf("int: %d\n", t)
case string:
fmt.Printf("string: %s\n", t)
default:
fmt.Printf("unexpected type %T\n", t)
}
```
## Functions
### Multiple Return Values
```go
func (file *File) Write(b []byte) (n int, err error) {
// ...
}
```
### Named Result Parameters
- Named results are initialized to zero values
- Can be used for documentation
- Enable naked returns
```go
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}
```
### Defer
- Schedules function call to run after surrounding function returns
- LIFO order
- Arguments evaluated when defer executes
```go
func trace(s string) string {
fmt.Println("entering:", s)
return s
}
func un(s string) {
fmt.Println("leaving:", s)
}
func a() {
defer un(trace("a"))
fmt.Println("in a")
}
```
## Data
### Allocation with new
- `new(T)` allocates zeroed storage for new item of type T
- Returns `*T`
- Returns memory address of newly allocated zero value
```go
p := new(int) // p is *int, points to zeroed int
```
### Constructors and Composite Literals
```go
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
return &File{fd: fd, name: name}
}
```
### Allocation with make
- `make(T, args)` creates slices, maps, and channels only
- Returns initialized (not zeroed) value of type T (not *T)
```go
make([]int, 10, 100) // slice: len=10, cap=100
make(map[string]int) // map
make(chan int, 10) // buffered channel
```
### Arrays
- Arrays are values, not pointers
- Passing array to function copies the entire array
- Array size is part of its type
### Slices
- Hold references to underlying array
- Can grow dynamically with `append`
- Passing slice passes reference
### Maps
- Hold references to underlying data structure
- Passing map passes reference
- Zero value is `nil`
### Printing
- `%v` - default format
- `%+v` - struct with field names
- `%#v` - Go syntax representation
- `%T` - type
- `%q` - quoted string
## Initialization
### Constants
- Created at compile time
- Can only be numbers, characters, strings, or booleans
### init Function
- Each source file can have `init()` function
- Called after package-level variables initialized
- Used for setup that can't be expressed as declarations
```go
func init() {
// initialization code
}
```
## Methods
### Pointers vs. Values
- Value methods can be invoked on pointers and values
- Pointer methods can only be invoked on pointers
Rule: Value methods can be called on both values and pointers, but pointer methods should only be called on pointers (though Go allows calling on addressable values).
```go
type ByteSlice []byte
func (slice ByteSlice) Append(data []byte) []byte {
// ...
}
func (p *ByteSlice) Append(data []byte) {
slice := *p
// ...
*p = slice
}
```
## Interfaces and Other Types
### Interfaces
- A type implements an interface by implementing its methods
- No explicit declaration of intent
### Type Assertions
```go
value, ok := str.(string)
```
### Type Switches
```go
switch v := value.(type) {
case string:
// v is string
case int:
// v is int
}
```
### Generality
- If a type exists only to implement an interface and will never have exported methods beyond that interface, there's no need to export the type itself
## The Blank Identifier
### Unused Imports and Variables
```go
import _ "net/http/pprof" // Import for side effects
```
### Interface Checks
```go
var _ json.Marshaler = (*RawMessage)(nil)
```
## Embedding
### Composition, not Inheritance
```go
type ReadWriter struct {
*Reader // *bufio.Reader
*Writer // *bufio.Writer
}
```
## Concurrency
### Share by Communicating
- Don't communicate by sharing memory; share memory by communicating
- Use channels to pass ownership
### Goroutines
- Cheap: small initial stack
- Multiplexed onto OS threads
- Prefix function call with `go` keyword
### Channels
- Allocate with `make`
- Unbuffered: synchronous
- Buffered: asynchronous up to buffer size
```go
ci := make(chan int) // unbuffered
cj := make(chan int, 0) // unbuffered
cs := make(chan *os.File, 100) // buffered
```
### Channels of Channels
```go
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
```
### Parallelization
```go
const numCPU = runtime.NumCPU()
runtime.GOMAXPROCS(numCPU)
```
## Errors
### Error Type
```go
type error interface {
Error() string
}
```
### Custom Errors
```go
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
```
### Panic
- Use for unrecoverable errors
- Generally avoid in library code
### Recover
- Called inside deferred function
- Stops panic sequence
- Returns value passed to panic
```go
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
```
## A Web Server Example
```go
package main
import (
"fmt"
"log"
"net/http"
)
type Counter struct {
n int
}
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctr.n++
fmt.Fprintf(w, "counter = %d\n", ctr.n)
}
func main() {
ctr := new(Counter)
http.Handle("/counter", ctr)
log.Fatal(http.ListenAndServe(":8080", nil))
}
```

View File

@@ -0,0 +1,528 @@
# Go Quick Reference Cheat Sheet
## Basic Syntax
### Hello World
```go
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
```
### Variables
```go
var name string = "John"
var age int = 30
var height = 5.9 // type inference
// Short declaration (inside functions only)
count := 42
```
### Constants
```go
const Pi = 3.14159
const (
Sunday = iota // 0
Monday // 1
Tuesday // 2
)
```
## Data Types
### Basic Types
```go
bool // true, false
string // "hello"
int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64
byte // alias for uint8
rune // alias for int32 (Unicode)
float32 float64
complex64 complex128
```
### Composite Types
```go
// Array (fixed size)
var arr [5]int
// Slice (dynamic)
slice := []int{1, 2, 3}
slice = append(slice, 4)
// Map
m := make(map[string]int)
m["key"] = 42
// Struct
type Person struct {
Name string
Age int
}
p := Person{Name: "Alice", Age: 30}
// Pointer
ptr := &p
```
## Functions
```go
// Basic function
func add(a, b int) int {
return a + b
}
// Named returns (preferred)
func divide(a, b float64) (result float64, err error) {
if b == 0 {
err = errors.New("division by zero")
return
}
result = a / b
return
}
// Variadic
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// Multiple returns
func swap(a, b int) (int, int) {
return b, a
}
```
## Control Flow
### If/Else
```go
if x > 0 {
// positive
} else if x < 0 {
// negative
} else {
// zero
}
// With initialization
if err := doSomething(); err != nil {
return err
}
```
### For Loops
```go
// Traditional for
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// While-style
for condition {
}
// Infinite
for {
}
// Range
for i, v := range slice {
fmt.Printf("%d: %v\n", i, v)
}
for key, value := range myMap {
fmt.Printf("%s: %v\n", key, value)
}
```
### Switch
```go
switch x {
case 1:
fmt.Println("one")
case 2, 3:
fmt.Println("two or three")
default:
fmt.Println("other")
}
// Type switch
switch v := i.(type) {
case int:
fmt.Printf("int: %d\n", v)
case string:
fmt.Printf("string: %s\n", v)
}
```
## Methods & Interfaces
### Methods
```go
type Rectangle struct {
Width, Height float64
}
// Value receiver
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// Pointer receiver
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
```
### Interfaces
```go
type Shape interface {
Area() float64
Perimeter() float64
}
// Empty interface (any type)
var x interface{} // or: var x any
```
## Concurrency
### Goroutines
```go
go doSomething()
go func() {
fmt.Println("In goroutine")
}()
```
### Channels
```go
// Create
ch := make(chan int) // unbuffered
ch := make(chan int, 10) // buffered
// Send & Receive
ch <- 42 // send
value := <-ch // receive
// Close
close(ch)
// Check if closed
value, ok := <-ch
```
### Select
```go
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
case <-time.After(1 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("no channel ready")
}
```
### Sync Package
```go
// Mutex
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// RWMutex
var mu sync.RWMutex
mu.RLock()
defer mu.RUnlock()
// WaitGroup
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// work
}()
wg.Wait()
```
## Error Handling
```go
// Create errors
err := errors.New("error message")
err := fmt.Errorf("failed: %w", originalErr)
// Check errors
if err != nil {
return err
}
// Custom error type
type MyError struct {
Msg string
}
func (e *MyError) Error() string {
return e.Msg
}
// Error checking (Go 1.13+)
if errors.Is(err, os.ErrNotExist) {
// handle
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// handle
}
```
## Standard Library Snippets
### fmt - Formatting
```go
fmt.Print("text")
fmt.Println("text with newline")
fmt.Printf("Name: %s, Age: %d\n", name, age)
s := fmt.Sprintf("formatted %v", value)
```
### strings
```go
strings.Contains(s, substr)
strings.HasPrefix(s, prefix)
strings.Join([]string{"a", "b"}, ",")
strings.Split(s, ",")
strings.ToLower(s)
strings.TrimSpace(s)
```
### strconv
```go
i, _ := strconv.Atoi("42")
s := strconv.Itoa(42)
f, _ := strconv.ParseFloat("3.14", 64)
```
### io
```go
io.Copy(dst, src)
data, _ := io.ReadAll(r)
io.WriteString(w, "data")
```
### os
```go
file, _ := os.Open("file.txt")
defer file.Close()
os.Getenv("PATH")
os.Exit(1)
```
### net/http
```go
// Server
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
// Client
resp, _ := http.Get("https://example.com")
defer resp.Body.Close()
```
### encoding/json
```go
// Encode
data, _ := json.Marshal(obj)
// Decode
json.Unmarshal(data, &obj)
```
### time
```go
now := time.Now()
time.Sleep(5 * time.Second)
t.Format("2006-01-02 15:04:05")
time.Parse("2006-01-02", "2024-01-01")
```
## Testing
### Basic Test
```go
// mycode_test.go
package mypackage
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("got %d, want 5", result)
}
}
```
### Table-Driven Test
```go
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -1, -1, -2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
```
### Benchmark
```go
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
```
## Go Commands
```bash
# Run
go run main.go
# Build
go build
go build -o myapp
# Test
go test
go test -v
go test -cover
go test -race
# Format
go fmt ./...
gofmt -s -w .
# Lint
go vet ./...
# Modules
go mod init module-name
go mod tidy
go get package@version
go get -u ./...
# Install
go install
# Documentation
go doc package.Function
```
## Common Patterns
### Defer
```go
file, err := os.Open("file.txt")
if err != nil {
return err
}
defer file.Close()
```
### Error Wrapping
```go
if err != nil {
return fmt.Errorf("failed to process: %w", err)
}
```
### Context
```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
```
### Options Pattern
```go
type Option func(*Config)
func WithPort(port int) Option {
return func(c *Config) {
c.port = port
}
}
func New(opts ...Option) *Server {
cfg := &Config{port: 8080}
for _, opt := range opts {
opt(cfg)
}
return &Server{cfg: cfg}
}
```
## Format Verbs
```go
%v // default format
%+v // struct with field names
%#v // Go-syntax representation
%T // type
%t // bool
%d // decimal integer
%b // binary
%o // octal
%x // hex (lowercase)
%X // hex (uppercase)
%f // float
%e // scientific notation
%s // string
%q // quoted string
%p // pointer address
%w // error wrapping
```
## Best Practices
1. Use `gofmt` to format code
2. Always check errors
3. Use named return values
4. Prefer composition over inheritance
5. Use defer for cleanup
6. Keep functions small and focused
7. Write table-driven tests
8. Document exported names
9. Use interfaces for flexibility
10. Follow Effective Go guidelines

286
.claude/skills/ndk/INDEX.md Normal file
View File

@@ -0,0 +1,286 @@
# NDK (Nostr Development Kit) Claude Skill
> **Comprehensive knowledge base for working with NDK in production applications**
This Claude skill provides deep expertise in the Nostr Development Kit based on real-world usage patterns from the Plebeian Market application.
## 📚 Documentation Structure
```
.claude/skills/ndk/
├── README.md # This file - Overview and getting started
├── ndk-skill.md # Complete reference guide (18KB)
├── quick-reference.md # Fast lookup for common tasks (7KB)
├── troubleshooting.md # Common problems and solutions
└── examples/ # Production code examples
├── README.md
├── 01-initialization.ts # NDK setup and connection
├── 02-authentication.ts # NIP-07, NIP-46, private keys
├── 03-publishing-events.ts # Creating and publishing events
├── 04-querying-subscribing.ts # Fetching and real-time subs
└── 05-users-profiles.ts # User and profile management
```
## 🚀 Quick Start
### For Quick Lookups
Start with **`quick-reference.md`** for:
- Common code snippets
- Quick syntax reminders
- Frequently used patterns
### For Deep Learning
Read **`ndk-skill.md`** for:
- Complete API documentation
- Best practices
- Integration patterns
- Performance optimization
### For Problem Solving
Check **`troubleshooting.md`** for:
- Common error solutions
- Performance tips
- Testing strategies
- Debug techniques
### For Code Examples
Browse **`examples/`** directory for:
- Real production code
- Full implementations
- React integration patterns
- Error handling examples
## 📖 Core Topics Covered
### 1. Initialization & Setup
- Basic NDK initialization
- Multiple instance patterns (main + zap relays)
- Connection management with timeouts
- Relay pool configuration
- Connection status monitoring
### 2. Authentication
- **NIP-07**: Browser extension signers (Alby, nos2x)
- **NIP-46**: Remote signers (Bunker)
- **Private Keys**: Direct key management
- Auto-login with localStorage
- Multi-account session management
### 3. Event Publishing
- Basic text notes
- Parameterized replaceable events (products, profiles)
- Order and payment events
- Batch publishing
- Error handling patterns
### 4. Querying & Subscriptions
- One-time fetches with `fetchEvents()`
- Real-time subscriptions
- Tag filtering patterns
- Time-range queries
- Event monitoring
- React Query integration
### 5. User & Profile Management
- Fetch profiles (npub, hex, NIP-05)
- Update user profiles
- Follow/unfollow operations
- Batch profile loading
- Profile caching strategies
### 6. Advanced Patterns
- Store-based NDK management
- Query + subscription combination
- Event parsing utilities
- Memory leak prevention
- Performance optimization
## 🎯 Use Cases
### Building a Nostr Client
```typescript
// Initialize
const { ndk, isConnected } = await initializeNDK({
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
timeoutMs: 10000
})
// Authenticate
const { user } = await loginWithExtension(ndk)
// Publish
await publishBasicNote(ndk, 'Hello Nostr!')
// Subscribe
const sub = subscribeToNotes(ndk, user.pubkey, (event) => {
console.log('New note:', event.content)
})
```
### Building a Marketplace
```typescript
// Publish product
await publishProduct(ndk, {
slug: 'bitcoin-shirt',
title: 'Bitcoin T-Shirt',
price: 25,
currency: 'USD',
images: ['https://...']
})
// Create order
await createOrder(ndk, {
orderId: uuidv4(),
sellerPubkey: merchant.pubkey,
productRef: '30402:pubkey:bitcoin-shirt',
quantity: 1,
totalAmount: '25.00'
})
// Monitor payment
monitorPaymentReceipt(ndk, orderId, invoiceId, (preimage) => {
console.log('Payment confirmed!')
})
```
### React Integration
```typescript
function Feed() {
const ndk = useNDK()
const { user } = useAuth()
// Query with real-time updates
const { data: notes } = useNotesWithSubscription(
ndk,
user.pubkey
)
return (
<div>
{notes?.map(note => (
<NoteCard key={note.id} note={note} />
))}
</div>
)
}
```
## 🔍 Common Patterns Quick Reference
### Safe NDK Access
```typescript
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
```
### Subscription Cleanup
```typescript
useEffect(() => {
const sub = ndk.subscribe(filter, { closeOnEose: false })
sub.on('event', handleEvent)
return () => sub.stop() // Critical!
}, [ndk])
```
### Error Handling
```typescript
try {
await event.sign()
await event.publish()
} catch (error) {
console.error('Publishing failed:', error)
throw new Error('Failed to publish. Check connection.')
}
```
### Tag Filtering
```typescript
// ✅ Correct (note the # prefix for tag filters)
{ kinds: [16], '#order': [orderId] }
// ❌ Wrong
{ kinds: [16], 'order': [orderId] }
```
## 🛠 Development Tools
### VS Code Integration
These skill files work with:
- Cursor AI for code completion
- Claude for code assistance
- GitHub Copilot with context
### Debugging Tips
```typescript
// Check connection
console.log('Connected relays:',
Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1)
.map(r => r.url)
)
// Verify signer
console.log('Signer:', ndk.signer)
console.log('Active user:', ndk.activeUser)
// Event inspection
console.log('Event:', {
id: event.id,
kind: event.kind,
tags: event.tags,
sig: event.sig
})
```
## 📊 Statistics
- **Total Documentation**: ~50KB
- **Code Examples**: 5 complete modules
- **Patterns Documented**: 50+
- **Common Issues Covered**: 15+
- **Based On**: Real production code
## 🔗 Additional Resources
### Official NDK Resources
- **GitHub**: https://github.com/nostr-dev-kit/ndk
- **Documentation**: https://ndk.fyi
- **NPM**: `@nostr-dev-kit/ndk`
### Nostr Protocol
- **NIPs**: https://github.com/nostr-protocol/nips
- **Nostr**: https://nostr.com
### Related Tools
- **TanStack Query**: React state management
- **TanStack Router**: Type-safe routing
- **Radix UI**: Accessible components
## 💡 Tips for Using This Skill
1. **Start Small**: Begin with quick-reference.md for syntax
2. **Go Deep**: Read ndk-skill.md section by section
3. **Copy Examples**: Use examples/ as templates
4. **Debug Issues**: Check troubleshooting.md first
5. **Stay Updated**: Patterns based on production usage
## 🤝 Contributing
This skill is maintained based on the Plebeian Market codebase. To improve it:
1. Document new patterns you discover
2. Add solutions to common problems
3. Update examples with better approaches
4. Keep synchronized with NDK updates
## 📝 Version Info
- **Skill Version**: 1.0.0
- **NDK Version**: Latest (based on production usage)
- **Last Updated**: November 2025
- **Codebase**: Plebeian Market
---
**Ready to build with NDK?** Start with `quick-reference.md` or dive into `examples/01-initialization.ts`!

View File

@@ -0,0 +1,38 @@
# NDK (Nostr Development Kit) Claude Skill
This skill provides comprehensive knowledge about working with the Nostr Development Kit (NDK) library.
## Files
- **ndk-skill.md** - Complete reference documentation with patterns from production usage
- **quick-reference.md** - Quick lookup guide for common NDK tasks
- **examples/** - Code examples extracted from the Plebeian Market codebase
## Usage
When working with NDK-related code, reference these documents to:
- Understand initialization patterns
- Learn authentication flows (NIP-07, NIP-46, private keys)
- Implement event creation and publishing
- Set up subscriptions for real-time updates
- Query events with filters
- Handle users and profiles
- Integrate with TanStack Query
## Key Topics Covered
1. NDK Initialization & Configuration
2. Authentication & Signers
3. Event Creation & Publishing
4. Querying Events
5. Real-time Subscriptions
6. User & Profile Management
7. Tag Handling
8. Replaceable Events
9. Relay Management
10. Integration with React/TanStack Query
11. Error Handling & Best Practices
12. Performance Optimization
All examples are based on real production code from the Plebeian Market application.

View File

@@ -0,0 +1,162 @@
/**
* NDK Initialization Patterns
*
* Examples from: src/lib/stores/ndk.ts
*/
import NDK from '@nostr-dev-kit/ndk'
// ============================================================
// BASIC INITIALIZATION
// ============================================================
const basicInit = () => {
const ndk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io', 'wss://relay.nostr.band']
})
return ndk
}
// ============================================================
// PRODUCTION PATTERN - WITH MULTIPLE NDK INSTANCES
// ============================================================
const productionInit = (relays: string[], zapRelays: string[]) => {
// Main NDK instance for general operations
const ndk = new NDK({
explicitRelayUrls: relays
})
// Separate NDK for zap operations (performance optimization)
const zapNdk = new NDK({
explicitRelayUrls: zapRelays
})
return { ndk, zapNdk }
}
// ============================================================
// CONNECTION WITH TIMEOUT
// ============================================================
const connectWithTimeout = async (
ndk: NDK,
timeoutMs: number = 10000
): Promise<void> => {
// Create connection promise
const connectPromise = ndk.connect()
// Create timeout promise
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), timeoutMs)
)
try {
// Race between connection and timeout
await Promise.race([connectPromise, timeoutPromise])
console.log('✅ NDK connected successfully')
} catch (error) {
if (error instanceof Error && error.message === 'Connection timeout') {
console.error('❌ Connection timed out after', timeoutMs, 'ms')
} else {
console.error('❌ Connection failed:', error)
}
throw error
}
}
// ============================================================
// FULL INITIALIZATION FLOW
// ============================================================
interface InitConfig {
relays?: string[]
zapRelays?: string[]
timeoutMs?: number
}
const defaultRelays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol'
]
const defaultZapRelays = [
'wss://relay.damus.io',
'wss://nostr.wine'
]
const initializeNDK = async (config: InitConfig = {}) => {
const {
relays = defaultRelays,
zapRelays = defaultZapRelays,
timeoutMs = 10000
} = config
// Initialize instances
const ndk = new NDK({ explicitRelayUrls: relays })
const zapNdk = new NDK({ explicitRelayUrls: zapRelays })
// Connect with timeout protection
try {
await connectWithTimeout(ndk, timeoutMs)
await connectWithTimeout(zapNdk, timeoutMs)
return { ndk, zapNdk, isConnected: true }
} catch (error) {
return { ndk, zapNdk, isConnected: false, error }
}
}
// ============================================================
// CHECKING CONNECTION STATUS
// ============================================================
const getConnectionStatus = (ndk: NDK) => {
const connectedRelays = Array.from(ndk.pool?.relays.values() || [])
.filter(relay => relay.status === 1)
.map(relay => relay.url)
const isConnected = connectedRelays.length > 0
return {
isConnected,
connectedRelays,
totalRelays: ndk.pool?.relays.size || 0
}
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function main() {
// Initialize
const { ndk, zapNdk, isConnected } = await initializeNDK({
relays: defaultRelays,
zapRelays: defaultZapRelays,
timeoutMs: 10000
})
if (!isConnected) {
console.error('Failed to connect to relays')
return
}
// Check status
const status = getConnectionStatus(ndk)
console.log('Connection status:', status)
// Ready to use
console.log('NDK ready for operations')
}
export {
basicInit,
productionInit,
connectWithTimeout,
initializeNDK,
getConnectionStatus
}

View File

@@ -0,0 +1,255 @@
/**
* NDK Authentication Patterns
*
* Examples from: src/lib/stores/auth.ts
*/
import NDK from '@nostr-dev-kit/ndk'
import { NDKNip07Signer, NDKPrivateKeySigner, NDKNip46Signer } from '@nostr-dev-kit/ndk'
// ============================================================
// NIP-07 - BROWSER EXTENSION SIGNER
// ============================================================
const loginWithExtension = async (ndk: NDK) => {
try {
// Create NIP-07 signer (browser extension like Alby, nos2x)
const signer = new NDKNip07Signer()
// Wait for signer to be ready
await signer.blockUntilReady()
// Set signer on NDK instance
ndk.signer = signer
// Get authenticated user
const user = await signer.user()
console.log('✅ Logged in via extension:', user.npub)
return { user, signer }
} catch (error) {
console.error('❌ Extension login failed:', error)
throw new Error('Failed to login with browser extension. Is it installed?')
}
}
// ============================================================
// PRIVATE KEY SIGNER
// ============================================================
const loginWithPrivateKey = async (ndk: NDK, privateKeyHex: string) => {
try {
// Validate private key format (64 hex characters)
if (!/^[0-9a-f]{64}$/.test(privateKeyHex)) {
throw new Error('Invalid private key format')
}
// Create private key signer
const signer = new NDKPrivateKeySigner(privateKeyHex)
// Wait for signer to be ready
await signer.blockUntilReady()
// Set signer on NDK instance
ndk.signer = signer
// Get authenticated user
const user = await signer.user()
console.log('✅ Logged in with private key:', user.npub)
return { user, signer }
} catch (error) {
console.error('❌ Private key login failed:', error)
throw error
}
}
// ============================================================
// NIP-46 - REMOTE SIGNER (BUNKER)
// ============================================================
const loginWithNip46 = async (
ndk: NDK,
bunkerUrl: string,
localPrivateKey?: string
) => {
try {
// Create or use existing local signer
const localSigner = localPrivateKey
? new NDKPrivateKeySigner(localPrivateKey)
: NDKPrivateKeySigner.generate()
// Create NIP-46 remote signer
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
// Wait for signer to be ready (may require user approval)
await remoteSigner.blockUntilReady()
// Set signer on NDK instance
ndk.signer = remoteSigner
// Get authenticated user
const user = await remoteSigner.user()
console.log('✅ Logged in via NIP-46:', user.npub)
// Store local signer key for reconnection
return {
user,
signer: remoteSigner,
localSignerKey: localSigner.privateKey
}
} catch (error) {
console.error('❌ NIP-46 login failed:', error)
throw error
}
}
// ============================================================
// AUTO-LOGIN FROM LOCAL STORAGE
// ============================================================
const STORAGE_KEYS = {
AUTO_LOGIN: 'nostr:auto-login',
LOCAL_SIGNER: 'nostr:local-signer',
BUNKER_URL: 'nostr:bunker-url',
ENCRYPTED_KEY: 'nostr:encrypted-key'
}
const getAuthFromStorage = async (ndk: NDK) => {
try {
// Check if auto-login is enabled
const autoLogin = localStorage.getItem(STORAGE_KEYS.AUTO_LOGIN)
if (autoLogin !== 'true') {
return null
}
// Try NIP-46 bunker connection
const privateKey = localStorage.getItem(STORAGE_KEYS.LOCAL_SIGNER)
const bunkerUrl = localStorage.getItem(STORAGE_KEYS.BUNKER_URL)
if (privateKey && bunkerUrl) {
return await loginWithNip46(ndk, bunkerUrl, privateKey)
}
// Try encrypted private key
const encryptedKey = localStorage.getItem(STORAGE_KEYS.ENCRYPTED_KEY)
if (encryptedKey) {
// Would need decryption password from user
return { needsPassword: true, encryptedKey }
}
// Fallback to extension
return await loginWithExtension(ndk)
} catch (error) {
console.error('Auto-login failed:', error)
return null
}
}
// ============================================================
// SAVE AUTH TO STORAGE
// ============================================================
const saveAuthToStorage = (
method: 'extension' | 'private-key' | 'nip46',
data?: {
privateKey?: string
bunkerUrl?: string
encryptedKey?: string
}
) => {
// Enable auto-login
localStorage.setItem(STORAGE_KEYS.AUTO_LOGIN, 'true')
if (method === 'nip46' && data?.privateKey && data?.bunkerUrl) {
localStorage.setItem(STORAGE_KEYS.LOCAL_SIGNER, data.privateKey)
localStorage.setItem(STORAGE_KEYS.BUNKER_URL, data.bunkerUrl)
} else if (method === 'private-key' && data?.encryptedKey) {
localStorage.setItem(STORAGE_KEYS.ENCRYPTED_KEY, data.encryptedKey)
}
// Extension doesn't need storage
}
// ============================================================
// LOGOUT
// ============================================================
const logout = (ndk: NDK) => {
// Remove signer from NDK
ndk.signer = undefined
// Clear all auth storage
Object.values(STORAGE_KEYS).forEach(key => {
localStorage.removeItem(key)
})
console.log('✅ Logged out successfully')
}
// ============================================================
// GET CURRENT USER
// ============================================================
const getCurrentUser = async (ndk: NDK) => {
if (!ndk.signer) {
return null
}
try {
const user = await ndk.signer.user()
return {
pubkey: user.pubkey,
npub: user.npub,
profile: await user.fetchProfile()
}
} catch (error) {
console.error('Failed to get current user:', error)
return null
}
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function authExample(ndk: NDK) {
// Try auto-login first
let auth = await getAuthFromStorage(ndk)
if (!auth) {
// Manual login options
console.log('Choose login method:')
console.log('1. Browser Extension (NIP-07)')
console.log('2. Private Key')
console.log('3. Remote Signer (NIP-46)')
// Example: login with extension
auth = await loginWithExtension(ndk)
saveAuthToStorage('extension')
}
if (auth && 'needsPassword' in auth) {
// Handle encrypted key case
console.log('Password required for encrypted key')
return
}
// Get current user info
const currentUser = await getCurrentUser(ndk)
console.log('Current user:', currentUser)
// Logout when done
// logout(ndk)
}
export {
loginWithExtension,
loginWithPrivateKey,
loginWithNip46,
getAuthFromStorage,
saveAuthToStorage,
logout,
getCurrentUser
}

View File

@@ -0,0 +1,376 @@
/**
* NDK Event Publishing Patterns
*
* Examples from: src/publish/orders.tsx, scripts/gen_products.ts
*/
import NDK, { NDKEvent, NDKTag } from '@nostr-dev-kit/ndk'
// ============================================================
// BASIC EVENT PUBLISHING
// ============================================================
const publishBasicNote = async (ndk: NDK, content: string) => {
// Create event
const event = new NDKEvent(ndk)
event.kind = 1 // Text note
event.content = content
event.tags = []
// Sign and publish
await event.sign()
await event.publish()
console.log('✅ Published note:', event.id)
return event.id
}
// ============================================================
// EVENT WITH TAGS
// ============================================================
const publishNoteWithTags = async (
ndk: NDK,
content: string,
options: {
mentions?: string[] // pubkeys to mention
hashtags?: string[]
replyTo?: string // event ID
}
) => {
const event = new NDKEvent(ndk)
event.kind = 1
event.content = content
event.tags = []
// Add mentions
if (options.mentions) {
options.mentions.forEach(pubkey => {
event.tags.push(['p', pubkey])
})
}
// Add hashtags
if (options.hashtags) {
options.hashtags.forEach(tag => {
event.tags.push(['t', tag])
})
}
// Add reply
if (options.replyTo) {
event.tags.push(['e', options.replyTo, '', 'reply'])
}
await event.sign()
await event.publish()
return event.id
}
// ============================================================
// PRODUCT LISTING (PARAMETERIZED REPLACEABLE EVENT)
// ============================================================
interface ProductData {
slug: string // Unique identifier
title: string
description: string
price: number
currency: string
images: string[]
shippingRefs?: string[]
category?: string
}
const publishProduct = async (ndk: NDK, product: ProductData) => {
const event = new NDKEvent(ndk)
event.kind = 30402 // Product listing kind
event.content = product.description
// Build tags
event.tags = [
['d', product.slug], // Unique identifier (required for replaceable)
['title', product.title],
['price', product.price.toString(), product.currency],
]
// Add images
product.images.forEach(image => {
event.tags.push(['image', image])
})
// Add shipping options
if (product.shippingRefs) {
product.shippingRefs.forEach(ref => {
event.tags.push(['shipping', ref])
})
}
// Add category
if (product.category) {
event.tags.push(['t', product.category])
}
// Optional: set custom timestamp
event.created_at = Math.floor(Date.now() / 1000)
await event.sign()
await event.publish()
console.log('✅ Published product:', product.title)
return event.id
}
// ============================================================
// ORDER CREATION EVENT
// ============================================================
interface OrderData {
orderId: string
sellerPubkey: string
productRef: string
quantity: number
totalAmount: string
currency: string
shippingRef?: string
shippingAddress?: string
email?: string
phone?: string
notes?: string
}
const createOrder = async (ndk: NDK, order: OrderData) => {
const event = new NDKEvent(ndk)
event.kind = 16 // Order processing kind
event.content = order.notes || ''
// Required tags per spec
event.tags = [
['p', order.sellerPubkey],
['subject', `Order ${order.orderId.substring(0, 8)}`],
['type', 'order-creation'],
['order', order.orderId],
['amount', order.totalAmount],
['item', order.productRef, order.quantity.toString()],
]
// Optional tags
if (order.shippingRef) {
event.tags.push(['shipping', order.shippingRef])
}
if (order.shippingAddress) {
event.tags.push(['address', order.shippingAddress])
}
if (order.email) {
event.tags.push(['email', order.email])
}
if (order.phone) {
event.tags.push(['phone', order.phone])
}
try {
await event.sign()
await event.publish()
console.log('✅ Order created:', order.orderId)
return { success: true, eventId: event.id }
} catch (error) {
console.error('❌ Failed to create order:', error)
return { success: false, error }
}
}
// ============================================================
// STATUS UPDATE EVENT
// ============================================================
const publishStatusUpdate = async (
ndk: NDK,
orderId: string,
recipientPubkey: string,
status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled',
notes?: string
) => {
const event = new NDKEvent(ndk)
event.kind = 16
event.content = notes || `Order status updated to ${status}`
event.tags = [
['p', recipientPubkey],
['subject', 'order-info'],
['type', 'status-update'],
['order', orderId],
['status', status],
]
await event.sign()
await event.publish()
return event.id
}
// ============================================================
// BATCH PUBLISHING
// ============================================================
const publishMultipleEvents = async (
ndk: NDK,
events: Array<{ kind: number; content: string; tags: NDKTag[] }>
) => {
const results = []
for (const eventData of events) {
try {
const event = new NDKEvent(ndk)
event.kind = eventData.kind
event.content = eventData.content
event.tags = eventData.tags
await event.sign()
await event.publish()
results.push({ success: true, eventId: event.id })
} catch (error) {
results.push({ success: false, error })
}
}
return results
}
// ============================================================
// PUBLISH WITH CUSTOM SIGNER
// ============================================================
import { NDKSigner } from '@nostr-dev-kit/ndk'
const publishWithCustomSigner = async (
ndk: NDK,
signer: NDKSigner,
eventData: { kind: number; content: string; tags: NDKTag[] }
) => {
const event = new NDKEvent(ndk)
event.kind = eventData.kind
event.content = eventData.content
event.tags = eventData.tags
// Sign with specific signer (not ndk.signer)
await event.sign(signer)
await event.publish()
return event.id
}
// ============================================================
// ERROR HANDLING PATTERN
// ============================================================
const publishWithErrorHandling = async (
ndk: NDK,
eventData: { kind: number; content: string; tags: NDKTag[] }
) => {
// Validate NDK
if (!ndk) {
throw new Error('NDK not initialized')
}
// Validate signer
if (!ndk.signer) {
throw new Error('No active signer. Please login first.')
}
try {
const event = new NDKEvent(ndk)
event.kind = eventData.kind
event.content = eventData.content
event.tags = eventData.tags
// Sign
await event.sign()
// Verify signature
if (!event.sig) {
throw new Error('Event signing failed')
}
// Publish
await event.publish()
// Verify event ID
if (!event.id) {
throw new Error('Event ID not generated')
}
return {
success: true,
eventId: event.id,
pubkey: event.pubkey
}
} catch (error) {
console.error('Publishing failed:', error)
if (error instanceof Error) {
// Handle specific error types
if (error.message.includes('relay')) {
throw new Error('Failed to publish to relays. Check connection.')
}
if (error.message.includes('sign')) {
throw new Error('Failed to sign event. Check signer.')
}
}
throw error
}
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function publishingExample(ndk: NDK) {
// Simple note
await publishBasicNote(ndk, 'Hello Nostr!')
// Note with tags
await publishNoteWithTags(ndk, 'Check out this product!', {
hashtags: ['marketplace', 'nostr'],
mentions: ['pubkey123...']
})
// Product listing
await publishProduct(ndk, {
slug: 'bitcoin-tshirt',
title: 'Bitcoin T-Shirt',
description: 'High quality Bitcoin t-shirt',
price: 25,
currency: 'USD',
images: ['https://example.com/image.jpg'],
category: 'clothing'
})
// Order
await createOrder(ndk, {
orderId: 'order-123',
sellerPubkey: 'seller-pubkey',
productRef: '30402:pubkey:bitcoin-tshirt',
quantity: 1,
totalAmount: '25.00',
currency: 'USD',
email: 'customer@example.com'
})
}
export {
publishBasicNote,
publishNoteWithTags,
publishProduct,
createOrder,
publishStatusUpdate,
publishMultipleEvents,
publishWithCustomSigner,
publishWithErrorHandling
}

View File

@@ -0,0 +1,404 @@
/**
* NDK Query and Subscription Patterns
*
* Examples from: src/queries/orders.tsx, src/queries/payment.tsx
*/
import NDK, { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk'
// ============================================================
// BASIC FETCH (ONE-TIME QUERY)
// ============================================================
const fetchNotes = async (ndk: NDK, authorPubkey: string, limit: number = 50) => {
const filter: NDKFilter = {
kinds: [1], // Text notes
authors: [authorPubkey],
limit
}
// Fetch returns a Set
const events = await ndk.fetchEvents(filter)
// Convert to array and sort by timestamp
const eventArray = Array.from(events).sort((a, b) =>
(b.created_at || 0) - (a.created_at || 0)
)
return eventArray
}
// ============================================================
// FETCH WITH MULTIPLE FILTERS
// ============================================================
const fetchProductsByMultipleAuthors = async (
ndk: NDK,
pubkeys: string[]
) => {
const filter: NDKFilter = {
kinds: [30402], // Product listings
authors: pubkeys,
limit: 100
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// ============================================================
// FETCH WITH TAG FILTERS
// ============================================================
const fetchOrderEvents = async (ndk: NDK, orderId: string) => {
const filter: NDKFilter = {
kinds: [16, 17], // Order and payment receipt
'#order': [orderId], // Tag filter (note the # prefix)
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// ============================================================
// FETCH WITH TIME RANGE
// ============================================================
const fetchRecentEvents = async (
ndk: NDK,
kind: number,
hoursAgo: number = 24
) => {
const now = Math.floor(Date.now() / 1000)
const since = now - (hoursAgo * 3600)
const filter: NDKFilter = {
kinds: [kind],
since,
until: now,
limit: 100
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// ============================================================
// FETCH BY EVENT ID
// ============================================================
const fetchEventById = async (ndk: NDK, eventId: string) => {
const filter: NDKFilter = {
ids: [eventId]
}
const events = await ndk.fetchEvents(filter)
if (events.size === 0) {
return null
}
return Array.from(events)[0]
}
// ============================================================
// BASIC SUBSCRIPTION (REAL-TIME)
// ============================================================
const subscribeToNotes = (
ndk: NDK,
authorPubkey: string,
onEvent: (event: NDKEvent) => void
): NDKSubscription => {
const filter: NDKFilter = {
kinds: [1],
authors: [authorPubkey]
}
const subscription = ndk.subscribe(filter, {
closeOnEose: false // Keep open for real-time updates
})
// Event handler
subscription.on('event', (event: NDKEvent) => {
onEvent(event)
})
// EOSE (End of Stored Events) handler
subscription.on('eose', () => {
console.log('✅ Received all stored events')
})
return subscription
}
// ============================================================
// SUBSCRIPTION WITH CLEANUP
// ============================================================
const createManagedSubscription = (
ndk: NDK,
filter: NDKFilter,
handlers: {
onEvent: (event: NDKEvent) => void
onEose?: () => void
onClose?: () => void
}
) => {
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', handlers.onEvent)
if (handlers.onEose) {
subscription.on('eose', handlers.onEose)
}
if (handlers.onClose) {
subscription.on('close', handlers.onClose)
}
// Return cleanup function
return () => {
subscription.stop()
console.log('✅ Subscription stopped')
}
}
// ============================================================
// MONITORING SPECIFIC EVENT
// ============================================================
const monitorPaymentReceipt = (
ndk: NDK,
orderId: string,
invoiceId: string,
onPaymentReceived: (preimage: string) => void
): NDKSubscription => {
const sessionStart = Math.floor(Date.now() / 1000)
const filter: NDKFilter = {
kinds: [17], // Payment receipt
'#order': [orderId],
'#payment-request': [invoiceId],
since: sessionStart - 30 // 30 second buffer for clock skew
}
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', (event: NDKEvent) => {
// Verify event is recent
if (event.created_at && event.created_at < sessionStart - 30) {
console.log('⏰ Ignoring old receipt')
return
}
// Verify it's the correct invoice
const paymentRequestTag = event.tags.find(tag => tag[0] === 'payment-request')
if (paymentRequestTag?.[1] !== invoiceId) {
return
}
// Extract preimage
const paymentTag = event.tags.find(tag => tag[0] === 'payment')
const preimage = paymentTag?.[3] || 'external-payment'
console.log('✅ Payment received!')
subscription.stop()
onPaymentReceived(preimage)
})
return subscription
}
// ============================================================
// REACT INTEGRATION PATTERN
// ============================================================
import { useEffect, useState } from 'react'
function useOrderSubscription(ndk: NDK | null, orderId: string) {
const [events, setEvents] = useState<NDKEvent[]>([])
const [eosed, setEosed] = useState(false)
useEffect(() => {
if (!ndk || !orderId) return
const filter: NDKFilter = {
kinds: [16, 17],
'#order': [orderId]
}
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', (event: NDKEvent) => {
setEvents(prev => {
// Avoid duplicates
if (prev.some(e => e.id === event.id)) {
return prev
}
return [...prev, event].sort((a, b) =>
(a.created_at || 0) - (b.created_at || 0)
)
})
})
subscription.on('eose', () => {
setEosed(true)
})
// Cleanup on unmount
return () => {
subscription.stop()
}
}, [ndk, orderId])
return { events, eosed }
}
// ============================================================
// REACT QUERY INTEGRATION
// ============================================================
import { useQuery, useQueryClient } from '@tanstack/react-query'
// Query function
const fetchProducts = async (ndk: NDK, pubkey: string) => {
if (!ndk) throw new Error('NDK not initialized')
const filter: NDKFilter = {
kinds: [30402],
authors: [pubkey]
}
const events = await ndk.fetchEvents(filter)
return Array.from(events)
}
// Hook with subscription for real-time updates
function useProductsWithSubscription(ndk: NDK | null, pubkey: string) {
const queryClient = useQueryClient()
// Initial query
const query = useQuery({
queryKey: ['products', pubkey],
queryFn: () => fetchProducts(ndk!, pubkey),
enabled: !!ndk && !!pubkey,
staleTime: 30000
})
// Real-time subscription
useEffect(() => {
if (!ndk || !pubkey) return
const filter: NDKFilter = {
kinds: [30402],
authors: [pubkey]
}
const subscription = ndk.subscribe(filter, { closeOnEose: false })
subscription.on('event', () => {
// Invalidate query to trigger refetch
queryClient.invalidateQueries({ queryKey: ['products', pubkey] })
})
return () => {
subscription.stop()
}
}, [ndk, pubkey, queryClient])
return query
}
// ============================================================
// ADVANCED: WAITING FOR SPECIFIC EVENT
// ============================================================
const waitForEvent = (
ndk: NDK,
filter: NDKFilter,
condition: (event: NDKEvent) => boolean,
timeoutMs: number = 30000
): Promise<NDKEvent | null> => {
return new Promise((resolve) => {
const subscription = ndk.subscribe(filter, { closeOnEose: false })
// Timeout
const timeout = setTimeout(() => {
subscription.stop()
resolve(null)
}, timeoutMs)
// Event handler
subscription.on('event', (event: NDKEvent) => {
if (condition(event)) {
clearTimeout(timeout)
subscription.stop()
resolve(event)
}
})
})
}
// Usage example
async function waitForPayment(ndk: NDK, orderId: string, invoiceId: string) {
const paymentEvent = await waitForEvent(
ndk,
{
kinds: [17],
'#order': [orderId],
since: Math.floor(Date.now() / 1000)
},
(event) => {
const tag = event.tags.find(t => t[0] === 'payment-request')
return tag?.[1] === invoiceId
},
60000 // 60 second timeout
)
if (paymentEvent) {
console.log('✅ Payment confirmed!')
return paymentEvent
} else {
console.log('⏰ Payment timeout')
return null
}
}
// ============================================================
// USAGE EXAMPLES
// ============================================================
async function queryExample(ndk: NDK) {
// Fetch notes
const notes = await fetchNotes(ndk, 'pubkey123', 50)
console.log(`Found ${notes.length} notes`)
// Subscribe to new notes
const cleanup = subscribeToNotes(ndk, 'pubkey123', (event) => {
console.log('New note:', event.content)
})
// Clean up after 60 seconds
setTimeout(cleanup, 60000)
// Monitor payment
monitorPaymentReceipt(ndk, 'order-123', 'invoice-456', (preimage) => {
console.log('Payment received:', preimage)
})
}
export {
fetchNotes,
fetchProductsByMultipleAuthors,
fetchOrderEvents,
fetchRecentEvents,
fetchEventById,
subscribeToNotes,
createManagedSubscription,
monitorPaymentReceipt,
useOrderSubscription,
useProductsWithSubscription,
waitForEvent
}

View File

@@ -0,0 +1,423 @@
/**
* NDK User and Profile Handling
*
* Examples from: src/queries/profiles.tsx, src/components/Profile.tsx
*/
import NDK, { NDKUser, NDKUserProfile } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
// ============================================================
// FETCH PROFILE BY NPUB
// ============================================================
const fetchProfileByNpub = async (ndk: NDK, npub: string): Promise<NDKUserProfile | null> => {
try {
// Get user object from npub
const user = ndk.getUser({ npub })
// Fetch profile from relays
const profile = await user.fetchProfile()
return profile
} catch (error) {
console.error('Failed to fetch profile:', error)
return null
}
}
// ============================================================
// FETCH PROFILE BY HEX PUBKEY
// ============================================================
const fetchProfileByPubkey = async (ndk: NDK, pubkey: string): Promise<NDKUserProfile | null> => {
try {
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
return profile
} catch (error) {
console.error('Failed to fetch profile:', error)
return null
}
}
// ============================================================
// FETCH PROFILE BY NIP-05
// ============================================================
const fetchProfileByNip05 = async (ndk: NDK, nip05: string): Promise<NDKUserProfile | null> => {
try {
// Resolve NIP-05 identifier to user
const user = await ndk.getUserFromNip05(nip05)
if (!user) {
console.log('User not found for NIP-05:', nip05)
return null
}
// Fetch profile
const profile = await user.fetchProfile()
return profile
} catch (error) {
console.error('Failed to fetch profile by NIP-05:', error)
return null
}
}
// ============================================================
// FETCH PROFILE BY ANY IDENTIFIER
// ============================================================
const fetchProfileByIdentifier = async (
ndk: NDK,
identifier: string
): Promise<{ profile: NDKUserProfile | null; user: NDKUser | null }> => {
try {
// Check if it's a NIP-05 (contains @)
if (identifier.includes('@')) {
const user = await ndk.getUserFromNip05(identifier)
if (!user) return { profile: null, user: null }
const profile = await user.fetchProfile()
return { profile, user }
}
// Check if it's an npub
if (identifier.startsWith('npub')) {
const user = ndk.getUser({ npub: identifier })
const profile = await user.fetchProfile()
return { profile, user }
}
// Assume it's a hex pubkey
const user = ndk.getUser({ hexpubkey: identifier })
const profile = await user.fetchProfile()
return { profile, user }
} catch (error) {
console.error('Failed to fetch profile:', error)
return { profile: null, user: null }
}
}
// ============================================================
// GET CURRENT USER
// ============================================================
const getCurrentUser = async (ndk: NDK): Promise<NDKUser | null> => {
if (!ndk.signer) {
console.log('No signer set')
return null
}
try {
const user = await ndk.signer.user()
return user
} catch (error) {
console.error('Failed to get current user:', error)
return null
}
}
// ============================================================
// PROFILE DATA STRUCTURE
// ============================================================
interface ProfileData {
// Standard fields
name?: string
displayName?: string
display_name?: string
picture?: string
image?: string
banner?: string
about?: string
// Contact
nip05?: string
lud06?: string // LNURL
lud16?: string // Lightning address
// Social
website?: string
// Raw data
[key: string]: any
}
// ============================================================
// EXTRACT PROFILE INFO
// ============================================================
const extractProfileInfo = (profile: NDKUserProfile | null) => {
if (!profile) {
return {
displayName: 'Anonymous',
avatar: null,
bio: null,
lightningAddress: null,
nip05: null
}
}
return {
displayName: profile.displayName || profile.display_name || profile.name || 'Anonymous',
avatar: profile.picture || profile.image || null,
banner: profile.banner || null,
bio: profile.about || null,
lightningAddress: profile.lud16 || profile.lud06 || null,
nip05: profile.nip05 || null,
website: profile.website || null
}
}
// ============================================================
// UPDATE PROFILE
// ============================================================
import { NDKEvent } from '@nostr-dev-kit/ndk'
const updateProfile = async (ndk: NDK, profileData: Partial<ProfileData>) => {
if (!ndk.signer) {
throw new Error('No signer available')
}
// Get current profile
const currentUser = await ndk.signer.user()
const currentProfile = await currentUser.fetchProfile()
// Merge with new data
const updatedProfile = {
...currentProfile,
...profileData
}
// Create kind 0 (metadata) event
const event = new NDKEvent(ndk)
event.kind = 0
event.content = JSON.stringify(updatedProfile)
event.tags = []
await event.sign()
await event.publish()
console.log('✅ Profile updated')
return event.id
}
// ============================================================
// BATCH FETCH PROFILES
// ============================================================
const fetchMultipleProfiles = async (
ndk: NDK,
pubkeys: string[]
): Promise<Map<string, NDKUserProfile | null>> => {
const profiles = new Map<string, NDKUserProfile | null>()
// Fetch all profiles in parallel
await Promise.all(
pubkeys.map(async (pubkey) => {
try {
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
profiles.set(pubkey, profile)
} catch (error) {
console.error(`Failed to fetch profile for ${pubkey}:`, error)
profiles.set(pubkey, null)
}
})
)
return profiles
}
// ============================================================
// CONVERT BETWEEN FORMATS
// ============================================================
const convertPubkeyFormats = (identifier: string) => {
try {
// If it's npub, convert to hex
if (identifier.startsWith('npub')) {
const decoded = nip19.decode(identifier)
if (decoded.type === 'npub') {
return {
hex: decoded.data as string,
npub: identifier
}
}
}
// If it's hex, convert to npub
if (/^[0-9a-f]{64}$/.test(identifier)) {
return {
hex: identifier,
npub: nip19.npubEncode(identifier)
}
}
throw new Error('Invalid pubkey format')
} catch (error) {
console.error('Format conversion failed:', error)
return null
}
}
// ============================================================
// REACT HOOK FOR PROFILE
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
function useProfile(ndk: NDK | null, npub: string | undefined) {
return useQuery({
queryKey: ['profile', npub],
queryFn: async () => {
if (!ndk || !npub) throw new Error('NDK or npub missing')
return await fetchProfileByNpub(ndk, npub)
},
enabled: !!ndk && !!npub,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000 // 30 minutes
})
}
// ============================================================
// REACT COMPONENT EXAMPLE
// ============================================================
interface ProfileDisplayProps {
ndk: NDK
pubkey: string
}
function ProfileDisplay({ ndk, pubkey }: ProfileDisplayProps) {
const [profile, setProfile] = useState<NDKUserProfile | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadProfile = async () => {
setLoading(true)
try {
const user = ndk.getUser({ hexpubkey: pubkey })
const fetchedProfile = await user.fetchProfile()
setProfile(fetchedProfile)
} catch (error) {
console.error('Failed to load profile:', error)
} finally {
setLoading(false)
}
}
loadProfile()
}, [ndk, pubkey])
if (loading) {
return <div>Loading profile...</div>
}
const info = extractProfileInfo(profile)
return (
<div className="profile">
{info.avatar && <img src={info.avatar} alt={info.displayName} />}
<h2>{info.displayName}</h2>
{info.bio && <p>{info.bio}</p>}
{info.nip05 && <span> {info.nip05}</span>}
{info.lightningAddress && <span> {info.lightningAddress}</span>}
</div>
)
}
// ============================================================
// FOLLOW/UNFOLLOW USER
// ============================================================
const followUser = async (ndk: NDK, pubkeyToFollow: string) => {
if (!ndk.signer) {
throw new Error('No signer available')
}
// Fetch current contact list (kind 3)
const currentUser = await ndk.signer.user()
const contactListFilter = {
kinds: [3],
authors: [currentUser.pubkey]
}
const existingEvents = await ndk.fetchEvents(contactListFilter)
const existingContactList = existingEvents.size > 0
? Array.from(existingEvents)[0]
: null
// Get existing p tags
const existingPTags = existingContactList
? existingContactList.tags.filter(tag => tag[0] === 'p')
: []
// Check if already following
const alreadyFollowing = existingPTags.some(tag => tag[1] === pubkeyToFollow)
if (alreadyFollowing) {
console.log('Already following this user')
return
}
// Create new contact list with added user
const event = new NDKEvent(ndk)
event.kind = 3
event.content = existingContactList?.content || ''
event.tags = [
...existingPTags,
['p', pubkeyToFollow]
]
await event.sign()
await event.publish()
console.log('✅ Now following user')
}
// ============================================================
// USAGE EXAMPLE
// ============================================================
async function profileExample(ndk: NDK) {
// Fetch by different identifiers
const profile1 = await fetchProfileByNpub(ndk, 'npub1...')
const profile2 = await fetchProfileByNip05(ndk, 'user@domain.com')
const profile3 = await fetchProfileByPubkey(ndk, 'hex pubkey...')
// Extract display info
const info = extractProfileInfo(profile1)
console.log('Display name:', info.displayName)
console.log('Avatar:', info.avatar)
// Update own profile
await updateProfile(ndk, {
name: 'My Name',
about: 'My bio',
picture: 'https://example.com/avatar.jpg',
lud16: 'me@getalby.com'
})
// Follow someone
await followUser(ndk, 'pubkey to follow')
}
export {
fetchProfileByNpub,
fetchProfileByPubkey,
fetchProfileByNip05,
fetchProfileByIdentifier,
getCurrentUser,
extractProfileInfo,
updateProfile,
fetchMultipleProfiles,
convertPubkeyFormats,
useProfile,
followUser
}

View File

@@ -0,0 +1,94 @@
# NDK Examples Index
Complete code examples extracted from the Plebeian Market production codebase.
## Available Examples
### 01-initialization.ts
- Basic NDK initialization
- Multiple NDK instances (main + zap relays)
- Connection with timeout protection
- Connection status checking
- Full initialization flow with error handling
### 02-authentication.ts
- NIP-07 browser extension login
- Private key signer
- NIP-46 remote signer (Bunker)
- Auto-login from localStorage
- Saving auth credentials
- Logout functionality
- Getting current user
### 03-publishing-events.ts
- Basic note publishing
- Events with tags (mentions, hashtags, replies)
- Product listings (parameterized replaceable events)
- Order creation events
- Status update events
- Batch publishing
- Custom signer usage
- Comprehensive error handling
### 04-querying-subscribing.ts
- Basic fetch queries
- Multiple author queries
- Tag filtering
- Time range filtering
- Event ID lookup
- Real-time subscriptions
- Subscription cleanup patterns
- React integration hooks
- React Query integration
- Waiting for specific events
- Payment monitoring
### 05-users-profiles.ts
- Fetch profile by npub
- Fetch profile by hex pubkey
- Fetch profile by NIP-05
- Universal identifier lookup
- Get current user
- Extract profile information
- Update user profile
- Batch fetch multiple profiles
- Convert between pubkey formats (hex/npub)
- React hooks for profiles
- Follow/unfollow users
## Usage
Each file contains:
- Fully typed TypeScript code
- JSDoc comments explaining the pattern
- Error handling examples
- Integration patterns with React/TanStack Query
- Real-world usage examples
All examples are based on actual production code from the Plebeian Market application.
## Running Examples
```typescript
import { initializeNDK } from './01-initialization'
import { loginWithExtension } from './02-authentication'
import { publishBasicNote } from './03-publishing-events'
// Initialize NDK
const { ndk, isConnected } = await initializeNDK()
if (isConnected) {
// Authenticate
const { user } = await loginWithExtension(ndk)
// Publish
await publishBasicNote(ndk, 'Hello Nostr!')
}
```
## Additional Resources
- See `../ndk-skill.md` for detailed documentation
- See `../quick-reference.md` for quick lookup
- Check the main codebase for more complex patterns

View File

@@ -0,0 +1,701 @@
# NDK (Nostr Development Kit) - Claude Skill Reference
## Overview
NDK is the primary Nostr development kit with outbox-model support, designed for building Nostr applications with TypeScript/JavaScript. This reference is based on analyzing production usage in the Plebeian Market codebase.
## Core Concepts
### 1. NDK Initialization
**Basic Pattern:**
```typescript
import NDK from '@nostr-dev-kit/ndk'
// Simple initialization
const ndk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io', 'wss://relay.nostr.band']
})
await ndk.connect()
```
**Store-based Pattern (Production):**
```typescript
// From src/lib/stores/ndk.ts
const ndk = new NDK({
explicitRelayUrls: relays || defaultRelaysUrls,
})
// Separate NDK for zaps on specialized relays
const zapNdk = new NDK({
explicitRelayUrls: ZAP_RELAYS,
})
// Connect with timeout protection
const connectPromise = ndk.connect()
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Connection timeout')), timeoutMs)
)
await Promise.race([connectPromise, timeoutPromise])
```
### 2. Authentication & Signers
NDK supports multiple signer types for different authentication methods:
#### NIP-07 (Browser Extension)
```typescript
import { NDKNip07Signer } from '@nostr-dev-kit/ndk'
const signer = new NDKNip07Signer()
await signer.blockUntilReady()
ndk.signer = signer
const user = await signer.user()
```
#### Private Key Signer
```typescript
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'
const signer = new NDKPrivateKeySigner(privateKeyHex)
await signer.blockUntilReady()
ndk.signer = signer
const user = await signer.user()
```
#### NIP-46 (Remote Signer / Bunker)
```typescript
import { NDKNip46Signer } from '@nostr-dev-kit/ndk'
const localSigner = new NDKPrivateKeySigner(localPrivateKey)
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
await remoteSigner.blockUntilReady()
ndk.signer = remoteSigner
const user = await remoteSigner.user()
```
**Key Points:**
- Always call `blockUntilReady()` before using a signer
- Store signer reference in your state management
- Set `ndk.signer` to enable signing operations
- Use `await signer.user()` to get the authenticated user
### 3. Event Creation & Publishing
#### Basic Event Pattern
```typescript
import { NDKEvent } from '@nostr-dev-kit/ndk'
// Create event
const event = new NDKEvent(ndk)
event.kind = 1 // Kind 1 = text note
event.content = "Hello Nostr!"
event.tags = [
['t', 'nostr'],
['p', recipientPubkey]
]
// Sign and publish
await event.sign() // Uses ndk.signer automatically
await event.publish()
// Get event ID after signing
console.log(event.id)
```
#### Production Pattern with Error Handling
```typescript
// From src/publish/orders.tsx
const event = new NDKEvent(ndk)
event.kind = ORDER_PROCESS_KIND
event.content = orderNotes || ''
event.tags = [
['p', sellerPubkey],
['subject', `Order for ${productName}`],
['type', 'order-creation'],
['order', orderId],
['amount', totalAmount],
['item', productRef, quantity.toString()],
]
// Optional tags
if (shippingRef) {
event.tags.push(['shipping', shippingRef])
}
try {
await event.sign(signer) // Can pass explicit signer
await event.publish()
return event.id
} catch (error) {
console.error('Failed to publish event:', error)
throw error
}
```
**Key Points:**
- Create event with `new NDKEvent(ndk)`
- Set `kind`, `content`, and `tags` properties
- Optional: Set `created_at` timestamp (defaults to now)
- Call `await event.sign()` before publishing
- Call `await event.publish()` to broadcast to relays
- Access `event.id` after signing for the event hash
### 4. Querying Events with Filters
#### fetchEvents() - One-time Fetch
```typescript
import { NDKFilter } from '@nostr-dev-kit/ndk'
// Simple filter
const filter: NDKFilter = {
kinds: [30402], // Product listings
authors: [merchantPubkey],
limit: 50
}
const events = await ndk.fetchEvents(filter)
// Returns Set<NDKEvent>
// Convert to array and process
const eventArray = Array.from(events)
const sortedEvents = eventArray.sort((a, b) =>
(b.created_at || 0) - (a.created_at || 0)
)
```
#### Advanced Filters
```typescript
// Multiple kinds
const filter: NDKFilter = {
kinds: [16, 17], // Orders and payment receipts
'#order': [orderId], // Tag filter (# prefix)
since: Math.floor(Date.now() / 1000) - 86400, // Last 24 hours
limit: 100
}
// Event ID lookup
const filter: NDKFilter = {
ids: [eventIdHex],
}
// Tag filtering
const filter: NDKFilter = {
kinds: [1],
'#p': [pubkey], // Events mentioning pubkey
'#t': ['nostr'], // Events with hashtag 'nostr'
}
```
### 5. Subscriptions (Real-time)
#### Basic Subscription
```typescript
// From src/queries/blacklist.tsx
const filter = {
kinds: [10000],
authors: [appPubkey],
}
const subscription = ndk.subscribe(filter, {
closeOnEose: false, // Keep open for real-time updates
})
subscription.on('event', (event: NDKEvent) => {
console.log('New event received:', event)
// Process event
})
subscription.on('eose', () => {
console.log('End of stored events')
})
// Cleanup
subscription.stop()
```
#### Production Pattern with React Query
```typescript
// From src/queries/orders.tsx
useEffect(() => {
if (!orderId || !ndk) return
const filter = {
kinds: [ORDER_PROCESS_KIND, PAYMENT_RECEIPT_KIND],
'#order': [orderId],
}
const subscription = ndk.subscribe(filter, {
closeOnEose: false,
})
subscription.on('event', (newEvent) => {
// Invalidate React Query cache to trigger refetch
queryClient.invalidateQueries({
queryKey: orderKeys.details(orderId)
})
})
// Cleanup on unmount
return () => {
subscription.stop()
}
}, [orderId, ndk, queryClient])
```
#### Monitoring Specific Events
```typescript
// From src/queries/payment.tsx - Payment receipt monitoring
const receiptFilter = {
kinds: [17], // Payment receipts
'#order': [orderId],
'#payment-request': [invoiceId],
since: sessionStartTime - 30, // Clock skew buffer
}
const subscription = ndk.subscribe(receiptFilter, {
closeOnEose: false,
})
subscription.on('event', (receiptEvent: NDKEvent) => {
// Verify this is the correct invoice
const paymentRequestTag = receiptEvent.tags.find(
tag => tag[0] === 'payment-request'
)
if (paymentRequestTag?.[1] === invoiceId) {
const paymentTag = receiptEvent.tags.find(tag => tag[0] === 'payment')
const preimage = paymentTag?.[3] || 'external-payment'
// Stop subscription after finding payment
subscription.stop()
handlePaymentReceived(preimage)
}
})
```
**Key Subscription Patterns:**
- Use `closeOnEose: false` for real-time monitoring
- Use `closeOnEose: true` for one-time historical fetch
- Always call `subscription.stop()` in cleanup
- Listen to both `'event'` and `'eose'` events
- Filter events in the handler for specific conditions
- Integrate with React Query for reactive UI updates
### 6. User & Profile Handling
#### Fetching User Profiles
```typescript
// From src/queries/profiles.tsx
// By npub
const user = ndk.getUser({ npub })
const profile = await user.fetchProfile()
// Returns NDKUserProfile with name, picture, about, etc.
// By hex pubkey
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
// By NIP-05 identifier
const user = await ndk.getUserFromNip05('user@domain.com')
if (user) {
const profile = await user.fetchProfile()
}
// Profile fields
const name = profile?.name || profile?.displayName
const avatar = profile?.picture || profile?.image
const bio = profile?.about
const nip05 = profile?.nip05
const lud16 = profile?.lud16 // Lightning address
```
#### Getting Current User
```typescript
// Active user (authenticated)
const user = ndk.activeUser
// From signer
const user = await ndk.signer?.user()
// User properties
const pubkey = user.pubkey // Hex format
const npub = user.npub // NIP-19 encoded
```
### 7. NDK Event Object
#### Essential Properties
```typescript
interface NDKEvent {
id: string // Event hash (after signing)
kind: number // Event kind
content: string // Event content
tags: NDKTag[] // Array of tag arrays
created_at?: number // Unix timestamp
pubkey?: string // Author pubkey (after signing)
sig?: string // Signature (after signing)
// Methods
sign(signer?: NDKSigner): Promise<void>
publish(): Promise<void>
tagValue(tagName: string): string | undefined
}
type NDKTag = string[] // e.g., ['p', pubkey, relay, petname]
```
#### Tag Helpers
```typescript
// Get first value of a tag
const orderId = event.tagValue('order')
const recipientPubkey = event.tagValue('p')
// Find specific tag
const paymentTag = event.tags.find(tag => tag[0] === 'payment')
const preimage = paymentTag?.[3]
// Get all tags of a type
const pTags = event.tags.filter(tag => tag[0] === 'p')
const allPubkeys = pTags.map(tag => tag[1])
// Common tag patterns
event.tags.push(['p', pubkey]) // Mention
event.tags.push(['e', eventId]) // Reference event
event.tags.push(['t', 'nostr']) // Hashtag
event.tags.push(['d', identifier]) // Replaceable event ID
event.tags.push(['a', '30402:pubkey:d-tag']) // Addressable event reference
```
### 8. Parameterized Replaceable Events (NIP-33)
Used for products, collections, profiles that need updates:
```typescript
// Product listing (kind 30402)
const event = new NDKEvent(ndk)
event.kind = 30402
event.content = JSON.stringify(productDetails)
event.tags = [
['d', productSlug], // Unique identifier
['title', productName],
['price', price, currency],
['image', imageUrl],
['shipping', shippingRef],
]
await event.sign()
await event.publish()
// Querying replaceable events
const filter = {
kinds: [30402],
authors: [merchantPubkey],
'#d': [productSlug], // Specific product
}
const events = await ndk.fetchEvents(filter)
// Returns only the latest version due to replaceable nature
```
### 9. Relay Management
#### Getting Relay Status
```typescript
// From src/lib/stores/ndk.ts
const connectedRelays = Array.from(ndk.pool?.relays.values() || [])
.filter(relay => relay.status === 1) // 1 = connected
.map(relay => relay.url)
const outboxRelays = Array.from(ndk.outboxPool?.relays.values() || [])
```
#### Adding Relays
```typescript
// Add explicit relays
ndk.addExplicitRelay('wss://relay.example.com')
// Multiple relays
const relays = ['wss://relay1.com', 'wss://relay2.com']
relays.forEach(url => ndk.addExplicitRelay(url))
```
### 10. Common Patterns & Best Practices
#### Null Safety
```typescript
// Always check NDK initialization
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
// Check signer before operations requiring auth
const signer = ndk.signer
if (!signer) throw new Error('No active signer')
// Check user authentication
const user = ndk.activeUser
if (!user) throw new Error('Not authenticated')
```
#### Error Handling
```typescript
try {
const events = await ndk.fetchEvents(filter)
if (events.size === 0) {
return null // No results found
}
return Array.from(events)
} catch (error) {
console.error('Failed to fetch events:', error)
throw new Error('Could not fetch data from relays')
}
```
#### Connection Lifecycle
```typescript
// Initialize once at app startup
const ndk = new NDK({ explicitRelayUrls: relays })
// Connect with timeout
await Promise.race([
ndk.connect(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 10000)
)
])
// Check connection status
const isConnected = ndk.pool?.connectedRelays().length > 0
// Reconnect if needed
if (!isConnected) {
await ndk.connect()
}
```
#### Subscription Cleanup
```typescript
// In React components
useEffect(() => {
if (!ndk) return
const sub = ndk.subscribe(filter, { closeOnEose: false })
sub.on('event', handleEvent)
sub.on('eose', handleEose)
// Critical: cleanup on unmount
return () => {
sub.stop()
}
}, [dependencies])
```
#### Event Validation
```typescript
// Check required fields before processing
if (!event.pubkey) {
console.error('Event missing pubkey')
return
}
if (!event.created_at) {
console.error('Event missing timestamp')
return
}
// Verify event age
const now = Math.floor(Date.now() / 1000)
const eventAge = now - (event.created_at || 0)
if (eventAge > 86400) { // Older than 24 hours
console.log('Event is old, skipping')
return
}
// Validate specific tags exist
const orderId = event.tagValue('order')
if (!orderId) {
console.error('Order event missing order ID')
return
}
```
### 11. Common Event Kinds
```typescript
// NIP-01: Basic Events
const KIND_METADATA = 0 // User profile
const KIND_TEXT_NOTE = 1 // Short text note
const KIND_RECOMMEND_RELAY = 2 // Relay recommendation
// NIP-04: Encrypted Direct Messages
const KIND_ENCRYPTED_DM = 4
// NIP-25: Reactions
const KIND_REACTION = 7
// NIP-51: Lists
const KIND_MUTE_LIST = 10000
const KIND_PIN_LIST = 10001
const KIND_RELAY_LIST = 10002
// NIP-57: Lightning Zaps
const KIND_ZAP_REQUEST = 9734
const KIND_ZAP_RECEIPT = 9735
// Marketplace (Plebeian/Gamma spec)
const ORDER_PROCESS_KIND = 16 // Order processing
const PAYMENT_RECEIPT_KIND = 17 // Payment receipts
const DIRECT_MESSAGE_KIND = 14 // Direct messages
const ORDER_GENERAL_KIND = 27 // General order events
const SHIPPING_KIND = 30405 // Shipping options
const PRODUCT_KIND = 30402 // Product listings
const COLLECTION_KIND = 30401 // Product collections
const REVIEW_KIND = 30407 // Product reviews
// Application Handlers
const APP_HANDLER_KIND = 31990 // NIP-89 app handlers
```
## Integration with TanStack Query
NDK works excellently with TanStack Query for reactive data fetching:
### Query Functions
```typescript
// From src/queries/products.tsx
export const fetchProductsByPubkey = async (pubkey: string) => {
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
const filter: NDKFilter = {
kinds: [30402],
authors: [pubkey],
}
const events = await ndk.fetchEvents(filter)
return Array.from(events).map(parseProductEvent)
}
export const useProductsByPubkey = (pubkey: string) => {
return useQuery({
queryKey: productKeys.byAuthor(pubkey),
queryFn: () => fetchProductsByPubkey(pubkey),
enabled: !!pubkey,
staleTime: 30000,
})
}
```
### Combining Queries with Subscriptions
```typescript
// Query for initial data
const { data: order, refetch } = useQuery({
queryKey: orderKeys.details(orderId),
queryFn: () => fetchOrderById(orderId),
enabled: !!orderId,
})
// Subscription for real-time updates
useEffect(() => {
if (!orderId || !ndk) return
const sub = ndk.subscribe(
{ kinds: [16, 17], '#order': [orderId] },
{ closeOnEose: false }
)
sub.on('event', () => {
// Invalidate query to trigger refetch
queryClient.invalidateQueries({
queryKey: orderKeys.details(orderId)
})
})
return () => sub.stop()
}, [orderId, ndk, queryClient])
```
## Troubleshooting
### Events Not Received
- Check relay connections: `ndk.pool?.connectedRelays()`
- Verify filter syntax (especially tag filters with `#` prefix)
- Check event timestamps match filter's `since`/`until`
- Ensure `closeOnEose: false` for real-time subscriptions
### Signing Errors
- Verify signer is initialized: `await signer.blockUntilReady()`
- Check signer is set: `ndk.signer !== undefined`
- For NIP-07, ensure browser extension is installed and enabled
- For NIP-46, verify bunker URL and local signer are correct
### Connection Timeouts
- Implement connection timeout pattern shown above
- Try connecting to fewer, more reliable relays initially
- Use fallback relays in production
### Duplicate Events
- NDK deduplicates by event ID automatically
- For subscriptions, track processed event IDs if needed
- Use replaceable events (kinds 10000-19999, 30000-39999) when appropriate
## Performance Optimization
### Batching Queries
```typescript
// Instead of multiple fetchEvents calls
const [products, orders, profiles] = await Promise.all([
ndk.fetchEvents(productFilter),
ndk.fetchEvents(orderFilter),
ndk.fetchEvents(profileFilter),
])
```
### Limiting Results
```typescript
const filter = {
kinds: [1],
authors: [pubkey],
limit: 50, // Limit results
since: recentTimestamp, // Only recent events
}
```
### Caching with React Query
```typescript
export const useProfile = (npub: string) => {
return useQuery({
queryKey: profileKeys.byNpub(npub),
queryFn: () => fetchProfileByNpub(npub),
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
enabled: !!npub,
})
}
```
## References
- **NDK GitHub**: https://github.com/nostr-dev-kit/ndk
- **NDK Documentation**: https://ndk.fyi
- **Nostr NIPs**: https://github.com/nostr-protocol/nips
- **Production Example**: Plebeian Market codebase
## Key Files in This Codebase
- `src/lib/stores/ndk.ts` - NDK store and initialization
- `src/lib/stores/auth.ts` - Authentication with NDK signers
- `src/queries/*.tsx` - Query patterns with NDK
- `src/publish/*.tsx` - Event publishing patterns
- `scripts/gen_*.ts` - Event creation examples
---
*This reference is based on NDK version used in production and real-world patterns from the Plebeian Market application.*

View File

@@ -0,0 +1,351 @@
# NDK Quick Reference
Fast lookup guide for common NDK tasks.
## Quick Start
```typescript
import NDK from '@nostr-dev-kit/ndk'
const ndk = new NDK({ explicitRelayUrls: ['wss://relay.damus.io'] })
await ndk.connect()
```
## Authentication
### Browser Extension (NIP-07)
```typescript
import { NDKNip07Signer } from '@nostr-dev-kit/ndk'
const signer = new NDKNip07Signer()
await signer.blockUntilReady()
ndk.signer = signer
```
### Private Key
```typescript
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'
const signer = new NDKPrivateKeySigner(privateKeyHex)
await signer.blockUntilReady()
ndk.signer = signer
```
### Remote Signer (NIP-46)
```typescript
import { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'
const localSigner = new NDKPrivateKeySigner()
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
await remoteSigner.blockUntilReady()
ndk.signer = remoteSigner
```
## Publish Event
```typescript
import { NDKEvent } from '@nostr-dev-kit/ndk'
const event = new NDKEvent(ndk)
event.kind = 1
event.content = "Hello Nostr!"
event.tags = [['t', 'nostr']]
await event.sign()
await event.publish()
```
## Query Events (One-time)
```typescript
const events = await ndk.fetchEvents({
kinds: [1],
authors: [pubkey],
limit: 50
})
// Convert Set to Array
const eventArray = Array.from(events)
```
## Subscribe (Real-time)
```typescript
const sub = ndk.subscribe(
{ kinds: [1], authors: [pubkey] },
{ closeOnEose: false }
)
sub.on('event', (event) => {
console.log('New event:', event.content)
})
// Cleanup
sub.stop()
```
## Get User Profile
```typescript
// By npub
const user = ndk.getUser({ npub })
const profile = await user.fetchProfile()
// By hex pubkey
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
// By NIP-05
const user = await ndk.getUserFromNip05('user@domain.com')
const profile = await user?.fetchProfile()
```
## Common Filters
```typescript
// By author
{ kinds: [1], authors: [pubkey] }
// By tag
{ kinds: [1], '#p': [pubkey] }
{ kinds: [30402], '#d': [productSlug] }
// By time
{
kinds: [1],
since: Math.floor(Date.now() / 1000) - 86400, // Last 24h
until: Math.floor(Date.now() / 1000)
}
// By event ID
{ ids: [eventId] }
// Multiple conditions
{
kinds: [16, 17],
'#order': [orderId],
since: timestamp,
limit: 100
}
```
## Tag Helpers
```typescript
// Get first tag value
const orderId = event.tagValue('order')
// Find specific tag
const tag = event.tags.find(t => t[0] === 'payment')
const value = tag?.[1]
// Get all of one type
const pTags = event.tags.filter(t => t[0] === 'p')
// Common tag formats
['p', pubkey] // Mention
['e', eventId] // Event reference
['t', 'nostr'] // Hashtag
['d', identifier] // Replaceable ID
['a', '30402:pubkey:d-tag'] // Addressable reference
```
## Error Handling Pattern
```typescript
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
const signer = ndk.signer
if (!signer) throw new Error('No active signer')
try {
await event.publish()
} catch (error) {
console.error('Publish failed:', error)
throw error
}
```
## React Integration
```typescript
// Query function
export const fetchProducts = async (pubkey: string) => {
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
const events = await ndk.fetchEvents({
kinds: [30402],
authors: [pubkey]
})
return Array.from(events)
}
// React Query hook
export const useProducts = (pubkey: string) => {
return useQuery({
queryKey: ['products', pubkey],
queryFn: () => fetchProducts(pubkey),
enabled: !!pubkey,
})
}
// Subscription in useEffect
useEffect(() => {
if (!ndk || !orderId) return
const sub = ndk.subscribe(
{ kinds: [16], '#order': [orderId] },
{ closeOnEose: false }
)
sub.on('event', () => {
queryClient.invalidateQueries(['order', orderId])
})
return () => sub.stop()
}, [ndk, orderId, queryClient])
```
## Common Event Kinds
```typescript
0 // Metadata (profile)
1 // Text note
4 // Encrypted DM (NIP-04)
7 // Reaction
9735 // Zap receipt
10000 // Mute list
10002 // Relay list
30402 // Product listing (Marketplace)
31990 // App handler (NIP-89)
```
## Relay Management
```typescript
// Check connection
const connected = ndk.pool?.connectedRelays().length > 0
// Get connected relays
const relays = Array.from(ndk.pool?.relays.values() || [])
.filter(r => r.status === 1)
// Add relay
ndk.addExplicitRelay('wss://relay.example.com')
```
## Connection with Timeout
```typescript
const connectWithTimeout = async (timeoutMs = 10000) => {
const connectPromise = ndk.connect()
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
)
await Promise.race([connectPromise, timeoutPromise])
}
```
## Current User
```typescript
// Active user
const user = ndk.activeUser
// From signer
const user = await ndk.signer?.user()
// User info
const pubkey = user.pubkey // hex
const npub = user.npub // NIP-19
```
## Parameterized Replaceable Events
```typescript
// Create
const event = new NDKEvent(ndk)
event.kind = 30402
event.content = JSON.stringify(data)
event.tags = [
['d', uniqueIdentifier], // Required for replaceable
['title', 'Product Name'],
]
await event.sign()
await event.publish()
// Query (returns latest only)
const events = await ndk.fetchEvents({
kinds: [30402],
authors: [pubkey],
'#d': [identifier]
})
```
## Validation Checks
```typescript
// Event age check
const now = Math.floor(Date.now() / 1000)
const age = now - (event.created_at || 0)
if (age > 86400) console.log('Event older than 24h')
// Required fields
if (!event.pubkey || !event.created_at || !event.sig) {
throw new Error('Invalid event')
}
// Tag existence
const orderId = event.tagValue('order')
if (!orderId) throw new Error('Missing order tag')
```
## Performance Tips
```typescript
// Batch queries
const [products, orders] = await Promise.all([
ndk.fetchEvents(productFilter),
ndk.fetchEvents(orderFilter)
])
// Limit results
const filter = {
kinds: [1],
limit: 50,
since: recentTimestamp
}
// Cache with React Query
const { data } = useQuery({
queryKey: ['profile', npub],
queryFn: () => fetchProfile(npub),
staleTime: 5 * 60 * 1000, // 5 min
})
```
## Debugging
```typescript
// Check NDK state
console.log('Connected:', ndk.pool?.connectedRelays())
console.log('Signer:', ndk.signer)
console.log('Active user:', ndk.activeUser)
// Event inspection
console.log('Event ID:', event.id)
console.log('Tags:', event.tags)
console.log('Content:', event.content)
console.log('Author:', event.pubkey)
// Subscription events
sub.on('event', e => console.log('Event:', e))
sub.on('eose', () => console.log('End of stored events'))
```
---
For detailed explanations and advanced patterns, see `ndk-skill.md`.

View File

@@ -0,0 +1,530 @@
# NDK Common Patterns & Troubleshooting
Quick reference for common patterns and solutions to frequent NDK issues.
## Common Patterns
### Store-Based NDK Management
```typescript
// Store pattern (recommended for React apps)
import { Store } from '@tanstack/store'
interface NDKState {
ndk: NDK | null
isConnected: boolean
signer?: NDKSigner
}
const ndkStore = new Store<NDKState>({
ndk: null,
isConnected: false
})
export const ndkActions = {
initialize: () => {
const ndk = new NDK({ explicitRelayUrls: relays })
ndkStore.setState({ ndk })
return ndk
},
getNDK: () => ndkStore.state.ndk,
setSigner: (signer: NDKSigner) => {
const ndk = ndkStore.state.ndk
if (ndk) {
ndk.signer = signer
ndkStore.setState({ signer })
}
}
}
```
### Query + Subscription Pattern
```typescript
// Initial data load + real-time updates
function useOrdersWithRealtime(orderId: string) {
const queryClient = useQueryClient()
const ndk = ndkActions.getNDK()
// Fetch initial data
const query = useQuery({
queryKey: ['orders', orderId],
queryFn: () => fetchOrders(orderId),
})
// Subscribe to updates
useEffect(() => {
if (!ndk || !orderId) return
const sub = ndk.subscribe(
{ kinds: [16], '#order': [orderId] },
{ closeOnEose: false }
)
sub.on('event', () => {
queryClient.invalidateQueries(['orders', orderId])
})
return () => sub.stop()
}, [ndk, orderId])
return query
}
```
### Event Parsing Pattern
```typescript
// Parse event tags into structured data
function parseProductEvent(event: NDKEvent) {
const getTag = (name: string) =>
event.tags.find(t => t[0] === name)?.[1]
const getAllTags = (name: string) =>
event.tags.filter(t => t[0] === name).map(t => t[1])
return {
id: event.id,
slug: getTag('d'),
title: getTag('title'),
price: parseFloat(getTag('price') || '0'),
currency: event.tags.find(t => t[0] === 'price')?.[2] || 'USD',
images: getAllTags('image'),
shipping: getAllTags('shipping'),
description: event.content,
createdAt: event.created_at,
author: event.pubkey
}
}
```
### Relay Pool Pattern
```typescript
// Separate NDK instances for different purposes
const mainNdk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io', 'wss://nos.lol']
})
const zapNdk = new NDK({
explicitRelayUrls: ['wss://relay.damus.io'] // Zap-optimized relays
})
const blossomNdk = new NDK({
explicitRelayUrls: ['wss://blossom.server.com'] // Media server
})
await Promise.all([
mainNdk.connect(),
zapNdk.connect(),
blossomNdk.connect()
])
```
## Troubleshooting
### Problem: Events Not Received
**Symptoms:** Subscription doesn't receive events, fetchEvents returns empty Set
**Solutions:**
1. Check relay connection:
```typescript
const status = ndk.pool?.connectedRelays()
console.log('Connected relays:', status?.length)
if (status?.length === 0) {
await ndk.connect()
}
```
2. Verify filter syntax (especially tags):
```typescript
// ❌ Wrong
{ kinds: [16], 'order': [orderId] }
// ✅ Correct (note the # prefix for tags)
{ kinds: [16], '#order': [orderId] }
```
3. Check timestamps:
```typescript
// Events might be too old/new
const now = Math.floor(Date.now() / 1000)
const filter = {
kinds: [1],
since: now - 86400, // Last 24 hours
until: now
}
```
4. Ensure closeOnEose is correct:
```typescript
// For real-time updates
ndk.subscribe(filter, { closeOnEose: false })
// For one-time historical fetch
ndk.subscribe(filter, { closeOnEose: true })
```
### Problem: "NDK not initialized"
**Symptoms:** `ndk` is null/undefined
**Solutions:**
1. Initialize before use:
```typescript
// In app entry point
const ndk = new NDK({ explicitRelayUrls: relays })
await ndk.connect()
```
2. Add null checks:
```typescript
const ndk = ndkActions.getNDK()
if (!ndk) throw new Error('NDK not initialized')
```
3. Use initialization guard:
```typescript
const ensureNDK = () => {
let ndk = ndkActions.getNDK()
if (!ndk) {
ndk = ndkActions.initialize()
}
return ndk
}
```
### Problem: "No active signer" / Cannot Sign Events
**Symptoms:** Event signing fails, publishing throws error
**Solutions:**
1. Check signer is set:
```typescript
if (!ndk.signer) {
throw new Error('Please login first')
}
```
2. Ensure blockUntilReady called:
```typescript
const signer = new NDKNip07Signer()
await signer.blockUntilReady() // ← Critical!
ndk.signer = signer
```
3. Handle NIP-07 unavailable:
```typescript
try {
const signer = new NDKNip07Signer()
await signer.blockUntilReady()
ndk.signer = signer
} catch (error) {
console.error('Browser extension not available')
// Fallback to other auth method
}
```
### Problem: Duplicate Events in Subscriptions
**Symptoms:** Same event received multiple times
**Solutions:**
1. Track processed event IDs:
```typescript
const processedIds = new Set<string>()
sub.on('event', (event) => {
if (processedIds.has(event.id)) return
processedIds.add(event.id)
handleEvent(event)
})
```
2. Use Map for event storage:
```typescript
const [events, setEvents] = useState<Map<string, NDKEvent>>(new Map())
sub.on('event', (event) => {
setEvents(prev => new Map(prev).set(event.id, event))
})
```
### Problem: Connection Timeout
**Symptoms:** connect() hangs, never resolves
**Solutions:**
1. Use timeout wrapper:
```typescript
const connectWithTimeout = async (ndk: NDK, ms = 10000) => {
await Promise.race([
ndk.connect(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms)
)
])
}
```
2. Try fewer relays:
```typescript
// Start with reliable relays only
const reliableRelays = ['wss://relay.damus.io']
const ndk = new NDK({ explicitRelayUrls: reliableRelays })
```
3. Add connection retry:
```typescript
const connectWithRetry = async (ndk: NDK, maxRetries = 3) => {
for (let i = 0; i < maxRetries; i++) {
try {
await connectWithTimeout(ndk, 10000)
return
} catch (error) {
console.log(`Retry ${i + 1}/${maxRetries}`)
if (i === maxRetries - 1) throw error
}
}
}
```
### Problem: Subscription Memory Leak
**Symptoms:** App gets slower, memory usage increases
**Solutions:**
1. Always stop subscriptions:
```typescript
useEffect(() => {
const sub = ndk.subscribe(filter, { closeOnEose: false })
// ← CRITICAL: cleanup
return () => {
sub.stop()
}
}, [dependencies])
```
2. Track active subscriptions:
```typescript
const activeSubscriptions = new Set<NDKSubscription>()
const createSub = (filter: NDKFilter) => {
const sub = ndk.subscribe(filter, { closeOnEose: false })
activeSubscriptions.add(sub)
return sub
}
const stopAllSubs = () => {
activeSubscriptions.forEach(sub => sub.stop())
activeSubscriptions.clear()
}
```
### Problem: Profile Not Found
**Symptoms:** fetchProfile() returns null/undefined
**Solutions:**
1. Check different relays:
```typescript
// Add more relay URLs
const ndk = new NDK({
explicitRelayUrls: [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol'
]
})
```
2. Verify pubkey format:
```typescript
// Ensure correct format
if (pubkey.startsWith('npub')) {
const user = ndk.getUser({ npub: pubkey })
} else if (/^[0-9a-f]{64}$/.test(pubkey)) {
const user = ndk.getUser({ hexpubkey: pubkey })
}
```
3. Handle missing profiles gracefully:
```typescript
const profile = await user.fetchProfile()
const displayName = profile?.name || profile?.displayName || 'Anonymous'
const avatar = profile?.picture || '/default-avatar.png'
```
### Problem: Events Published But Not Visible
**Symptoms:** publish() succeeds but event not found in queries
**Solutions:**
1. Verify event was signed:
```typescript
await event.sign()
console.log('Event ID:', event.id) // Should be set
console.log('Signature:', event.sig) // Should exist
```
2. Check relay acceptance:
```typescript
const relays = await event.publish()
console.log('Published to relays:', relays)
```
3. Query immediately after publish:
```typescript
await event.publish()
// Wait a moment for relay propagation
await new Promise(resolve => setTimeout(resolve, 1000))
const found = await ndk.fetchEvents({ ids: [event.id] })
console.log('Event found:', found.size > 0)
```
### Problem: NIP-46 Connection Fails
**Symptoms:** Remote signer connection times out or fails
**Solutions:**
1. Verify bunker URL format:
```typescript
// Correct format: bunker://<remote-pubkey>?relay=wss://...
const isValidBunkerUrl = (url: string) => {
return url.startsWith('bunker://') && url.includes('?relay=')
}
```
2. Ensure local signer is ready:
```typescript
const localSigner = new NDKPrivateKeySigner(privateKey)
await localSigner.blockUntilReady()
const remoteSigner = new NDKNip46Signer(ndk, bunkerUrl, localSigner)
await remoteSigner.blockUntilReady()
```
3. Store credentials for reconnection:
```typescript
// Save for future sessions
localStorage.setItem('local-signer-key', localSigner.privateKey)
localStorage.setItem('bunker-url', bunkerUrl)
```
## Performance Tips
### Optimize Queries
```typescript
// ❌ Slow: Multiple sequential queries
const products = await ndk.fetchEvents({ kinds: [30402], authors: [pk1] })
const orders = await ndk.fetchEvents({ kinds: [16], authors: [pk1] })
const profiles = await ndk.fetchEvents({ kinds: [0], authors: [pk1] })
// ✅ Fast: Parallel queries
const [products, orders, profiles] = await Promise.all([
ndk.fetchEvents({ kinds: [30402], authors: [pk1] }),
ndk.fetchEvents({ kinds: [16], authors: [pk1] }),
ndk.fetchEvents({ kinds: [0], authors: [pk1] })
])
```
### Cache Profile Lookups
```typescript
const profileCache = new Map<string, NDKUserProfile>()
const getCachedProfile = async (ndk: NDK, pubkey: string) => {
if (profileCache.has(pubkey)) {
return profileCache.get(pubkey)!
}
const user = ndk.getUser({ hexpubkey: pubkey })
const profile = await user.fetchProfile()
if (profile) {
profileCache.set(pubkey, profile)
}
return profile
}
```
### Limit Result Sets
```typescript
// Always use limit to prevent over-fetching
const filter: NDKFilter = {
kinds: [1],
authors: [pubkey],
limit: 50 // ← Important!
}
```
### Debounce Subscription Updates
```typescript
import { debounce } from 'lodash'
const debouncedUpdate = debounce((event: NDKEvent) => {
handleEvent(event)
}, 300)
sub.on('event', debouncedUpdate)
```
## Testing Tips
### Mock NDK in Tests
```typescript
const mockNDK = {
fetchEvents: vi.fn().mockResolvedValue(new Set()),
subscribe: vi.fn().mockReturnValue({
on: vi.fn(),
stop: vi.fn()
}),
signer: {
user: vi.fn().mockResolvedValue({ pubkey: 'test-pubkey' })
}
} as unknown as NDK
```
### Test Event Creation
```typescript
const createTestEvent = (overrides?: Partial<NDKEvent>): NDKEvent => {
return {
id: 'test-id',
kind: 1,
content: 'test content',
tags: [],
created_at: Math.floor(Date.now() / 1000),
pubkey: 'test-pubkey',
sig: 'test-sig',
...overrides
} as NDKEvent
}
```
---
For more detailed information, see:
- `ndk-skill.md` - Complete reference
- `quick-reference.md` - Quick lookup
- `examples/` - Code examples

View File

@@ -0,0 +1,978 @@
---
name: nostr-websocket
description: This skill should be used when implementing, debugging, or discussing WebSocket connections for Nostr relays. Provides comprehensive knowledge of RFC 6455 WebSocket protocol, production-ready implementation patterns in Go (khatru), C++ (strfry), and Rust (nostr-rs-relay), including connection lifecycle, message framing, subscription management, and performance optimization techniques specific to Nostr relay operations.
---
# Nostr WebSocket Programming
## Overview
Implement robust, high-performance WebSocket connections for Nostr relays following RFC 6455 specifications and battle-tested production patterns. This skill provides comprehensive guidance on WebSocket protocol fundamentals, connection management, message handling, and language-specific implementation strategies using proven codebases.
## Core WebSocket Protocol (RFC 6455)
### Connection Upgrade Handshake
The WebSocket connection begins with an HTTP upgrade request:
**Client Request Headers:**
- `Upgrade: websocket` - Required
- `Connection: Upgrade` - Required
- `Sec-WebSocket-Key` - 16-byte random value, base64-encoded
- `Sec-WebSocket-Version: 13` - Required
- `Origin` - Required for browser clients (security)
**Server Response (HTTP 101):**
- `HTTP/1.1 101 Switching Protocols`
- `Upgrade: websocket`
- `Connection: Upgrade`
- `Sec-WebSocket-Accept` - SHA-1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"), base64-encoded
**Security validation:** Always verify the `Sec-WebSocket-Accept` value matches expected computation. Reject connections with missing or incorrect values.
### Frame Structure
WebSocket frames use binary encoding with variable-length fields:
**Header (minimum 2 bytes):**
- **FIN bit** (1 bit) - Final fragment indicator
- **RSV1-3** (3 bits) - Reserved for extensions (must be 0)
- **Opcode** (4 bits) - Frame type identifier
- **MASK bit** (1 bit) - Payload masking indicator
- **Payload length** (7, 7+16, or 7+64 bits) - Variable encoding
**Payload length encoding:**
- 0-125: Direct 7-bit value
- 126: Next 16 bits contain length
- 127: Next 64 bits contain length
### Frame Opcodes
**Data Frames:**
- `0x0` - Continuation frame
- `0x1` - Text frame (UTF-8)
- `0x2` - Binary frame
**Control Frames:**
- `0x8` - Connection close
- `0x9` - Ping
- `0xA` - Pong
**Control frame constraints:**
- Maximum 125-byte payload
- Cannot be fragmented
- Must be processed immediately
### Masking Requirements
**Critical security requirement:**
- Client-to-server frames MUST be masked
- Server-to-client frames MUST NOT be masked
- Masking uses XOR with 4-byte random key
- Prevents cache poisoning and intermediary attacks
**Masking algorithm:**
```
transformed[i] = original[i] XOR masking_key[i MOD 4]
```
### Ping/Pong Keep-Alive
**Purpose:** Detect broken connections and maintain NAT traversal
**Pattern:**
1. Either endpoint sends Ping (0x9) with optional payload
2. Recipient responds with Pong (0xA) containing identical payload
3. Implement timeouts to detect unresponsive connections
**Nostr relay recommendations:**
- Send pings every 30-60 seconds
- Timeout after 60-120 seconds without pong response
- Close connections exceeding timeout threshold
### Close Handshake
**Initiation:** Either peer sends Close frame (0x8)
**Close frame structure:**
- Optional 2-byte status code
- Optional UTF-8 reason string
**Common status codes:**
- `1000` - Normal closure
- `1001` - Going away (server shutdown/navigation)
- `1002` - Protocol error
- `1003` - Unsupported data type
- `1006` - Abnormal closure (no close frame)
- `1011` - Server error
**Proper shutdown sequence:**
1. Initiator sends Close frame
2. Recipient responds with Close frame
3. Both close TCP connection
## Nostr Relay WebSocket Architecture
### Message Flow Overview
```
Client Relay
| |
|--- HTTP Upgrade ------->|
|<-- 101 Switching -------|
| |
|--- ["EVENT", {...}] --->| (Validate, store, broadcast)
|<-- ["OK", id, ...] -----|
| |
|--- ["REQ", id, {...}]-->| (Query + subscribe)
|<-- ["EVENT", id, {...}]-| (Stored events)
|<-- ["EOSE", id] --------| (End of stored)
|<-- ["EVENT", id, {...}]-| (Real-time events)
| |
|--- ["CLOSE", id] ------>| (Unsubscribe)
| |
|--- Close Frame -------->|
|<-- Close Frame ---------|
```
### Critical Concurrency Considerations
**Write concurrency:** WebSocket libraries panic/error on concurrent writes. Always protect writes with:
- Mutex locks (Go, C++)
- Single-writer goroutine/thread pattern
- Message queue with dedicated sender
**Read concurrency:** Concurrent reads generally allowed but not useful - implement single reader loop per connection.
**Subscription management:** Concurrent access to subscription maps requires synchronization or lock-free data structures.
## Language-Specific Implementation Patterns
### Go Implementation (khatru-style)
**Recommended library:** `github.com/fasthttp/websocket`
**Connection structure:**
```go
type WebSocket struct {
conn *websocket.Conn
mutex sync.Mutex // Protects writes
Request *http.Request // Original HTTP request
Context context.Context // Cancellation context
cancel context.CancelFunc
// NIP-42 authentication
Challenge string
AuthedPublicKey string
// Concurrent session management
negentropySessions *xsync.MapOf[string, *NegentropySession]
}
// Thread-safe write
func (ws *WebSocket) WriteJSON(v any) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteJSON(v)
}
```
**Lifecycle pattern (dual goroutines):**
```go
// Read goroutine
go func() {
defer cleanup()
ws.conn.SetReadLimit(maxMessageSize)
ws.conn.SetReadDeadline(time.Now().Add(pongWait))
ws.conn.SetPongHandler(func(string) error {
ws.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
typ, msg, err := ws.conn.ReadMessage()
if err != nil {
return // Connection closed
}
if typ == websocket.PingMessage {
ws.WriteMessage(websocket.PongMessage, nil)
continue
}
// Parse and handle message in separate goroutine
go handleMessage(msg)
}
}()
// Write/ping goroutine
go func() {
defer cleanup()
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := ws.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}()
```
**Key patterns:**
- **Mutex-protected writes** - Prevent concurrent write panics
- **Context-based lifecycle** - Clean cancellation hierarchy
- **Swap-delete for subscriptions** - O(1) removal from listener arrays
- **Zero-copy string conversion** - `unsafe.String()` for message parsing
- **Goroutine-per-message** - Sequential parsing, concurrent handling
- **Hook-based extensibility** - Plugin architecture without core modifications
**Configuration constants:**
```go
WriteWait: 10 * time.Second // Write timeout
PongWait: 60 * time.Second // Pong timeout
PingPeriod: 30 * time.Second // Ping interval (< PongWait)
MaxMessageSize: 512000 // 512 KB limit
```
**Subscription management:**
```go
type listenerSpec struct {
id string
cancel context.CancelCauseFunc
index int
subrelay *Relay
}
// Efficient removal with swap-delete
func (rl *Relay) removeListenerId(ws *WebSocket, id string) {
rl.clientsMutex.Lock()
defer rl.clientsMutex.Unlock()
if specs, ok := rl.clients[ws]; ok {
for i := len(specs) - 1; i >= 0; i-- {
if specs[i].id == id {
specs[i].cancel(ErrSubscriptionClosedByClient)
specs[i] = specs[len(specs)-1]
specs = specs[:len(specs)-1]
rl.clients[ws] = specs
break
}
}
}
}
```
For detailed khatru implementation examples, see [references/khatru_implementation.md](references/khatru_implementation.md).
### C++ Implementation (strfry-style)
**Recommended library:** Custom fork of `uWebSockets` with epoll
**Architecture highlights:**
- Single-threaded I/O using epoll for connection multiplexing
- Thread pool architecture: 6 specialized pools (WebSocket, Ingester, Writer, ReqWorker, ReqMonitor, Negentropy)
- "Shared nothing" message-passing design eliminates lock contention
- Deterministic thread assignment: `connId % numThreads`
**Connection structure:**
```cpp
struct ConnectionState {
uint64_t connId;
std::string remoteAddr;
flat_str subId; // Subscription ID
std::shared_ptr<Subscription> sub;
PerMessageDeflate pmd; // Compression state
uint64_t latestEventSent = 0;
// Message parsing state
secp256k1_context *secpCtx;
std::string parseBuffer;
};
```
**Message handling pattern:**
```cpp
// WebSocket message callback
ws->onMessage([=](std::string_view msg, uWS::OpCode opCode) {
// Reuse buffer to avoid allocations
state->parseBuffer.assign(msg.data(), msg.size());
try {
auto json = nlohmann::json::parse(state->parseBuffer);
auto cmdStr = json[0].get<std::string>();
if (cmdStr == "EVENT") {
// Send to Ingester thread pool
auto packed = MsgIngester::Message(connId, std::move(json));
tpIngester->dispatchToThread(connId, std::move(packed));
}
else if (cmdStr == "REQ") {
// Send to ReqWorker thread pool
auto packed = MsgReq::Message(connId, std::move(json));
tpReqWorker->dispatchToThread(connId, std::move(packed));
}
} catch (std::exception &e) {
sendNotice("Error: " + std::string(e.what()));
}
});
```
**Critical performance optimizations:**
1. **Event batching** - Serialize event JSON once, reuse for thousands of subscribers:
```cpp
// Single serialization
std::string eventJson = event.toJson();
// Broadcast to all matching subscriptions
for (auto &[connId, sub] : activeSubscriptions) {
if (sub->matches(event)) {
sendToConnection(connId, eventJson); // Reuse serialized JSON
}
}
```
2. **Move semantics** - Zero-copy message passing:
```cpp
tpIngester->dispatchToThread(connId, std::move(message));
```
3. **Pre-allocated buffers** - Single reusable buffer per connection:
```cpp
state->parseBuffer.assign(msg.data(), msg.size());
```
4. **std::variant dispatch** - Type-safe without virtual function overhead:
```cpp
std::variant<MsgReq, MsgIngester, MsgWriter> message;
std::visit([](auto&& msg) { msg.handle(); }, message);
```
For detailed strfry implementation examples, see [references/strfry_implementation.md](references/strfry_implementation.md).
### Rust Implementation (nostr-rs-relay-style)
**Recommended libraries:**
- `tokio-tungstenite 0.17` - Async WebSocket support
- `tokio 1.x` - Async runtime
- `serde_json` - Message parsing
**WebSocket configuration:**
```rust
let config = WebSocketConfig {
max_send_queue: Some(1024),
max_message_size: settings.limits.max_ws_message_bytes,
max_frame_size: settings.limits.max_ws_frame_bytes,
..Default::default()
};
let ws_stream = WebSocketStream::from_raw_socket(
upgraded,
Role::Server,
Some(config),
).await;
```
**Connection state:**
```rust
pub struct ClientConn {
client_ip_addr: String,
client_id: Uuid,
subscriptions: HashMap<String, Subscription>,
max_subs: usize,
auth: Nip42AuthState,
}
pub enum Nip42AuthState {
NoAuth,
Challenge(String),
AuthPubkey(String),
}
```
**Async message loop with tokio::select!:**
```rust
async fn nostr_server(
repo: Arc<dyn NostrRepo>,
mut ws_stream: WebSocketStream<Upgraded>,
broadcast: Sender<Event>,
mut shutdown: Receiver<()>,
) {
let mut conn = ClientConn::new(client_ip);
let mut bcast_rx = broadcast.subscribe();
let mut ping_interval = tokio::time::interval(Duration::from_secs(300));
loop {
tokio::select! {
// Handle shutdown
_ = shutdown.recv() => { break; }
// Send periodic pings
_ = ping_interval.tick() => {
ws_stream.send(Message::Ping(Vec::new())).await.ok();
}
// Handle broadcast events (real-time)
Ok(event) = bcast_rx.recv() => {
for (id, sub) in conn.subscriptions() {
if sub.interested_in_event(&event) {
let msg = format!("[\"EVENT\",\"{}\",{}]", id,
serde_json::to_string(&event)?);
ws_stream.send(Message::Text(msg)).await.ok();
}
}
}
// Handle incoming client messages
Some(result) = ws_stream.next() => {
match result {
Ok(Message::Text(msg)) => {
handle_nostr_message(&msg, &mut conn).await;
}
Ok(Message::Binary(_)) => {
send_notice("binary messages not accepted").await;
}
Ok(Message::Ping(_) | Message::Pong(_)) => {
continue; // Auto-handled by tungstenite
}
Ok(Message::Close(_)) | Err(_) => {
break;
}
_ => {}
}
}
}
}
}
```
**Subscription filtering:**
```rust
pub struct ReqFilter {
pub ids: Option<Vec<String>>,
pub kinds: Option<Vec<u64>>,
pub since: Option<u64>,
pub until: Option<u64>,
pub authors: Option<Vec<String>>,
pub limit: Option<u64>,
pub tags: Option<HashMap<char, HashSet<String>>>,
}
impl ReqFilter {
pub fn interested_in_event(&self, event: &Event) -> bool {
self.ids_match(event)
&& self.since.map_or(true, |t| event.created_at >= t)
&& self.until.map_or(true, |t| event.created_at <= t)
&& self.kind_match(event.kind)
&& self.authors_match(event)
&& self.tag_match(event)
}
fn ids_match(&self, event: &Event) -> bool {
self.ids.as_ref()
.map_or(true, |ids| ids.iter().any(|id| event.id.starts_with(id)))
}
}
```
**Error handling:**
```rust
match ws_stream.next().await {
Some(Ok(Message::Text(msg))) => { /* handle */ }
Some(Err(WsError::Capacity(MessageTooLong{size, max_size}))) => {
send_notice(&format!("message too large ({} > {})", size, max_size)).await;
continue;
}
None | Some(Ok(Message::Close(_))) => {
info!("client closed connection");
break;
}
Some(Err(WsError::Io(e))) => {
warn!("IO error: {:?}", e);
break;
}
_ => { break; }
}
```
For detailed Rust implementation examples, see [references/rust_implementation.md](references/rust_implementation.md).
## Common Implementation Patterns
### Pattern 1: Dual Goroutine/Task Architecture
**Purpose:** Separate read and write concerns, enable ping/pong management
**Structure:**
- **Reader goroutine/task:** Blocks on `ReadMessage()`, handles incoming frames
- **Writer goroutine/task:** Sends periodic pings, processes outgoing message queue
**Benefits:**
- Natural separation of concerns
- Ping timer doesn't block message processing
- Clean shutdown coordination via context/channels
### Pattern 2: Subscription Lifecycle
**Create subscription (REQ):**
1. Parse filter from client message
2. Query database for matching stored events
3. Send stored events to client
4. Send EOSE (End of Stored Events)
5. Add subscription to active listeners for real-time events
**Handle real-time event:**
1. Check all active subscriptions
2. For each matching subscription:
- Apply filter matching logic
- Send EVENT message to client
3. Track broadcast count for monitoring
**Close subscription (CLOSE):**
1. Find subscription by ID
2. Cancel subscription context
3. Remove from active listeners
4. Clean up resources
### Pattern 3: Write Serialization
**Problem:** Concurrent writes cause panics/errors in WebSocket libraries
**Solutions:**
**Mutex approach (Go, C++):**
```go
func (ws *WebSocket) WriteJSON(v any) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteJSON(v)
}
```
**Single-writer goroutine (Alternative):**
```go
type writeMsg struct {
data []byte
done chan error
}
go func() {
for msg := range writeChan {
msg.done <- ws.conn.WriteMessage(websocket.TextMessage, msg.data)
}
}()
```
### Pattern 4: Connection Cleanup
**Essential cleanup steps:**
1. Cancel all subscription contexts
2. Stop ping ticker/interval
3. Remove connection from active clients map
4. Close WebSocket connection
5. Close TCP connection
6. Log connection statistics
**Go cleanup function:**
```go
kill := func() {
// Cancel contexts
cancel()
ws.cancel()
// Stop timers
ticker.Stop()
// Remove from tracking
rl.removeClientAndListeners(ws)
// Close connection
ws.conn.Close()
// Trigger hooks
for _, ondisconnect := range rl.OnDisconnect {
ondisconnect(ctx)
}
}
defer kill()
```
### Pattern 5: Event Broadcasting Optimization
**Naive approach (inefficient):**
```go
// DON'T: Serialize for each subscriber
for _, listener := range listeners {
if listener.filter.Matches(event) {
json := serializeEvent(event) // Repeated work!
listener.ws.WriteJSON(json)
}
}
```
**Optimized approach:**
```go
// DO: Serialize once, reuse for all subscribers
eventJSON, err := json.Marshal(event)
if err != nil {
return
}
for _, listener := range listeners {
if listener.filter.Matches(event) {
listener.ws.WriteMessage(websocket.TextMessage, eventJSON)
}
}
```
**Savings:** For 1000 subscribers, reduces 1000 JSON serializations to 1.
## Security Considerations
### Origin Validation
Always validate the `Origin` header for browser-based clients:
```go
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return isAllowedOrigin(origin) // Implement allowlist
},
}
```
**Default behavior:** Most libraries reject all cross-origin connections. Override with caution.
### Rate Limiting
Implement rate limits for:
- Connection establishment (per IP)
- Message throughput (per connection)
- Subscription creation (per connection)
- Event publication (per connection, per pubkey)
```go
// Example: Connection rate limiting
type rateLimiter struct {
connections map[string]*rate.Limiter
mu sync.Mutex
}
func (rl *Relay) checkRateLimit(ip string) bool {
limiter := rl.rateLimiter.getLimiter(ip)
return limiter.Allow()
}
```
### Message Size Limits
Configure limits to prevent memory exhaustion:
```go
ws.conn.SetReadLimit(maxMessageSize) // e.g., 512 KB
```
```rust
max_message_size: Some(512_000),
max_frame_size: Some(16_384),
```
### Subscription Limits
Prevent resource exhaustion:
- Max subscriptions per connection (typically 10-20)
- Max subscription ID length (prevent hash collision attacks)
- Require specific filters (prevent full database scans)
```rust
const MAX_SUBSCRIPTION_ID_LEN: usize = 256;
const MAX_SUBS_PER_CLIENT: usize = 20;
if subscriptions.len() >= MAX_SUBS_PER_CLIENT {
return Err(Error::SubMaxExceededError);
}
```
### Authentication (NIP-42)
Implement challenge-response authentication:
1. **Generate challenge on connect:**
```go
challenge := make([]byte, 8)
rand.Read(challenge)
ws.Challenge = hex.EncodeToString(challenge)
```
2. **Send AUTH challenge when required:**
```json
["AUTH", "<challenge>"]
```
3. **Validate AUTH event:**
```go
func validateAuthEvent(event *Event, challenge, relayURL string) bool {
// Check kind 22242
if event.Kind != 22242 { return false }
// Check challenge in tags
if !hasTag(event, "challenge", challenge) { return false }
// Check relay URL
if !hasTag(event, "relay", relayURL) { return false }
// Check timestamp (within 10 minutes)
if abs(time.Now().Unix() - event.CreatedAt) > 600 { return false }
// Verify signature
return event.CheckSignature()
}
```
## Performance Optimization Techniques
### 1. Connection Pooling
Reuse connections for database queries:
```go
db, _ := sql.Open("postgres", dsn)
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
```
### 2. Event Caching
Cache frequently accessed events:
```go
type EventCache struct {
cache *lru.Cache
mu sync.RWMutex
}
func (ec *EventCache) Get(id string) (*Event, bool) {
ec.mu.RLock()
defer ec.mu.RUnlock()
if val, ok := ec.cache.Get(id); ok {
return val.(*Event), true
}
return nil, false
}
```
### 3. Batch Database Queries
Execute queries concurrently for multi-filter subscriptions:
```go
var wg sync.WaitGroup
for _, filter := range filters {
wg.Add(1)
go func(f Filter) {
defer wg.Done()
events := queryDatabase(f)
sendEvents(events)
}(filter)
}
wg.Wait()
sendEOSE()
```
### 4. Compression (permessage-deflate)
Enable WebSocket compression for text frames:
```go
upgrader := websocket.Upgrader{
EnableCompression: true,
}
```
**Typical savings:** 60-80% bandwidth reduction for JSON messages
**Trade-off:** Increased CPU usage (usually worthwhile)
### 5. Monitoring and Metrics
Track key performance indicators:
- Connections (active, total, per IP)
- Messages (received, sent, per type)
- Events (stored, broadcast, per second)
- Subscriptions (active, per connection)
- Query latency (p50, p95, p99)
- Database pool utilization
```go
// Prometheus-style metrics
type Metrics struct {
Connections prometheus.Gauge
MessagesRecv prometheus.Counter
MessagesSent prometheus.Counter
EventsStored prometheus.Counter
QueryDuration prometheus.Histogram
}
```
## Testing WebSocket Implementations
### Unit Testing
Test individual components in isolation:
```go
func TestFilterMatching(t *testing.T) {
filter := Filter{
Kinds: []int{1, 3},
Authors: []string{"abc123"},
}
event := &Event{
Kind: 1,
PubKey: "abc123",
}
if !filter.Matches(event) {
t.Error("Expected filter to match event")
}
}
```
### Integration Testing
Test WebSocket connection handling:
```go
func TestWebSocketConnection(t *testing.T) {
// Start test server
server := startTestRelay(t)
defer server.Close()
// Connect client
ws, _, err := websocket.DefaultDialer.Dial(server.URL, nil)
if err != nil {
t.Fatalf("Failed to connect: %v", err)
}
defer ws.Close()
// Send REQ
req := `["REQ","test",{"kinds":[1]}]`
if err := ws.WriteMessage(websocket.TextMessage, []byte(req)); err != nil {
t.Fatalf("Failed to send REQ: %v", err)
}
// Read EOSE
_, msg, err := ws.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if !strings.Contains(string(msg), "EOSE") {
t.Errorf("Expected EOSE, got: %s", msg)
}
}
```
### Load Testing
Use tools like `websocat` or custom scripts:
```bash
# Connect 1000 concurrent clients
for i in {1..1000}; do
(websocat "ws://localhost:8080" <<< '["REQ","test",{"kinds":[1]}]' &)
done
```
Monitor server metrics during load testing:
- CPU usage
- Memory consumption
- Connection count
- Message throughput
- Database query rate
## Debugging and Troubleshooting
### Common Issues
**1. Concurrent write panic/error**
**Symptom:** `concurrent write to websocket connection` error
**Solution:** Ensure all writes protected by mutex or use single-writer pattern
**2. Connection timeouts**
**Symptom:** Connections close after 60 seconds
**Solution:** Implement ping/pong mechanism properly:
```go
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
```
**3. Memory leaks**
**Symptom:** Memory usage grows over time
**Common causes:**
- Subscriptions not removed on disconnect
- Event channels not closed
- Goroutines not terminated
**Solution:** Ensure cleanup function called on disconnect
**4. Slow subscription queries**
**Symptom:** EOSE delayed by seconds
**Solution:**
- Add database indexes on filtered columns
- Implement query timeouts
- Consider caching frequently accessed events
### Logging Best Practices
Log critical events with context:
```go
log.Printf(
"connection closed: cid=%s ip=%s duration=%v sent=%d recv=%d",
conn.ID,
conn.IP,
time.Since(conn.ConnectedAt),
conn.EventsSent,
conn.EventsRecv,
)
```
Use log levels appropriately:
- **DEBUG:** Message parsing, filter matching
- **INFO:** Connection lifecycle, subscription changes
- **WARN:** Rate limit violations, invalid messages
- **ERROR:** Database errors, unexpected panics
## Resources
This skill includes comprehensive reference documentation with production code examples:
### references/
- **websocket_protocol.md** - Complete RFC 6455 specification details including frame structure, opcodes, masking algorithm, and security considerations
- **khatru_implementation.md** - Go WebSocket patterns from khatru including connection lifecycle, subscription management, and performance optimizations (3000+ lines)
- **strfry_implementation.md** - C++ high-performance patterns from strfry including thread pool architecture, message batching, and zero-copy techniques (2000+ lines)
- **rust_implementation.md** - Rust async patterns from nostr-rs-relay including tokio::select! usage, error handling, and subscription filtering (2000+ lines)
Load these references when implementing specific language solutions or troubleshooting complex WebSocket issues.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,921 @@
# C++ WebSocket Implementation for Nostr Relays (strfry patterns)
This reference documents high-performance WebSocket patterns from the strfry Nostr relay implementation in C++.
## Repository Information
- **Project:** strfry - High-performance Nostr relay
- **Repository:** https://github.com/hoytech/strfry
- **Language:** C++ (C++20)
- **WebSocket Library:** Custom fork of uWebSockets with epoll
- **Architecture:** Single-threaded I/O with specialized thread pools
## Core Architecture
### Thread Pool Design
strfry uses 6 specialized thread pools for different operations:
```
┌─────────────────────────────────────────────────────────────┐
│ Main Thread (I/O) │
│ - epoll event loop │
│ - WebSocket message reception │
│ - Connection management │
└─────────────────────────────────────────────────────────────┘
┌───────────────────┼───────────────────┐
│ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼────┐
│Ingester │ │ReqWorker│ │Negentropy│
│ (3) │ │ (3) │ │ (2) │
└─────────┘ └─────────┘ └─────────┘
│ │ │
┌────▼────┐ ┌───▼────┐
│ Writer │ │ReqMonitor│
│ (1) │ │ (3) │
└─────────┘ └─────────┘
```
**Thread Pool Responsibilities:**
1. **WebSocket (1 thread):** Main I/O loop, epoll event handling
2. **Ingester (3 threads):** Event validation, signature verification, deduplication
3. **Writer (1 thread):** Database writes, event storage
4. **ReqWorker (3 threads):** Process REQ subscriptions, query database
5. **ReqMonitor (3 threads):** Monitor active subscriptions, send real-time events
6. **Negentropy (2 threads):** NIP-77 set reconciliation
**Deterministic thread assignment:**
```cpp
int threadId = connId % numThreads;
```
**Benefits:**
- **No lock contention:** Shared-nothing architecture
- **Predictable performance:** Same connection always same thread
- **CPU cache efficiency:** Thread-local data stays hot
### Connection State
```cpp
struct ConnectionState {
uint64_t connId; // Unique connection identifier
std::string remoteAddr; // Client IP address
// Subscription state
flat_str subId; // Current subscription ID
std::shared_ptr<Subscription> sub; // Subscription filter
uint64_t latestEventSent = 0; // Latest event ID sent
// Compression state (per-message deflate)
PerMessageDeflate pmd;
// Parsing state (reused buffer)
std::string parseBuffer;
// Signature verification context (reused)
secp256k1_context *secpCtx;
};
```
**Key design decisions:**
1. **Reusable parseBuffer:** Single allocation per connection
2. **Persistent secp256k1_context:** Expensive to create, reused for all signatures
3. **Connection ID:** Enables deterministic thread assignment
4. **Flat string (flat_str):** Value-semantic string-like type for zero-copy
## WebSocket Message Reception
### Main Event Loop (epoll)
```cpp
// Pseudocode representation of strfry's I/O loop
uWS::App app;
app.ws<ConnectionState>("/*", {
.compression = uWS::SHARED_COMPRESSOR,
.maxPayloadLength = 16 * 1024 * 1024,
.idleTimeout = 120,
.maxBackpressure = 1 * 1024 * 1024,
.upgrade = nullptr,
.open = [](auto *ws) {
auto *state = ws->getUserData();
state->connId = nextConnId++;
state->remoteAddr = getRemoteAddress(ws);
state->secpCtx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY);
LI << "New connection: " << state->connId << " from " << state->remoteAddr;
},
.message = [](auto *ws, std::string_view message, uWS::OpCode opCode) {
auto *state = ws->getUserData();
// Reuse parseBuffer to avoid allocation
state->parseBuffer.assign(message.data(), message.size());
try {
// Parse JSON (nlohmann::json)
auto json = nlohmann::json::parse(state->parseBuffer);
// Extract command type
auto cmdStr = json[0].get<std::string>();
if (cmdStr == "EVENT") {
handleEventMessage(ws, std::move(json));
}
else if (cmdStr == "REQ") {
handleReqMessage(ws, std::move(json));
}
else if (cmdStr == "CLOSE") {
handleCloseMessage(ws, std::move(json));
}
else if (cmdStr == "NEG-OPEN") {
handleNegentropyOpen(ws, std::move(json));
}
else {
sendNotice(ws, "unknown command: " + cmdStr);
}
}
catch (std::exception &e) {
sendNotice(ws, "Error: " + std::string(e.what()));
}
},
.close = [](auto *ws, int code, std::string_view message) {
auto *state = ws->getUserData();
LI << "Connection closed: " << state->connId
<< " code=" << code
<< " msg=" << std::string(message);
// Cleanup
secp256k1_context_destroy(state->secpCtx);
cleanupSubscription(state->connId);
},
});
app.listen(8080, [](auto *token) {
if (token) {
LI << "Listening on port 8080";
}
});
app.run();
```
**Key patterns:**
1. **epoll-based I/O:** Single thread handles thousands of connections
2. **Buffer reuse:** `state->parseBuffer` avoids allocation per message
3. **Move semantics:** `std::move(json)` transfers ownership to handler
4. **Exception handling:** Catches parsing errors, sends NOTICE
### Message Dispatch to Thread Pools
```cpp
void handleEventMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Pack message with connection ID
auto msg = MsgIngester{
.connId = state->connId,
.payload = std::move(json),
};
// Dispatch to Ingester thread pool (deterministic assignment)
tpIngester->dispatchToThread(state->connId, std::move(msg));
}
void handleReqMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Pack message
auto msg = MsgReq{
.connId = state->connId,
.payload = std::move(json),
};
// Dispatch to ReqWorker thread pool
tpReqWorker->dispatchToThread(state->connId, std::move(msg));
}
```
**Message passing pattern:**
```cpp
// ThreadPool::dispatchToThread
void dispatchToThread(uint64_t connId, Message &&msg) {
size_t threadId = connId % threads.size();
threads[threadId]->queue.push(std::move(msg));
}
```
**Benefits:**
- **Zero-copy:** `std::move` transfers ownership without copying
- **Deterministic:** Same connection always processed by same thread
- **Lock-free:** Each thread has own queue
## Event Ingestion Pipeline
### Ingester Thread Pool
```cpp
void IngesterThread::run() {
while (running) {
Message msg;
if (!queue.pop(msg, 100ms)) continue;
// Extract event from JSON
auto event = parseEvent(msg.payload);
// Validate event ID
if (!validateEventId(event)) {
sendOK(msg.connId, event.id, false, "invalid: id mismatch");
continue;
}
// Verify signature (using thread-local secp256k1 context)
if (!verifySignature(event, secpCtx)) {
sendOK(msg.connId, event.id, false, "invalid: signature verification failed");
continue;
}
// Check for duplicate (bloom filter + database)
if (isDuplicate(event.id)) {
sendOK(msg.connId, event.id, true, "duplicate: already have this event");
continue;
}
// Send to Writer thread
auto writerMsg = MsgWriter{
.connId = msg.connId,
.event = std::move(event),
};
tpWriter->dispatch(std::move(writerMsg));
}
}
```
**Validation sequence:**
1. Parse JSON into Event struct
2. Validate event ID matches content hash
3. Verify secp256k1 signature
4. Check duplicate (bloom filter for speed)
5. Forward to Writer thread for storage
### Writer Thread
```cpp
void WriterThread::run() {
// Single thread for all database writes
while (running) {
Message msg;
if (!queue.pop(msg, 100ms)) continue;
// Write to database
bool success = db.insertEvent(msg.event);
// Send OK to client
sendOK(msg.connId, msg.event.id, success,
success ? "" : "error: failed to store");
if (success) {
// Broadcast to subscribers
broadcastEvent(msg.event);
}
}
}
```
**Single-writer pattern:**
- Only one thread writes to database
- Eliminates write conflicts
- Simplified transaction management
### Event Broadcasting
```cpp
void broadcastEvent(const Event &event) {
// Serialize event JSON once
std::string eventJson = serializeEvent(event);
// Iterate all active subscriptions
for (auto &[connId, sub] : activeSubscriptions) {
// Check if filter matches
if (!sub->filter.matches(event)) continue;
// Check if event newer than last sent
if (event.id <= sub->latestEventSent) continue;
// Send to connection
auto msg = MsgWebSocket{
.connId = connId,
.payload = eventJson, // Reuse serialized JSON
};
tpWebSocket->dispatch(std::move(msg));
// Update latest sent
sub->latestEventSent = event.id;
}
}
```
**Critical optimization:** Serialize event JSON once, send to N subscribers
**Performance impact:** For 1000 subscribers, reduces:
- JSON serialization: 1000× → 1×
- Memory allocations: 1000× → 1×
- CPU time: ~100ms → ~1ms
## Subscription Management
### REQ Processing
```cpp
void ReqWorkerThread::run() {
while (running) {
MsgReq msg;
if (!queue.pop(msg, 100ms)) continue;
// Parse REQ message: ["REQ", subId, filter1, filter2, ...]
std::string subId = msg.payload[1];
// Create subscription object
auto sub = std::make_shared<Subscription>();
sub->subId = subId;
// Parse filters
for (size_t i = 2; i < msg.payload.size(); i++) {
Filter filter = parseFilter(msg.payload[i]);
sub->filters.push_back(filter);
}
// Store subscription
activeSubscriptions[msg.connId] = sub;
// Query stored events
std::vector<Event> events = db.queryEvents(sub->filters);
// Send matching events
for (const auto &event : events) {
sendEvent(msg.connId, subId, event);
}
// Send EOSE
sendEOSE(msg.connId, subId);
// Notify ReqMonitor to watch for real-time events
auto monitorMsg = MsgReqMonitor{
.connId = msg.connId,
.subId = subId,
};
tpReqMonitor->dispatchToThread(msg.connId, std::move(monitorMsg));
}
}
```
**Query optimization:**
```cpp
std::vector<Event> Database::queryEvents(const std::vector<Filter> &filters) {
// Combine filters with OR logic
std::string sql = "SELECT * FROM events WHERE ";
for (size_t i = 0; i < filters.size(); i++) {
if (i > 0) sql += " OR ";
sql += buildFilterSQL(filters[i]);
}
sql += " ORDER BY created_at DESC LIMIT 1000";
return executeQuery(sql);
}
```
**Filter SQL generation:**
```cpp
std::string buildFilterSQL(const Filter &filter) {
std::vector<std::string> conditions;
// Event IDs
if (!filter.ids.empty()) {
conditions.push_back("id IN (" + joinQuoted(filter.ids) + ")");
}
// Authors
if (!filter.authors.empty()) {
conditions.push_back("pubkey IN (" + joinQuoted(filter.authors) + ")");
}
// Kinds
if (!filter.kinds.empty()) {
conditions.push_back("kind IN (" + join(filter.kinds) + ")");
}
// Time range
if (filter.since) {
conditions.push_back("created_at >= " + std::to_string(*filter.since));
}
if (filter.until) {
conditions.push_back("created_at <= " + std::to_string(*filter.until));
}
// Tags (requires JOIN with tags table)
if (!filter.tags.empty()) {
for (const auto &[tagName, tagValues] : filter.tags) {
conditions.push_back(
"EXISTS (SELECT 1 FROM tags WHERE tags.event_id = events.id "
"AND tags.name = '" + tagName + "' "
"AND tags.value IN (" + joinQuoted(tagValues) + "))"
);
}
}
return "(" + join(conditions, " AND ") + ")";
}
```
### ReqMonitor for Real-Time Events
```cpp
void ReqMonitorThread::run() {
// Subscribe to event broadcast channel
auto eventSubscription = subscribeToEvents();
while (running) {
Event event;
if (!eventSubscription.receive(event, 100ms)) continue;
// Check all subscriptions assigned to this thread
for (auto &[connId, sub] : mySubscriptions) {
// Only process subscriptions for this thread
if (connId % numThreads != threadId) continue;
// Check if filter matches
bool matches = false;
for (const auto &filter : sub->filters) {
if (filter.matches(event)) {
matches = true;
break;
}
}
if (matches) {
sendEvent(connId, sub->subId, event);
}
}
}
}
```
**Pattern:** Monitor thread watches event stream, sends to matching subscriptions
### CLOSE Handling
```cpp
void handleCloseMessage(auto *ws, nlohmann::json &&json) {
auto *state = ws->getUserData();
// Parse CLOSE message: ["CLOSE", subId]
std::string subId = json[1];
// Remove subscription
activeSubscriptions.erase(state->connId);
LI << "Subscription closed: connId=" << state->connId
<< " subId=" << subId;
}
```
## Performance Optimizations
### 1. Event Batching
**Problem:** Serializing same event 1000× for 1000 subscribers is wasteful
**Solution:** Serialize once, send to all
```cpp
// BAD: Serialize for each subscriber
for (auto &sub : subscriptions) {
std::string json = serializeEvent(event); // Repeated!
send(sub.connId, json);
}
// GOOD: Serialize once
std::string json = serializeEvent(event);
for (auto &sub : subscriptions) {
send(sub.connId, json); // Reuse!
}
```
**Measurement:** For 1000 subscribers, reduces broadcast time from 100ms to 1ms
### 2. Move Semantics
**Problem:** Copying large JSON objects is expensive
**Solution:** Transfer ownership with `std::move`
```cpp
// BAD: Copies JSON object
void dispatch(Message msg) {
queue.push(msg); // Copy
}
// GOOD: Moves JSON object
void dispatch(Message &&msg) {
queue.push(std::move(msg)); // Move
}
```
**Benefit:** Zero-copy message passing between threads
### 3. Pre-allocated Buffers
**Problem:** Allocating buffer for each message
**Solution:** Reuse buffer per connection
```cpp
struct ConnectionState {
std::string parseBuffer; // Reused for all messages
};
void handleMessage(std::string_view msg) {
state->parseBuffer.assign(msg.data(), msg.size());
auto json = nlohmann::json::parse(state->parseBuffer);
// ...
}
```
**Benefit:** Eliminates 10,000+ allocations/second per connection
### 4. std::variant for Message Types
**Problem:** Virtual function calls for polymorphic messages
**Solution:** `std::variant` with `std::visit`
```cpp
// BAD: Virtual function (pointer indirection, vtable lookup)
struct Message {
virtual void handle() = 0;
};
// GOOD: std::variant (no indirection, inlined)
using Message = std::variant<
MsgIngester,
MsgReq,
MsgWriter,
MsgWebSocket
>;
void handle(Message &&msg) {
std::visit([](auto &&m) { m.handle(); }, msg);
}
```
**Benefit:** Compiler inlines visit, eliminates virtual call overhead
### 5. Bloom Filter for Duplicate Detection
**Problem:** Database query for every event to check duplicate
**Solution:** In-memory bloom filter for fast negative
```cpp
class DuplicateDetector {
BloomFilter bloom; // Fast probabilistic check
bool isDuplicate(const std::string &eventId) {
// Fast negative (definitely not seen)
if (!bloom.contains(eventId)) {
bloom.insert(eventId);
return false;
}
// Possible positive (maybe seen, check database)
if (db.eventExists(eventId)) {
return true;
}
// False positive
bloom.insert(eventId);
return false;
}
};
```
**Benefit:** 99% of duplicate checks avoid database query
### 6. Batch Queue Operations
**Problem:** Lock contention on message queue
**Solution:** Batch multiple pushes with single lock
```cpp
class MessageQueue {
std::mutex mutex;
std::deque<Message> queue;
void pushBatch(std::vector<Message> &messages) {
std::lock_guard lock(mutex);
for (auto &msg : messages) {
queue.push_back(std::move(msg));
}
}
};
```
**Benefit:** Reduces lock acquisitions by 10-100×
### 7. ZSTD Dictionary Compression
**Problem:** WebSocket compression slower than desired
**Solution:** Train ZSTD dictionary on typical Nostr messages
```cpp
// Train dictionary on corpus of Nostr events
std::string corpus = collectTypicalEvents();
ZSTD_CDict *dict = ZSTD_createCDict(
corpus.data(), corpus.size(),
compressionLevel
);
// Use dictionary for compression
size_t compressedSize = ZSTD_compress_usingCDict(
cctx, dst, dstSize,
src, srcSize, dict
);
```
**Benefit:** 10-20% better compression ratio, 2× faster decompression
### 8. String Views
**Problem:** Unnecessary string copies when parsing
**Solution:** Use `std::string_view` for zero-copy
```cpp
// BAD: Copies substring
std::string extractCommand(const std::string &msg) {
return msg.substr(0, 5); // Copy
}
// GOOD: View into original string
std::string_view extractCommand(std::string_view msg) {
return msg.substr(0, 5); // No copy
}
```
**Benefit:** Eliminates allocations during parsing
## Compression (permessage-deflate)
### WebSocket Compression Configuration
```cpp
struct PerMessageDeflate {
z_stream deflate_stream;
z_stream inflate_stream;
// Sliding window for compression history
static constexpr int WINDOW_BITS = 15;
static constexpr int MEM_LEVEL = 8;
void init() {
// Initialize deflate (compression)
deflate_stream.zalloc = Z_NULL;
deflate_stream.zfree = Z_NULL;
deflate_stream.opaque = Z_NULL;
deflateInit2(&deflate_stream,
Z_DEFAULT_COMPRESSION,
Z_DEFLATED,
-WINDOW_BITS, // Negative = no zlib header
MEM_LEVEL,
Z_DEFAULT_STRATEGY);
// Initialize inflate (decompression)
inflate_stream.zalloc = Z_NULL;
inflate_stream.zfree = Z_NULL;
inflate_stream.opaque = Z_NULL;
inflateInit2(&inflate_stream, -WINDOW_BITS);
}
std::string compress(std::string_view data) {
// Compress with sliding window
deflate_stream.next_in = (Bytef*)data.data();
deflate_stream.avail_in = data.size();
std::string compressed;
compressed.resize(deflateBound(&deflate_stream, data.size()));
deflate_stream.next_out = (Bytef*)compressed.data();
deflate_stream.avail_out = compressed.size();
deflate(&deflate_stream, Z_SYNC_FLUSH);
compressed.resize(compressed.size() - deflate_stream.avail_out);
return compressed;
}
};
```
**Typical compression ratios:**
- JSON events: 60-80% reduction
- Subscription filters: 40-60% reduction
- Binary events: 10-30% reduction
## Database Schema (LMDB)
strfry uses LMDB (Lightning Memory-Mapped Database) for event storage:
```cpp
// Key-value stores
struct EventDB {
// Primary event storage (key: event ID, value: event data)
lmdb::dbi eventsDB;
// Index by pubkey (key: pubkey + created_at, value: event ID)
lmdb::dbi pubkeyDB;
// Index by kind (key: kind + created_at, value: event ID)
lmdb::dbi kindDB;
// Index by tags (key: tag_name + tag_value + created_at, value: event ID)
lmdb::dbi tagsDB;
// Deletion index (key: event ID, value: deletion event ID)
lmdb::dbi deletionsDB;
};
```
**Why LMDB?**
- Memory-mapped I/O (kernel manages caching)
- Copy-on-write (MVCC without locks)
- Ordered keys (enables range queries)
- Crash-proof (no corruption on power loss)
## Monitoring and Metrics
### Connection Statistics
```cpp
struct RelayStats {
std::atomic<uint64_t> totalConnections{0};
std::atomic<uint64_t> activeConnections{0};
std::atomic<uint64_t> eventsReceived{0};
std::atomic<uint64_t> eventsSent{0};
std::atomic<uint64_t> bytesReceived{0};
std::atomic<uint64_t> bytesSent{0};
void recordConnection() {
totalConnections.fetch_add(1, std::memory_order_relaxed);
activeConnections.fetch_add(1, std::memory_order_relaxed);
}
void recordDisconnection() {
activeConnections.fetch_sub(1, std::memory_order_relaxed);
}
void recordEventReceived(size_t bytes) {
eventsReceived.fetch_add(1, std::memory_order_relaxed);
bytesReceived.fetch_add(bytes, std::memory_order_relaxed);
}
};
```
**Atomic operations:** Lock-free updates from multiple threads
### Performance Metrics
```cpp
struct PerformanceMetrics {
// Latency histograms
Histogram eventIngestionLatency;
Histogram subscriptionQueryLatency;
Histogram eventBroadcastLatency;
// Thread pool queue depths
std::atomic<size_t> ingesterQueueDepth{0};
std::atomic<size_t> writerQueueDepth{0};
std::atomic<size_t> reqWorkerQueueDepth{0};
void recordIngestion(std::chrono::microseconds duration) {
eventIngestionLatency.record(duration.count());
}
};
```
## Configuration
### relay.conf Example
```ini
[relay]
bind = 0.0.0.0
port = 8080
maxConnections = 10000
maxMessageSize = 16777216 # 16 MB
[ingester]
threads = 3
queueSize = 10000
[writer]
threads = 1
queueSize = 1000
batchSize = 100
[reqWorker]
threads = 3
queueSize = 10000
[db]
path = /var/lib/strfry/events.lmdb
maxSizeGB = 100
```
## Deployment Considerations
### System Limits
```bash
# Increase file descriptor limit
ulimit -n 65536
# Increase maximum socket connections
sysctl -w net.core.somaxconn=4096
# TCP tuning
sysctl -w net.ipv4.tcp_fin_timeout=15
sysctl -w net.ipv4.tcp_tw_reuse=1
```
### Memory Requirements
**Per connection:**
- ConnectionState: ~1 KB
- WebSocket buffers: ~32 KB (16 KB send + 16 KB receive)
- Compression state: ~400 KB (200 KB deflate + 200 KB inflate)
**Total:** ~433 KB per connection
**For 10,000 connections:** ~4.3 GB
### CPU Requirements
**Single-core can handle:**
- 1000 concurrent connections
- 10,000 events/sec ingestion
- 100,000 events/sec broadcast (cached)
**Recommended:**
- 8+ cores for 10,000 connections
- 16+ cores for 50,000 connections
## Summary
**Key architectural patterns:**
1. **Single-threaded I/O:** epoll handles all connections in one thread
2. **Specialized thread pools:** Different operations use dedicated threads
3. **Deterministic assignment:** Connection ID determines thread assignment
4. **Move semantics:** Zero-copy message passing
5. **Event batching:** Serialize once, send to many
6. **Pre-allocated buffers:** Reuse memory per connection
7. **Bloom filters:** Fast duplicate detection
8. **LMDB:** Memory-mapped database for zero-copy reads
**Performance characteristics:**
- **50,000+ concurrent connections** per server
- **100,000+ events/sec** throughput
- **Sub-millisecond** latency for broadcasts
- **10 GB+ event database** with fast queries
**When to use strfry patterns:**
- Need maximum performance (trading complexity)
- Have C++ expertise on team
- Running large public relay (thousands of users)
- Want minimal memory footprint
- Need to scale to 50K+ connections
**Trade-offs:**
- **Complexity:** More complex than Go/Rust implementations
- **Portability:** Linux-specific (epoll, LMDB)
- **Development speed:** Slower iteration than higher-level languages
**Further reading:**
- strfry repository: https://github.com/hoytech/strfry
- uWebSockets: https://github.com/uNetworking/uWebSockets
- LMDB: http://www.lmdb.tech/doc/
- epoll: https://man7.org/linux/man-pages/man7/epoll.7.html

View File

@@ -0,0 +1,881 @@
# WebSocket Protocol (RFC 6455) - Complete Reference
## Connection Establishment
### HTTP Upgrade Handshake
The WebSocket protocol begins as an HTTP request that upgrades to WebSocket:
**Client Request:**
```http
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
```
**Server Response:**
```http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
```
### Handshake Details
**Sec-WebSocket-Key Generation (Client):**
1. Generate 16 random bytes
2. Base64-encode the result
3. Send in `Sec-WebSocket-Key` header
**Sec-WebSocket-Accept Computation (Server):**
1. Concatenate client key with GUID: `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`
2. Compute SHA-1 hash of concatenated string
3. Base64-encode the hash
4. Send in `Sec-WebSocket-Accept` header
**Example computation:**
```
Client Key: dGhlIHNhbXBsZSBub25jZQ==
Concatenated: dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
SHA-1 Hash: b37a4f2cc0cb4e7e8cf769a5f3f8f2e8e4c9f7a3
Base64: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
```
**Validation (Client):**
- Verify HTTP status is 101
- Verify `Sec-WebSocket-Accept` matches expected value
- If validation fails, do not establish connection
### Origin Header
The `Origin` header provides protection against cross-site WebSocket hijacking:
**Server-side validation:**
```go
func checkOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
allowedOrigins := []string{
"https://example.com",
"https://app.example.com",
}
for _, allowed := range allowedOrigins {
if origin == allowed {
return true
}
}
return false
}
```
**Security consideration:** Browser-based clients MUST send Origin header. Non-browser clients MAY omit it. Servers SHOULD validate Origin for browser clients to prevent CSRF attacks.
## Frame Format
### Base Framing Protocol
WebSocket frames use a binary format with variable-length fields:
```
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
```
### Frame Header Fields
**FIN (1 bit):**
- `1` = Final fragment in message
- `0` = More fragments follow
- Used for message fragmentation
**RSV1, RSV2, RSV3 (1 bit each):**
- Reserved for extensions
- MUST be 0 unless extension negotiated
- Server MUST fail connection if non-zero with no extension
**Opcode (4 bits):**
- Defines interpretation of payload data
- See "Frame Opcodes" section below
**MASK (1 bit):**
- `1` = Payload is masked (required for client-to-server)
- `0` = Payload is not masked (required for server-to-client)
- Client MUST mask all frames sent to server
- Server MUST NOT mask frames sent to client
**Payload Length (7 bits, 7+16 bits, or 7+64 bits):**
- If 0-125: Actual payload length
- If 126: Next 2 bytes are 16-bit unsigned payload length
- If 127: Next 8 bytes are 64-bit unsigned payload length
**Masking-key (0 or 4 bytes):**
- Present if MASK bit is set
- 32-bit value used to mask payload
- MUST be unpredictable (strong entropy source)
### Frame Opcodes
**Data Frame Opcodes:**
- `0x0` - Continuation Frame
- Used for fragmented messages
- Must follow initial data frame (text/binary)
- Carries same data type as initial frame
- `0x1` - Text Frame
- Payload is UTF-8 encoded text
- MUST be valid UTF-8
- Endpoint MUST fail connection if invalid UTF-8
- `0x2` - Binary Frame
- Payload is arbitrary binary data
- Application interprets data
- `0x3-0x7` - Reserved for future non-control frames
**Control Frame Opcodes:**
- `0x8` - Connection Close
- Initiates or acknowledges connection closure
- MAY contain status code and reason
- See "Close Handshake" section
- `0x9` - Ping
- Heartbeat mechanism
- MAY contain application data
- Recipient MUST respond with Pong
- `0xA` - Pong
- Response to Ping
- MUST contain identical payload as Ping
- MAY be sent unsolicited (unidirectional heartbeat)
- `0xB-0xF` - Reserved for future control frames
### Control Frame Constraints
**Control frames are subject to strict rules:**
1. **Maximum payload:** 125 bytes
- Allows control frames to fit in single IP packet
- Reduces fragmentation
2. **No fragmentation:** Control frames MUST NOT be fragmented
- FIN bit MUST be 1
- Ensures immediate processing
3. **Interleaving:** Control frames MAY be injected in middle of fragmented message
- Enables ping/pong during long transfers
- Close frames can interrupt any operation
4. **All control frames MUST be handled immediately**
### Masking
**Purpose of masking:**
- Prevents cache poisoning attacks
- Protects against misinterpretation by intermediaries
- Makes WebSocket traffic unpredictable to proxies
**Masking algorithm:**
```
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
```
**Implementation:**
```go
func maskBytes(data []byte, mask [4]byte) {
for i := range data {
data[i] ^= mask[i%4]
}
}
```
**Example:**
```
Original: [0x48, 0x65, 0x6C, 0x6C, 0x6F] // "Hello"
Masking Key: [0x37, 0xFA, 0x21, 0x3D]
Masked: [0x7F, 0x9F, 0x4D, 0x51, 0x58]
Calculation:
0x48 XOR 0x37 = 0x7F
0x65 XOR 0xFA = 0x9F
0x6C XOR 0x21 = 0x4D
0x6C XOR 0x3D = 0x51
0x6F XOR 0x37 = 0x58 (wraps around to mask[0])
```
**Security requirement:** Masking key MUST be derived from strong source of entropy. Predictable masking keys defeat the security purpose.
## Message Fragmentation
### Why Fragment?
- Send message without knowing total size upfront
- Multiplex logical channels (interleave messages)
- Keep control frames responsive during large transfers
### Fragmentation Rules
**Sender rules:**
1. First fragment has opcode (text/binary)
2. Subsequent fragments have opcode 0x0 (continuation)
3. Last fragment has FIN bit set to 1
4. Control frames MAY be interleaved
**Receiver rules:**
1. Reassemble fragments in order
2. Final message type determined by first fragment opcode
3. Validate UTF-8 across all text fragments
4. Process control frames immediately (don't wait for FIN)
### Fragmentation Example
**Sending "Hello World" in 3 fragments:**
```
Frame 1 (Text, More Fragments):
FIN=0, Opcode=0x1, Payload="Hello"
Frame 2 (Continuation, More Fragments):
FIN=0, Opcode=0x0, Payload=" Wor"
Frame 3 (Continuation, Final):
FIN=1, Opcode=0x0, Payload="ld"
```
**With interleaved Ping:**
```
Frame 1: FIN=0, Opcode=0x1, Payload="Hello"
Frame 2: FIN=1, Opcode=0x9, Payload="" <- Ping (complete)
Frame 3: FIN=0, Opcode=0x0, Payload=" Wor"
Frame 4: FIN=1, Opcode=0x0, Payload="ld"
```
### Implementation Pattern
```go
type fragmentState struct {
messageType int
fragments [][]byte
}
func (ws *WebSocket) handleFrame(fin bool, opcode int, payload []byte) {
switch opcode {
case 0x1, 0x2: // Text or Binary (first fragment)
if fin {
ws.handleCompleteMessage(opcode, payload)
} else {
ws.fragmentState = &fragmentState{
messageType: opcode,
fragments: [][]byte{payload},
}
}
case 0x0: // Continuation
if ws.fragmentState == nil {
ws.fail("Unexpected continuation frame")
return
}
ws.fragmentState.fragments = append(ws.fragmentState.fragments, payload)
if fin {
complete := bytes.Join(ws.fragmentState.fragments, nil)
ws.handleCompleteMessage(ws.fragmentState.messageType, complete)
ws.fragmentState = nil
}
case 0x8, 0x9, 0xA: // Control frames
ws.handleControlFrame(opcode, payload)
}
}
```
## Ping and Pong Frames
### Purpose
1. **Keep-alive:** Detect broken connections
2. **Latency measurement:** Time round-trip
3. **NAT traversal:** Maintain mapping in stateful firewalls
### Protocol Rules
**Ping (0x9):**
- MAY be sent by either endpoint at any time
- MAY contain application data (≤125 bytes)
- Application data arbitrary (often empty or timestamp)
**Pong (0xA):**
- MUST be sent in response to Ping
- MUST contain identical payload as Ping
- MUST be sent "as soon as practical"
- MAY be sent unsolicited (one-way heartbeat)
**No Response:**
- If Pong not received within timeout, connection assumed dead
- Application should close connection
### Implementation Patterns
**Pattern 1: Automatic Pong (most WebSocket libraries)**
```go
// Library handles pong automatically
ws.SetPingHandler(func(appData string) error {
// Custom handler if needed
return nil // Library sends pong automatically
})
```
**Pattern 2: Manual Pong**
```go
func (ws *WebSocket) handlePing(payload []byte) {
pongFrame := Frame{
FIN: true,
Opcode: 0xA,
Payload: payload, // Echo same payload
}
ws.writeFrame(pongFrame)
}
```
**Pattern 3: Periodic Client Ping**
```go
func (ws *WebSocket) pingLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := ws.writePing([]byte{}); err != nil {
return // Connection dead
}
case <-ws.done:
return
}
}
}
```
**Pattern 4: Timeout Detection**
```go
const pongWait = 60 * time.Second
ws.SetReadDeadline(time.Now().Add(pongWait))
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// If no frame received in pongWait, ReadMessage returns timeout error
```
### Nostr Relay Recommendations
**Server-side:**
- Send ping every 30-60 seconds
- Close connection if no pong within 60-120 seconds
- Log timeout closures for monitoring
**Client-side:**
- Respond to pings automatically (use library handler)
- Consider sending unsolicited pongs every 30 seconds (some proxies)
- Reconnect if no frames received for 120 seconds
## Close Handshake
### Close Frame Structure
**Close frame (Opcode 0x8) payload:**
```
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Status Code (16) | Reason (variable length)... |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
```
**Status Code (2 bytes, optional):**
- 16-bit unsigned integer
- Network byte order (big-endian)
- See "Status Codes" section below
**Reason (variable length, optional):**
- UTF-8 encoded text
- MUST be valid UTF-8
- Typically human-readable explanation
### Close Handshake Sequence
**Initiator (either endpoint):**
1. Send Close frame with optional status/reason
2. Stop sending data frames
3. Continue processing received frames until Close frame received
4. Close underlying TCP connection
**Recipient:**
1. Receive Close frame
2. Send Close frame in response (if not already sent)
3. Close underlying TCP connection
### Status Codes
**Normal Closure Codes:**
- `1000` - Normal Closure
- Successful operation complete
- Default if no code specified
- `1001` - Going Away
- Endpoint going away (server shutdown, browser navigation)
- Client navigating to new page
**Error Closure Codes:**
- `1002` - Protocol Error
- Endpoint terminating due to protocol error
- Invalid frame format, unexpected opcode, etc.
- `1003` - Unsupported Data
- Endpoint cannot accept data type
- Server received binary when expecting text
- `1007` - Invalid Frame Payload Data
- Inconsistent data (e.g., non-UTF-8 in text frame)
- `1008` - Policy Violation
- Message violates endpoint policy
- Generic code when specific code doesn't fit
- `1009` - Message Too Big
- Message too large to process
- `1010` - Mandatory Extension
- Client expected server to negotiate extension
- Server didn't respond with extension
- `1011` - Internal Server Error
- Server encountered unexpected condition
- Prevents fulfilling request
**Reserved Codes:**
- `1004` - Reserved
- `1005` - No Status Rcvd (internal use only, never sent)
- `1006` - Abnormal Closure (internal use only, never sent)
- `1015` - TLS Handshake (internal use only, never sent)
**Custom Application Codes:**
- `3000-3999` - Library/framework use
- `4000-4999` - Application use (e.g., Nostr-specific)
### Implementation Patterns
**Graceful close (initiator):**
```go
func (ws *WebSocket) Close() error {
// Send close frame
closeFrame := Frame{
FIN: true,
Opcode: 0x8,
Payload: encodeCloseStatus(1000, "goodbye"),
}
ws.writeFrame(closeFrame)
// Wait for close frame response (with timeout)
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
frame, err := ws.readFrame()
if err != nil || frame.Opcode == 0x8 {
break
}
// Process other frames
}
// Close TCP connection
return ws.conn.Close()
}
```
**Handling received close:**
```go
func (ws *WebSocket) handleCloseFrame(payload []byte) {
status, reason := decodeClosePayload(payload)
log.Printf("Close received: %d %s", status, reason)
// Send close response
closeFrame := Frame{
FIN: true,
Opcode: 0x8,
Payload: payload, // Echo same status/reason
}
ws.writeFrame(closeFrame)
// Close connection
ws.conn.Close()
}
```
**Nostr relay close examples:**
```go
// Client subscription limit exceeded
ws.SendClose(4000, "subscription limit exceeded")
// Invalid message format
ws.SendClose(1002, "protocol error: invalid JSON")
// Relay shutting down
ws.SendClose(1001, "relay shutting down")
// Client rate limit exceeded
ws.SendClose(4001, "rate limit exceeded")
```
## Security Considerations
### Origin-Based Security Model
**Threat:** Malicious web page opens WebSocket to victim server using user's credentials
**Mitigation:**
1. Server checks `Origin` header
2. Reject connections from untrusted origins
3. Implement same-origin or allowlist policy
**Example:**
```go
func validateOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
// Allow same-origin
if origin == "https://"+r.Host {
return true
}
// Allowlist trusted origins
trusted := []string{
"https://app.example.com",
"https://mobile.example.com",
}
for _, t := range trusted {
if origin == t {
return true
}
}
return false
}
```
### Masking Attacks
**Why masking is required:**
- Without masking, attacker can craft WebSocket frames that look like HTTP requests
- Proxies might misinterpret frame data as HTTP
- Could lead to cache poisoning or request smuggling
**Example attack (without masking):**
```
WebSocket payload: "GET /admin HTTP/1.1\r\nHost: victim.com\r\n\r\n"
Proxy might interpret as separate HTTP request
```
**Defense:** Client MUST mask all frames. Server MUST reject unmasked frames from client.
### Connection Limits
**Prevent resource exhaustion:**
```go
type ConnectionLimiter struct {
connections map[string]int
maxPerIP int
mu sync.Mutex
}
func (cl *ConnectionLimiter) Allow(ip string) bool {
cl.mu.Lock()
defer cl.mu.Unlock()
if cl.connections[ip] >= cl.maxPerIP {
return false
}
cl.connections[ip]++
return true
}
func (cl *ConnectionLimiter) Release(ip string) {
cl.mu.Lock()
defer cl.mu.Unlock()
cl.connections[ip]--
}
```
### TLS (WSS)
**Use WSS (WebSocket Secure) for:**
- Authentication credentials
- Private user data
- Financial transactions
- Any sensitive information
**WSS connection flow:**
1. Establish TLS connection
2. Perform TLS handshake
3. Verify server certificate
4. Perform WebSocket handshake over TLS
**URL schemes:**
- `ws://` - Unencrypted WebSocket (default port 80)
- `wss://` - Encrypted WebSocket over TLS (default port 443)
### Message Size Limits
**Prevent memory exhaustion:**
```go
const maxMessageSize = 512 * 1024 // 512 KB
ws.SetReadLimit(maxMessageSize)
// Or during frame reading:
if payloadLength > maxMessageSize {
ws.SendClose(1009, "message too large")
ws.Close()
}
```
### Rate Limiting
**Prevent abuse:**
```go
type RateLimiter struct {
limiter *rate.Limiter
}
func (rl *RateLimiter) Allow() bool {
return rl.limiter.Allow()
}
// Per-connection limiter
limiter := rate.NewLimiter(10, 20) // 10 msgs/sec, burst 20
if !limiter.Allow() {
ws.SendClose(4001, "rate limit exceeded")
}
```
## Error Handling
### Connection Errors
**Types of errors:**
1. **Network errors:** TCP connection failure, timeout
2. **Protocol errors:** Invalid frame format, wrong opcode
3. **Application errors:** Invalid message content
**Handling strategy:**
```go
for {
frame, err := ws.ReadFrame()
if err != nil {
// Check error type
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Timeout - connection likely dead
log.Println("Connection timeout")
ws.Close()
return
}
if err == io.EOF || err == io.ErrUnexpectedEOF {
// Connection closed
log.Println("Connection closed")
return
}
if protocolErr, ok := err.(*ProtocolError); ok {
// Protocol violation
log.Printf("Protocol error: %v", protocolErr)
ws.SendClose(1002, protocolErr.Error())
ws.Close()
return
}
// Unknown error
log.Printf("Unknown error: %v", err)
ws.Close()
return
}
// Process frame
}
```
### UTF-8 Validation
**Text frames MUST contain valid UTF-8:**
```go
func validateUTF8(data []byte) bool {
return utf8.Valid(data)
}
func handleTextFrame(payload []byte) error {
if !validateUTF8(payload) {
return fmt.Errorf("invalid UTF-8 in text frame")
}
// Process valid text
return nil
}
```
**For fragmented messages:** Validate UTF-8 across all fragments when reassembled.
## Implementation Checklist
### Client Implementation
- [ ] Generate random Sec-WebSocket-Key
- [ ] Compute and validate Sec-WebSocket-Accept
- [ ] MUST mask all frames sent to server
- [ ] Handle unmasked frames from server
- [ ] Respond to Ping with Pong
- [ ] Implement close handshake (both initiating and responding)
- [ ] Validate UTF-8 in text frames
- [ ] Handle fragmented messages
- [ ] Set reasonable timeouts
- [ ] Implement reconnection logic
### Server Implementation
- [ ] Validate Sec-WebSocket-Key format
- [ ] Compute correct Sec-WebSocket-Accept
- [ ] Validate Origin header
- [ ] MUST NOT mask frames sent to client
- [ ] Reject masked frames from server (protocol error)
- [ ] Respond to Ping with Pong
- [ ] Implement close handshake (both initiating and responding)
- [ ] Validate UTF-8 in text frames
- [ ] Handle fragmented messages
- [ ] Implement connection limits (per IP, total)
- [ ] Implement message size limits
- [ ] Implement rate limiting
- [ ] Log connection statistics
- [ ] Graceful shutdown (close all connections)
### Both Client and Server
- [ ] Handle concurrent read/write safely
- [ ] Process control frames immediately (even during fragmentation)
- [ ] Implement proper timeout mechanisms
- [ ] Log errors with appropriate detail
- [ ] Handle unexpected close gracefully
- [ ] Validate frame structure
- [ ] Check RSV bits (must be 0 unless extension)
- [ ] Support standard close status codes
- [ ] Implement proper error handling for all operations
## Common Implementation Mistakes
### 1. Concurrent Writes
**Mistake:** Writing to WebSocket from multiple goroutines without synchronization
**Fix:** Use mutex or single-writer goroutine
```go
type WebSocket struct {
conn *websocket.Conn
mutex sync.Mutex
}
func (ws *WebSocket) WriteMessage(data []byte) error {
ws.mutex.Lock()
defer ws.mutex.Unlock()
return ws.conn.WriteMessage(websocket.TextMessage, data)
}
```
### 2. Not Handling Pong
**Mistake:** Sending Ping but not updating read deadline on Pong
**Fix:**
```go
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
```
### 3. Forgetting Close Handshake
**Mistake:** Just calling `conn.Close()` without sending Close frame
**Fix:** Send Close frame first, wait for response, then close TCP
### 4. Not Validating UTF-8
**Mistake:** Accepting any bytes in text frames
**Fix:** Validate UTF-8 and fail connection on invalid text
### 5. No Message Size Limit
**Mistake:** Allowing unlimited message sizes
**Fix:** Set `SetReadLimit()` to reasonable value (e.g., 512 KB)
### 6. Blocking on Write
**Mistake:** Blocking indefinitely on slow clients
**Fix:** Set write deadline before each write
```go
ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
```
### 7. Memory Leaks
**Mistake:** Not cleaning up resources on disconnect
**Fix:** Use defer for cleanup, ensure all goroutines terminate
### 8. Race Conditions in Close
**Mistake:** Multiple goroutines trying to close connection
**Fix:** Use `sync.Once` for close operation
```go
type WebSocket struct {
conn *websocket.Conn
closeOnce sync.Once
}
func (ws *WebSocket) Close() error {
var err error
ws.closeOnce.Do(func() {
err = ws.conn.Close()
})
return err
}
```

View File

@@ -0,0 +1,162 @@
# Nostr Protocol Skill
A comprehensive Claude skill for working with the Nostr protocol and implementing Nostr clients and relays.
## Overview
This skill provides expert-level knowledge of the Nostr protocol, including:
- Complete NIP (Nostr Implementation Possibilities) reference
- Event structure and cryptographic operations
- Client-relay WebSocket communication
- Event kinds and their behaviors
- Best practices and common pitfalls
## Contents
### SKILL.md
The main skill file containing:
- Core protocol concepts
- Event structure and signing
- WebSocket communication patterns
- Cryptographic operations
- Common implementation patterns
- Quick reference guides
### Reference Files
#### references/nips-overview.md
Comprehensive documentation of all standard NIPs including:
- Core protocol NIPs (NIP-01, NIP-02, etc.)
- Social features (reactions, reposts, channels)
- Identity and discovery (NIP-05, NIP-65)
- Security and privacy (NIP-44, NIP-42)
- Lightning integration (NIP-47, NIP-57)
- Advanced features
#### references/event-kinds.md
Complete reference for all Nostr event kinds:
- Core events (0-999)
- Regular events (1000-9999)
- Replaceable events (10000-19999)
- Ephemeral events (20000-29999)
- Parameterized replaceable events (30000-39999)
- Event lifecycle behaviors
- Common patterns and examples
#### references/common-mistakes.md
Detailed guide on implementation pitfalls:
- Event creation and signing errors
- WebSocket communication issues
- Filter query problems
- Threading mistakes
- Relay management errors
- Security vulnerabilities
- UX considerations
- Testing strategies
## When to Use
Use this skill when:
- Implementing Nostr clients or relays
- Working with Nostr events and messages
- Handling cryptographic signatures and keys
- Implementing any NIP
- Building social features on Nostr
- Debugging Nostr applications
- Discussing Nostr protocol architecture
## Key Features
### Complete NIP Coverage
All standard NIPs documented with:
- Purpose and status
- Implementation details
- Code examples
- Usage patterns
- Interoperability notes
### Cryptographic Operations
Detailed guidance on:
- Event signing with Schnorr signatures
- Event ID calculation
- Signature verification
- Key management (BIP-39, NIP-06)
- Encryption (NIP-04, NIP-44)
### WebSocket Protocol
Complete reference for:
- Message types (EVENT, REQ, CLOSE, OK, EOSE, etc.)
- Filter queries and optimization
- Subscription management
- Connection handling
- Error handling
### Event Lifecycle
Understanding of:
- Regular events (immutable)
- Replaceable events (latest only)
- Ephemeral events (real-time only)
- Parameterized replaceable events (by identifier)
### Best Practices
Comprehensive guidance on:
- Multi-relay architecture
- NIP-65 relay lists
- Event caching
- Optimistic UI
- Security considerations
- Performance optimization
## Quick Start Examples
### Publishing a Note
```javascript
const event = {
pubkey: userPublicKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello Nostr!"
}
event.id = calculateId(event)
event.sig = signEvent(event, privateKey)
ws.send(JSON.stringify(["EVENT", event]))
```
### Subscribing to Events
```javascript
const filter = {
kinds: [1],
authors: [followedPubkey],
limit: 50
}
ws.send(JSON.stringify(["REQ", "sub-id", filter]))
```
### Replying to a Note
```javascript
const reply = {
kind: 1,
tags: [
["e", originalEventId, "", "root"],
["p", originalAuthorPubkey]
],
content: "Great post!"
}
```
## Official Resources
- **NIPs Repository**: https://github.com/nostr-protocol/nips
- **Nostr Website**: https://nostr.com
- **Nostr Documentation**: https://nostr.how
- **NIP Status**: https://nostr-nips.com
## Skill Maintenance
This skill is based on the official Nostr NIPs repository. As new NIPs are proposed and implemented, this skill should be updated to reflect the latest standards and best practices.
## License
Based on public Nostr protocol specifications (MIT License).

View File

@@ -0,0 +1,449 @@
---
name: nostr
description: This skill should be used when working with the Nostr protocol, implementing Nostr clients or relays, handling Nostr events, or discussing Nostr Implementation Possibilities (NIPs). Provides comprehensive knowledge of Nostr's decentralized protocol, event structure, cryptographic operations, and all standard NIPs.
---
# Nostr Protocol Expert
## Purpose
This skill provides expert-level assistance with the Nostr protocol, a simple, open protocol for global, decentralized, and censorship-resistant social networks. The protocol is built on relays and cryptographic keys, enabling direct peer-to-peer communication without central servers.
## When to Use
Activate this skill when:
- Implementing Nostr clients or relays
- Working with Nostr events and messages
- Handling cryptographic signatures and keys (schnorr signatures on secp256k1)
- Implementing any Nostr Implementation Possibility (NIP)
- Building social networking features on Nostr
- Querying or filtering Nostr events
- Discussing Nostr protocol architecture
- Implementing WebSocket communication with relays
## Core Concepts
### The Protocol Foundation
Nostr operates on two main components:
1. **Clients** - Applications users run to read/write data
2. **Relays** - Servers that store and forward messages
Key principles:
- Everyone runs a client
- Anyone can run a relay
- Users identified by public keys
- Messages signed with private keys
- No central authority or trusted servers
### Events Structure
All data in Nostr is represented as events. An event is a JSON object with this structure:
```json
{
"id": "<32-bytes lowercase hex-encoded sha256 of the serialized event data>",
"pubkey": "<32-bytes lowercase hex-encoded public key of the event creator>",
"created_at": "<unix timestamp in seconds>",
"kind": "<integer identifying event type>",
"tags": [
["<tag name>", "<tag value>", "<optional third param>", "..."]
],
"content": "<arbitrary string>",
"sig": "<64-bytes lowercase hex of the schnorr signature of the sha256 hash of the serialized event data>"
}
```
### Event Kinds
Standard event kinds (from various NIPs):
- `0` - Metadata (user profile)
- `1` - Text note (short post)
- `2` - Recommend relay
- `3` - Contacts (following list)
- `4` - Encrypted direct messages
- `5` - Event deletion
- `6` - Repost
- `7` - Reaction (like, emoji reaction)
- `40` - Channel creation
- `41` - Channel metadata
- `42` - Channel message
- `43` - Channel hide message
- `44` - Channel mute user
- `1000-9999` - Regular events
- `10000-19999` - Replaceable events
- `20000-29999` - Ephemeral events
- `30000-39999` - Parameterized replaceable events
### Tags
Common tag types:
- `["e", "<event-id>", "<relay-url>", "<marker>"]` - Reference to an event
- `["p", "<pubkey>", "<relay-url>"]` - Reference to a user
- `["a", "<kind>:<pubkey>:<d-tag>", "<relay-url>"]` - Reference to a replaceable event
- `["d", "<identifier>"]` - Identifier for parameterized replaceable events
- `["r", "<url>"]` - Reference/link to a web resource
- `["t", "<hashtag>"]` - Hashtag
- `["g", "<geohash>"]` - Geolocation
- `["nonce", "<number>", "<difficulty>"]` - Proof of work
- `["subject", "<subject>"]` - Subject/title
- `["client", "<client-name>"]` - Client application used
## Key NIPs Reference
For detailed specifications, refer to **references/nips-overview.md**.
### Core Protocol NIPs
#### NIP-01: Basic Protocol Flow
The foundation of Nostr. Defines:
- Event structure and validation
- Event ID calculation (SHA256 of serialized event)
- Signature verification (schnorr signatures)
- Client-relay communication via WebSocket
- Message types: EVENT, REQ, CLOSE, EOSE, OK, NOTICE
#### NIP-02: Contact List and Petnames
Event kind `3` for following lists:
- Each `p` tag represents a followed user
- Optional relay URL and petname in tag
- Replaceable event (latest overwrites)
#### NIP-04: Encrypted Direct Messages
Event kind `4` for private messages:
- Content encrypted with shared secret (ECDH)
- `p` tag for recipient pubkey
- Deprecated in favor of NIP-44
#### NIP-05: Mapping Nostr Keys to DNS
Internet identifier format: `name@domain.com`
- `.well-known/nostr.json` endpoint
- Maps names to pubkeys
- Optional relay list
#### NIP-09: Event Deletion
Event kind `5` to request deletion:
- Contains `e` tags for events to delete
- Relays should delete referenced events
- Only works for own events
#### NIP-10: Text Note References (Threads)
Conventions for `e` and `p` tags in replies:
- Root event reference
- Reply event reference
- Mentions
- Marker types: "root", "reply", "mention"
#### NIP-11: Relay Information Document
HTTP endpoint for relay metadata:
- GET request to relay URL
- Returns JSON with relay information
- Supported NIPs, software, limitations
### Social Features NIPs
#### NIP-25: Reactions
Event kind `7` for reactions:
- Content usually "+" (like) or emoji
- `e` tag for reacted event
- `p` tag for event author
#### NIP-42: Authentication
Client authentication to relays:
- AUTH message from relay
- Client responds with event kind `22242`
- Proves key ownership
#### NIP-50: Search
Query filter extension for full-text search:
- `search` field in REQ filters
- Implementation-defined behavior
### Advanced NIPs
#### NIP-19: bech32-encoded Entities
Human-readable identifiers:
- `npub`: public key
- `nsec`: private key (sensitive!)
- `note`: note/event ID
- `nprofile`: profile with relay hints
- `nevent`: event with relay hints
- `naddr`: replaceable event coordinate
#### NIP-44: Encrypted Payloads
Improved encryption for direct messages:
- Versioned encryption scheme
- Better security than NIP-04
- ChaCha20-Poly1305 AEAD
#### NIP-65: Relay List Metadata
Event kind `10002` for relay lists:
- Read/write relay preferences
- Optimizes relay discovery
- Replaceable event
## Client-Relay Communication
### WebSocket Messages
#### From Client to Relay
**EVENT** - Publish an event:
```json
["EVENT", <event JSON>]
```
**REQ** - Request events (subscription):
```json
["REQ", <subscription_id>, <filters JSON>, <filters JSON>, ...]
```
**CLOSE** - Stop a subscription:
```json
["CLOSE", <subscription_id>]
```
**AUTH** - Respond to auth challenge:
```json
["AUTH", <signed event kind 22242>]
```
#### From Relay to Client
**EVENT** - Send event to client:
```json
["EVENT", <subscription_id>, <event JSON>]
```
**OK** - Acceptance/rejection notice:
```json
["OK", <event_id>, <true|false>, <message>]
```
**EOSE** - End of stored events:
```json
["EOSE", <subscription_id>]
```
**CLOSED** - Subscription closed:
```json
["CLOSED", <subscription_id>, <message>]
```
**NOTICE** - Human-readable message:
```json
["NOTICE", <message>]
```
**AUTH** - Authentication challenge:
```json
["AUTH", <challenge>]
```
### Filter Objects
Filters select events in REQ messages:
```json
{
"ids": ["<event-id>", ...],
"authors": ["<pubkey>", ...],
"kinds": [<kind number>, ...],
"#e": ["<event-id>", ...],
"#p": ["<pubkey>", ...],
"#a": ["<coordinate>", ...],
"#t": ["<hashtag>", ...],
"since": <unix timestamp>,
"until": <unix timestamp>,
"limit": <max number of events>
}
```
Filtering rules:
- Arrays are ORed together
- Different fields are ANDed
- Tag filters: `#<single-letter>` matches tag values
- Prefix matching allowed for `ids` and `authors`
## Cryptographic Operations
### Key Management
- **Private Key**: 32-byte random value, keep secure
- **Public Key**: Derived via secp256k1
- **Encoding**: Hex (lowercase) or bech32
### Event Signing (schnorr)
Steps to create a signed event:
1. Set all fields except `id` and `sig`
2. Serialize event data to JSON (specific order)
3. Calculate SHA256 hash → `id`
4. Sign `id` with schnorr signature → `sig`
Serialization format for ID calculation:
```json
[
0,
<pubkey>,
<created_at>,
<kind>,
<tags>,
<content>
]
```
### Event Verification
Steps to verify an event:
1. Verify ID matches SHA256 of serialized data
2. Verify signature is valid schnorr signature
3. Check created_at is reasonable (not far future)
4. Validate event structure and required fields
## Implementation Best Practices
### For Clients
1. **Connect to Multiple Relays**: Don't rely on single relay
2. **Cache Events**: Reduce redundant relay queries
3. **Verify Signatures**: Always verify event signatures
4. **Handle Replaceable Events**: Keep only latest version
5. **Respect User Privacy**: Careful with sensitive data
6. **Implement NIP-65**: Use user's preferred relays
7. **Proper Error Handling**: Handle relay disconnections
8. **Pagination**: Use `limit`, `since`, `until` for queries
### For Relays
1. **Validate Events**: Check signatures, IDs, structure
2. **Rate Limiting**: Prevent spam and abuse
3. **Storage Management**: Ephemeral events, retention policies
4. **Implement NIP-11**: Provide relay information
5. **WebSocket Optimization**: Handle many connections
6. **Filter Optimization**: Efficient event querying
7. **Consider NIP-42**: Authentication for write access
8. **Performance**: Index by pubkey, kind, tags, timestamp
### Security Considerations
1. **Never Expose Private Keys**: Handle nsec carefully
2. **Validate All Input**: Prevent injection attacks
3. **Use NIP-44**: For encrypted messages (not NIP-04)
4. **Check Event Timestamps**: Reject far-future events
5. **Implement Proof of Work**: NIP-13 for spam prevention
6. **Sanitize Content**: XSS prevention in displayed content
7. **Relay Trust**: Don't trust single relay for critical data
## Common Patterns
### Publishing a Note
```javascript
const event = {
pubkey: userPublicKey,
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello Nostr!",
}
// Calculate ID and sign
event.id = calculateId(event)
event.sig = signEvent(event, privateKey)
// Publish to relay
ws.send(JSON.stringify(["EVENT", event]))
```
### Subscribing to Notes
```javascript
const filter = {
kinds: [1],
authors: [followedPubkey1, followedPubkey2],
limit: 50
}
ws.send(JSON.stringify(["REQ", "my-sub", filter]))
```
### Replying to a Note
```javascript
const reply = {
kind: 1,
tags: [
["e", originalEventId, relayUrl, "root"],
["p", originalAuthorPubkey]
],
content: "Great post!",
// ... other fields
}
```
### Reacting to a Note
```javascript
const reaction = {
kind: 7,
tags: [
["e", eventId],
["p", eventAuthorPubkey]
],
content: "+", // or emoji
// ... other fields
}
```
## Development Resources
### Essential NIPs for Beginners
Start with these NIPs in order:
1. **NIP-01** - Basic protocol (MUST read)
2. **NIP-19** - Bech32 identifiers
3. **NIP-02** - Following lists
4. **NIP-10** - Threaded conversations
5. **NIP-25** - Reactions
6. **NIP-65** - Relay lists
### Testing and Development
- **Relay Implementations**: nostream, strfry, relay.py
- **Test Relays**: wss://relay.damus.io, wss://nos.lol
- **Libraries**: nostr-tools (JS), rust-nostr (Rust), python-nostr (Python)
- **Development Tools**: NostrDebug, Nostr Army Knife, nostril
- **Reference Clients**: Damus (iOS), Amethyst (Android), Snort (Web)
### Key Repositories
- **NIPs Repository**: https://github.com/nostr-protocol/nips
- **Awesome Nostr**: https://github.com/aljazceru/awesome-nostr
- **Nostr Resources**: https://nostr.how
## Reference Files
For comprehensive NIP details, see:
- **references/nips-overview.md** - Detailed descriptions of all standard NIPs
- **references/event-kinds.md** - Complete event kinds reference
- **references/common-mistakes.md** - Pitfalls and how to avoid them
## Quick Checklist
When implementing Nostr:
- [ ] Events have all required fields (id, pubkey, created_at, kind, tags, content, sig)
- [ ] Event IDs calculated correctly (SHA256 of serialization)
- [ ] Signatures verified (schnorr on secp256k1)
- [ ] WebSocket messages properly formatted
- [ ] Filter queries optimized with appropriate limits
- [ ] Handling replaceable events correctly
- [ ] Connected to multiple relays for redundancy
- [ ] Following relevant NIPs for features implemented
- [ ] Private keys never exposed or transmitted
- [ ] Event timestamps validated
## Official Resources
- **NIPs Repository**: https://github.com/nostr-protocol/nips
- **Nostr Website**: https://nostr.com
- **Nostr Documentation**: https://nostr.how
- **NIP Status**: https://nostr-nips.com

View File

@@ -0,0 +1,657 @@
# Common Nostr Implementation Mistakes and How to Avoid Them
This document highlights frequent errors made when implementing Nostr clients and relays, along with solutions.
## Event Creation and Signing
### Mistake 1: Incorrect Event ID Calculation
**Problem**: Wrong serialization order or missing fields when calculating SHA256.
**Correct Serialization**:
```json
[
0, // Must be integer 0
<pubkey>, // Lowercase hex string
<created_at>, // Unix timestamp integer
<kind>, // Integer
<tags>, // Array of arrays
<content> // String
]
```
**Common errors**:
- Using string "0" instead of integer 0
- Including `id` or `sig` fields in serialization
- Wrong field order
- Not using compact JSON (no spaces)
- Using uppercase hex
**Fix**: Serialize exactly as shown, compact JSON, SHA256 the UTF-8 bytes.
### Mistake 2: Wrong Signature Algorithm
**Problem**: Using ECDSA instead of Schnorr signatures.
**Correct**:
- Use Schnorr signatures (BIP-340)
- Curve: secp256k1
- Sign the 32-byte event ID
**Libraries**:
- JavaScript: noble-secp256k1
- Rust: secp256k1
- Go: btcsuite/btcd/btcec/v2/schnorr
- Python: secp256k1-py
### Mistake 3: Invalid created_at Timestamps
**Problem**: Events with far-future timestamps or very old timestamps.
**Best practices**:
- Use current Unix time: `Math.floor(Date.now() / 1000)`
- Relays often reject if `created_at > now + 15 minutes`
- Don't backdate events to manipulate ordering
**Fix**: Always use current time when creating events.
### Mistake 4: Malformed Tags
**Problem**: Tags that aren't arrays or have wrong structure.
**Correct format**:
```json
{
"tags": [
["e", "event-id", "relay-url", "marker"],
["p", "pubkey", "relay-url"],
["t", "hashtag"]
]
}
```
**Common errors**:
- Using objects instead of arrays: `{"e": "..."}`
- Missing inner arrays: `["e", "event-id"]` when nested in tags is wrong
- Wrong nesting depth
- Non-string values (except for specific NIPs)
### Mistake 5: Not Handling Replaceable Events
**Problem**: Showing multiple versions of replaceable events.
**Event types**:
- **Replaceable (10000-19999)**: Same author + kind → replace
- **Parameterized Replaceable (30000-39999)**: Same author + kind + d-tag → replace
**Fix**:
```javascript
// For replaceable events
const key = `${event.pubkey}:${event.kind}`
if (latestEvents[key]?.created_at < event.created_at) {
latestEvents[key] = event
}
// For parameterized replaceable events
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
const key = `${event.pubkey}:${event.kind}:${dTag}`
if (latestEvents[key]?.created_at < event.created_at) {
latestEvents[key] = event
}
```
## WebSocket Communication
### Mistake 6: Not Handling EOSE
**Problem**: Loading indicators never finish or show wrong state.
**Solution**:
```javascript
const receivedEvents = new Set()
let eoseReceived = false
ws.onmessage = (msg) => {
const [type, ...rest] = JSON.parse(msg.data)
if (type === 'EVENT') {
const [subId, event] = rest
receivedEvents.add(event.id)
displayEvent(event)
}
if (type === 'EOSE') {
eoseReceived = true
hideLoadingSpinner()
}
}
```
### Mistake 7: Not Closing Subscriptions
**Problem**: Memory leaks and wasted bandwidth from unclosed subscriptions.
**Fix**: Always send CLOSE when done:
```javascript
ws.send(JSON.stringify(['CLOSE', subId]))
```
**Best practices**:
- Close when component unmounts
- Close before opening new subscription with same ID
- Use unique subscription IDs
- Track active subscriptions
### Mistake 8: Ignoring OK Messages
**Problem**: Not knowing if events were accepted or rejected.
**Solution**:
```javascript
ws.onmessage = (msg) => {
const [type, eventId, accepted, message] = JSON.parse(msg.data)
if (type === 'OK') {
if (!accepted) {
console.error(`Event ${eventId} rejected: ${message}`)
handleRejection(eventId, message)
}
}
}
```
**Common rejection reasons**:
- `pow:` - Insufficient proof of work
- `blocked:` - Pubkey or content blocked
- `rate-limited:` - Too many requests
- `invalid:` - Failed validation
### Mistake 9: Sending Events Before WebSocket Ready
**Problem**: Events lost because WebSocket not connected.
**Fix**:
```javascript
const sendWhenReady = (ws, message) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message)
} else {
ws.addEventListener('open', () => ws.send(message), { once: true })
}
}
```
### Mistake 10: Not Handling WebSocket Disconnections
**Problem**: App breaks when relay goes offline.
**Solution**: Implement reconnection with exponential backoff:
```javascript
let reconnectDelay = 1000
const maxDelay = 30000
const connect = () => {
const ws = new WebSocket(relayUrl)
ws.onclose = () => {
setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, maxDelay)
connect()
}, reconnectDelay)
}
ws.onopen = () => {
reconnectDelay = 1000 // Reset on successful connection
resubscribe() // Re-establish subscriptions
}
}
```
## Filter Queries
### Mistake 11: Overly Broad Filters
**Problem**: Requesting too many events, overwhelming relay and client.
**Bad**:
```json
{
"kinds": [1],
"limit": 10000
}
```
**Good**:
```json
{
"kinds": [1],
"authors": ["<followed-users>"],
"limit": 50,
"since": 1234567890
}
```
**Best practices**:
- Always set reasonable `limit` (50-500)
- Filter by `authors` when possible
- Use `since`/`until` for time ranges
- Be specific with `kinds`
- Multiple smaller queries > one huge query
### Mistake 12: Not Using Prefix Matching
**Problem**: Full hex strings in filters unnecessarily.
**Optimization**:
```json
{
"ids": ["abc12345"], // 8 chars enough for uniqueness
"authors": ["def67890"]
}
```
Relays support prefix matching for `ids` and `authors`.
### Mistake 13: Duplicate Filter Fields
**Problem**: Redundant filter conditions.
**Bad**:
```json
{
"authors": ["pubkey1", "pubkey1"],
"kinds": [1, 1]
}
```
**Good**:
```json
{
"authors": ["pubkey1"],
"kinds": [1]
}
```
Deduplicate filter arrays.
## Threading and References
### Mistake 14: Incorrect Thread Structure
**Problem**: Missing root/reply markers or wrong tag order.
**Correct reply structure** (NIP-10):
```json
{
"kind": 1,
"tags": [
["e", "<root-event-id>", "<relay>", "root"],
["e", "<parent-event-id>", "<relay>", "reply"],
["p", "<author1-pubkey>"],
["p", "<author2-pubkey>"]
]
}
```
**Key points**:
- Root event should have "root" marker
- Direct parent should have "reply" marker
- Include `p` tags for all mentioned users
- Relay hints are optional but helpful
### Mistake 15: Missing p Tags in Replies
**Problem**: Authors not notified of replies.
**Fix**: Always add `p` tag for:
- Original author
- Authors mentioned in content
- Authors in the thread chain
```json
{
"tags": [
["e", "event-id", "", "reply"],
["p", "original-author"],
["p", "mentioned-user1"],
["p", "mentioned-user2"]
]
}
```
### Mistake 16: Not Using Markers
**Problem**: Ambiguous thread structure.
**Solution**: Always use markers in `e` tags:
- `root` - Root of thread
- `reply` - Direct parent
- `mention` - Referenced but not replied to
Without markers, clients must guess thread structure.
## Relay Management
### Mistake 17: Relying on Single Relay
**Problem**: Single point of failure, censorship vulnerability.
**Solution**: Connect to multiple relays (5-15 common):
```javascript
const relays = [
'wss://relay1.com',
'wss://relay2.com',
'wss://relay3.com'
]
const connections = relays.map(url => connect(url))
```
**Best practices**:
- Publish to 3-5 write relays
- Read from 5-10 read relays
- Use NIP-65 for user's preferred relays
- Fall back to NIP-05 relays
- Implement relay rotation on failure
### Mistake 18: Not Implementing NIP-65
**Problem**: Querying wrong relays, missing user's events.
**Correct flow**:
1. Fetch user's kind `10002` event (relay list)
2. Connect to their read relays to fetch their content
3. Connect to their write relays to send them messages
```javascript
async function getUserRelays(pubkey) {
// Fetch kind 10002
const relayList = await fetchEvent({
kinds: [10002],
authors: [pubkey]
})
const readRelays = []
const writeRelays = []
relayList.tags.forEach(([tag, url, mode]) => {
if (tag === 'r') {
if (!mode || mode === 'read') readRelays.push(url)
if (!mode || mode === 'write') writeRelays.push(url)
}
})
return { readRelays, writeRelays }
}
```
### Mistake 19: Not Respecting Relay Limitations
**Problem**: Violating relay policies, getting rate limited or banned.
**Solution**: Fetch and respect NIP-11 relay info:
```javascript
const getRelayInfo = async (relayUrl) => {
const url = relayUrl.replace('wss://', 'https://').replace('ws://', 'http://')
const response = await fetch(url, {
headers: { 'Accept': 'application/nostr+json' }
})
return response.json()
}
// Respect limitations
const info = await getRelayInfo(relayUrl)
const maxLimit = info.limitation?.max_limit || 500
const maxFilters = info.limitation?.max_filters || 10
```
## Security
### Mistake 20: Exposing Private Keys
**Problem**: Including nsec in client code, logs, or network requests.
**Never**:
- Store nsec in localStorage without encryption
- Log private keys
- Send nsec over network
- Display nsec to user unless explicitly requested
- Hard-code private keys
**Best practices**:
- Use NIP-07 (browser extension) when possible
- Encrypt keys at rest
- Use NIP-46 (remote signing) for web apps
- Warn users when showing nsec
### Mistake 21: Not Verifying Signatures
**Problem**: Accepting invalid events, vulnerability to attacks.
**Always verify**:
```javascript
const verifyEvent = (event) => {
// 1. Verify ID
const calculatedId = sha256(serializeEvent(event))
if (calculatedId !== event.id) return false
// 2. Verify signature
const signatureValid = schnorr.verify(
event.sig,
event.id,
event.pubkey
)
if (!signatureValid) return false
// 3. Check timestamp
const now = Math.floor(Date.now() / 1000)
if (event.created_at > now + 900) return false // 15 min future
return true
}
```
**Verify before**:
- Displaying to user
- Storing in database
- Using event data for logic
### Mistake 22: Using NIP-04 Encryption
**Problem**: Weak encryption, vulnerable to attacks.
**Solution**: Use NIP-44 instead:
- Modern authenticated encryption
- ChaCha20-Poly1305 AEAD
- Proper key derivation
- Version byte for upgradability
**Migration**: Update to NIP-44 for all new encrypted messages.
### Mistake 23: Not Sanitizing Content
**Problem**: XSS vulnerabilities in displayed content.
**Solution**: Sanitize before rendering:
```javascript
import DOMPurify from 'dompurify'
const safeContent = DOMPurify.sanitize(event.content, {
ALLOWED_TAGS: ['b', 'i', 'u', 'a', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'target', 'rel']
})
```
**Especially critical for**:
- Markdown rendering
- Link parsing
- Image URLs
- User-provided HTML
## User Experience
### Mistake 24: Not Caching Events
**Problem**: Re-fetching same events repeatedly, poor performance.
**Solution**: Implement event cache:
```javascript
const eventCache = new Map()
const cacheEvent = (event) => {
eventCache.set(event.id, event)
}
const getCachedEvent = (eventId) => {
return eventCache.get(eventId)
}
```
**Cache strategies**:
- LRU eviction for memory management
- IndexedDB for persistence
- Invalidate replaceable events on update
- Cache metadata (kind 0) aggressively
### Mistake 25: Not Implementing Optimistic UI
**Problem**: Slow feeling app, waiting for relay confirmation.
**Solution**: Show user's events immediately:
```javascript
const publishEvent = async (event) => {
// Immediately show to user
displayEvent(event, { pending: true })
// Publish to relays
const results = await Promise.all(
relays.map(relay => relay.publish(event))
)
// Update status based on results
const success = results.some(r => r.accepted)
displayEvent(event, { pending: false, success })
}
```
### Mistake 26: Poor Loading States
**Problem**: User doesn't know if app is working.
**Solution**: Clear loading indicators:
- Show spinner until EOSE
- Display "Loading..." placeholder
- Show how many relays responded
- Indicate connection status per relay
### Mistake 27: Not Handling Large Threads
**Problem**: Loading entire thread at once, performance issues.
**Solution**: Implement pagination:
```javascript
const loadThread = async (eventId, cursor = null) => {
const filter = {
"#e": [eventId],
kinds: [1],
limit: 20,
until: cursor
}
const replies = await fetchEvents(filter)
return { replies, nextCursor: replies[replies.length - 1]?.created_at }
}
```
## Testing
### Mistake 28: Not Testing with Multiple Relays
**Problem**: App works with one relay but fails with others.
**Solution**: Test with:
- Fast relays
- Slow relays
- Unreliable relays
- Paid relays (auth required)
- Relays with different NIP support
### Mistake 29: Not Testing Edge Cases
**Critical tests**:
- Empty filter results
- WebSocket disconnections
- Malformed events
- Very long content
- Invalid signatures
- Relay errors
- Rate limiting
- Concurrent operations
### Mistake 30: Not Monitoring Performance
**Metrics to track**:
- Event verification time
- WebSocket latency per relay
- Events per second processed
- Memory usage (event cache)
- Subscription count
- Failed publishes
## Best Practices Checklist
**Event Creation**:
- [ ] Correct serialization for ID
- [ ] Schnorr signatures
- [ ] Current timestamp
- [ ] Valid tag structure
- [ ] Handle replaceable events
**WebSocket**:
- [ ] Handle EOSE
- [ ] Close subscriptions
- [ ] Process OK messages
- [ ] Check WebSocket state
- [ ] Reconnection logic
**Filters**:
- [ ] Set reasonable limits
- [ ] Specific queries
- [ ] Deduplicate arrays
- [ ] Use prefix matching
**Threading**:
- [ ] Use root/reply markers
- [ ] Include all p tags
- [ ] Proper thread structure
**Relays**:
- [ ] Multiple relays
- [ ] Implement NIP-65
- [ ] Respect limitations
- [ ] Handle failures
**Security**:
- [ ] Never expose nsec
- [ ] Verify all signatures
- [ ] Use NIP-44 encryption
- [ ] Sanitize content
**UX**:
- [ ] Cache events
- [ ] Optimistic UI
- [ ] Loading states
- [ ] Pagination
**Testing**:
- [ ] Multiple relays
- [ ] Edge cases
- [ ] Monitor performance
## Resources
- **nostr-tools**: JavaScript library with best practices
- **rust-nostr**: Rust implementation with strong typing
- **NIPs Repository**: Official specifications
- **Nostr Dev**: Community resources and help

View File

@@ -0,0 +1,361 @@
# Nostr Event Kinds - Complete Reference
This document provides a comprehensive list of all standard and commonly-used Nostr event kinds.
## Standard Event Kinds
### Core Events (0-999)
#### Metadata and Profile
- **0**: `Metadata` - User profile information (name, about, picture, etc.)
- Replaceable
- Content: JSON with profile fields
#### Text Content
- **1**: `Text Note` - Short-form post (like a tweet)
- Regular event (not replaceable)
- Most common event type
#### Relay Recommendations
- **2**: `Recommend Relay` - Deprecated, use NIP-65 instead
#### Contact Lists
- **3**: `Contacts` - Following list with optional relay hints
- Replaceable
- Tags: `p` tags for each followed user
#### Encrypted Messages
- **4**: `Encrypted Direct Message` - Private message (NIP-04, deprecated)
- Regular event
- Use NIP-44 instead for better security
#### Content Management
- **5**: `Event Deletion` - Request to delete events
- Tags: `e` tags for events to delete
- Only works for own events
#### Sharing
- **6**: `Repost` - Share another event
- Tags: `e` for reposted event, `p` for original author
- May include original event in content
#### Reactions
- **7**: `Reaction` - Like, emoji reaction to event
- Content: "+" or emoji
- Tags: `e` for reacted event, `p` for author
### Channel Events (40-49)
- **40**: `Channel Creation` - Create a public chat channel
- **41**: `Channel Metadata` - Set channel name, about, picture
- **42**: `Channel Message` - Post message in channel
- **43**: `Channel Hide Message` - Hide a message in channel
- **44**: `Channel Mute User` - Mute a user in channel
### Regular Events (1000-9999)
Regular events are never deleted or replaced. All versions are kept.
- **1000**: `Example regular event`
- **1063**: `File Metadata` (NIP-94) - Metadata for shared files
- Tags: url, MIME type, hash, size, dimensions
### Replaceable Events (10000-19999)
Only the latest event of each kind is kept per pubkey.
- **10000**: `Mute List` - List of muted users/content
- **10001**: `Pin List` - Pinned events
- **10002**: `Relay List Metadata` (NIP-65) - User's preferred relays
- Critical for routing
- Tags: `r` with relay URLs and read/write markers
### Ephemeral Events (20000-29999)
Not stored by relays, only forwarded once.
- **20000**: `Example ephemeral event`
- **21000**: `Typing Indicator` - User is typing
- **22242**: `Client Authentication` (NIP-42) - Auth response to relay
### Parameterized Replaceable Events (30000-39999)
Replaced based on `d` tag value.
#### Lists (30000-30009)
- **30000**: `Categorized People List` - Custom people lists
- `d` tag: list identifier
- `p` tags: people in list
- **30001**: `Categorized Bookmark List` - Bookmark collections
- `d` tag: list identifier
- `e` or `a` tags: bookmarked items
- **30008**: `Badge Definition` (NIP-58) - Define a badge/achievement
- `d` tag: badge ID
- Tags: name, description, image
- **30009**: `Profile Badges` (NIP-58) - Badges displayed on profile
- `d` tag: badge ID
- `e` or `a` tags: badge awards
#### Long-form Content (30023)
- **30023**: `Long-form Article` (NIP-23) - Blog post, article
- `d` tag: article identifier (slug)
- Tags: title, summary, published_at, image
- Content: Markdown
#### Application Data (30078)
- **30078**: `Application-specific Data` (NIP-78)
- `d` tag: app-name:data-key
- Content: app-specific data (may be encrypted)
#### Other Parameterized Replaceables
- **31989**: `Application Handler Information` (NIP-89)
- Declares app can handle certain event kinds
- **31990**: `Handler Recommendation` (NIP-89)
- User's preferred apps for event kinds
## Special Event Kinds
### Authentication & Signing
- **22242**: `Client Authentication` - Prove key ownership to relay
- **24133**: `Nostr Connect` - Remote signer protocol (NIP-46)
### Lightning & Payments
- **9734**: `Zap Request` (NIP-57) - Request Lightning payment
- Not published to regular relays
- Sent to LNURL provider
- **9735**: `Zap Receipt` (NIP-57) - Proof of Lightning payment
- Published by LNURL provider
- Proves zap was paid
- **23194**: `Wallet Request` (NIP-47) - Request wallet operation
- **23195**: `Wallet Response` (NIP-47) - Response to wallet request
### Content & Annotations
- **1984**: `Reporting` (NIP-56) - Report content/users
- Tags: reason (spam, illegal, etc.)
- **9802**: `Highlights` (NIP-84) - Highlight text
- Content: highlighted text
- Tags: context, source event
### Badges & Reputation
- **8**: `Badge Award` (NIP-58) - Award a badge to someone
- Tags: `a` for badge definition, `p` for recipient
### Generic Events
- **16**: `Generic Repost` (NIP-18) - Repost any event kind
- More flexible than kind 6
- **27235**: `HTTP Auth` (NIP-98) - Authenticate HTTP requests
- Tags: URL, method
## Event Kind Ranges Summary
| Range | Type | Behavior | Examples |
|-------|------|----------|----------|
| 0-999 | Core | Varies | Metadata, notes, reactions |
| 1000-9999 | Regular | Immutable, all kept | File metadata |
| 10000-19999 | Replaceable | Only latest kept | Mute list, relay list |
| 20000-29999 | Ephemeral | Not stored | Typing, presence |
| 30000-39999 | Parameterized Replaceable | Replaced by `d` tag | Articles, lists, badges |
## Event Lifecycle
### Regular Events (1000-9999)
```
Event A published → Stored
Event A' published → Both A and A' stored
```
### Replaceable Events (10000-19999)
```
Event A published → Stored
Event A' published (same kind, same pubkey) → A deleted, A' stored
```
### Parameterized Replaceable Events (30000-39999)
```
Event A (d="foo") published → Stored
Event B (d="bar") published → Both stored (different d)
Event A' (d="foo") published → A deleted, A' stored (same d)
```
### Ephemeral Events (20000-29999)
```
Event A published → Forwarded to subscribers, NOT stored
```
## Common Patterns
### Metadata (Kind 0)
```json
{
"kind": 0,
"content": "{\"name\":\"Alice\",\"about\":\"Nostr user\",\"picture\":\"https://...\",\"nip05\":\"alice@example.com\"}",
"tags": []
}
```
### Text Note (Kind 1)
```json
{
"kind": 1,
"content": "Hello Nostr!",
"tags": [
["t", "nostr"],
["t", "hello"]
]
}
```
### Reply (Kind 1 with thread tags)
```json
{
"kind": 1,
"content": "Great post!",
"tags": [
["e", "<root-event-id>", "<relay>", "root"],
["e", "<parent-event-id>", "<relay>", "reply"],
["p", "<author-pubkey>"]
]
}
```
### Reaction (Kind 7)
```json
{
"kind": 7,
"content": "+",
"tags": [
["e", "<reacted-event-id>"],
["p", "<event-author-pubkey>"],
["k", "1"]
]
}
```
### Long-form Article (Kind 30023)
```json
{
"kind": 30023,
"content": "# My Article\n\nContent here...",
"tags": [
["d", "my-article-slug"],
["title", "My Article"],
["summary", "This is about..."],
["published_at", "1234567890"],
["t", "nostr"],
["image", "https://..."]
]
}
```
### Relay List (Kind 10002)
```json
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay1.com"],
["r", "wss://relay2.com", "write"],
["r", "wss://relay3.com", "read"]
]
}
```
### Zap Request (Kind 9734)
```json
{
"kind": 9734,
"content": "",
"tags": [
["relays", "wss://relay1.com", "wss://relay2.com"],
["amount", "21000"],
["lnurl", "lnurl..."],
["p", "<recipient-pubkey>"],
["e", "<event-id>"]
]
}
```
### File Metadata (Kind 1063)
```json
{
"kind": 1063,
"content": "My photo from the trip",
"tags": [
["url", "https://cdn.example.com/image.jpg"],
["m", "image/jpeg"],
["x", "abc123..."],
["size", "524288"],
["dim", "1920x1080"],
["blurhash", "LEHV6n..."]
]
}
```
### Report (Kind 1984)
```json
{
"kind": 1984,
"content": "This is spam",
"tags": [
["e", "<reported-event-id>", "<relay>"],
["p", "<reported-pubkey>"],
["report", "spam"]
]
}
```
## Future Event Kinds
The event kind space is open-ended. New NIPs may define new event kinds.
**Guidelines for new event kinds**:
1. Use appropriate range for desired behavior
2. Document in a NIP
3. Implement in at least 2 clients and 1 relay
4. Ensure backwards compatibility
5. Don't overlap with existing kinds
**Custom event kinds**:
- Applications can use undefined event kinds
- Document behavior for interoperability
- Consider proposing as a NIP if useful broadly
## Event Kind Selection Guide
**Choose based on lifecycle needs**:
- **Regular (1000-9999)**: When you need history
- User posts, comments, reactions
- Payment records, receipts
- Immutable records
- **Replaceable (10000-19999)**: When you need latest state
- User settings, preferences
- Mute/block lists
- Current status
- **Ephemeral (20000-29999)**: When you need real-time only
- Typing indicators
- Online presence
- Temporary notifications
- **Parameterized Replaceable (30000-39999)**: When you need multiple latest states
- Articles (one per slug)
- Product listings (one per product ID)
- Configuration sets (one per setting name)
## References
- NIPs Repository: https://github.com/nostr-protocol/nips
- NIP-16: Event Treatment
- NIP-01: Event structure
- Various feature NIPs for specific kinds

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
# React 19 Skill
A comprehensive Claude skill for working with React 19, including hooks, components, server components, and modern React architecture.
## Contents
### Main Skill File
- **SKILL.md** - Main skill document with React 19 fundamentals, hooks, components, and best practices
### References
- **hooks-quick-reference.md** - Quick reference for all React hooks with examples
- **server-components.md** - Complete guide to React Server Components and Server Functions
- **performance.md** - Performance optimization strategies and techniques
### Examples
- **practical-patterns.tsx** - Real-world React patterns and solutions
## What This Skill Covers
### Core Topics
- React 19 features and improvements
- All built-in hooks (useState, useEffect, useTransition, useOptimistic, etc.)
- Component patterns and composition
- Server Components and Server Functions
- React Compiler and automatic optimization
- Performance optimization techniques
- Form handling and validation
- Error boundaries and error handling
- Context and global state management
- Code splitting and lazy loading
### Best Practices
- Component design principles
- State management strategies
- Performance optimization
- Error handling patterns
- TypeScript integration
- Testing considerations
- Accessibility guidelines
## When to Use This Skill
Use this skill when:
- Building React 19 applications
- Working with React hooks
- Implementing server components
- Optimizing React performance
- Troubleshooting React-specific issues
- Understanding concurrent features
- Working with forms and user input
- Implementing complex UI patterns
## Quick Start Examples
### Basic Component
```typescript
interface ButtonProps {
label: string
onClick: () => void
}
const Button = ({ label, onClick }: ButtonProps) => {
return <button onClick={onClick}>{label}</button>
}
```
### Using Hooks
```typescript
const Counter = () => {
const [count, setCount] = useState(0)
useEffect(() => {
console.log(`Count is: ${count}`)
}, [count])
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}
```
### Server Component
```typescript
const Page = async () => {
const data = await fetchData()
return <div>{data}</div>
}
```
### Server Function
```typescript
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name')
return await db.user.create({ data: { name } })
}
```
## Related Skills
- **typescript** - TypeScript patterns for React
- **ndk** - Nostr integration with React
- **skill-creator** - Creating reusable component libraries
## Resources
- [React Documentation](https://react.dev)
- [React API Reference](https://react.dev/reference/react)
- [React Hooks Reference](https://react.dev/reference/react/hooks)
- [React Server Components](https://react.dev/reference/rsc)
- [React Compiler](https://react.dev/reference/react-compiler)
## Version
This skill is based on React 19.2 and includes the latest features and APIs.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,878 @@
# React Practical Examples
This file contains real-world examples of React patterns and solutions.
## Example 1: Custom Hook for Data Fetching
```typescript
import { useState, useEffect } from 'react'
interface FetchState<T> {
data: T | null
loading: boolean
error: Error | null
}
const useFetch = <T,>(url: string) => {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null
})
useEffect(() => {
let cancelled = false
const controller = new AbortController()
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }))
const response = await fetch(url, {
signal: controller.signal
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (!cancelled) {
setState({ data, loading: false, error: null })
}
} catch (error) {
if (!cancelled && error.name !== 'AbortError') {
setState({
data: null,
loading: false,
error: error as Error
})
}
}
}
fetchData()
return () => {
cancelled = true
controller.abort()
}
}, [url])
return state
}
// Usage
const UserProfile = ({ userId }: { userId: string }) => {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`)
if (loading) return <Spinner />
if (error) return <ErrorMessage error={error} />
if (!data) return null
return <UserCard user={data} />
}
```
## Example 2: Form with Validation
```typescript
import { useState, useCallback } from 'react'
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be 18 or older')
})
type UserForm = z.infer<typeof userSchema>
type FormErrors = Partial<Record<keyof UserForm, string>>
const UserForm = () => {
const [formData, setFormData] = useState<UserForm>({
name: '',
email: '',
age: 0
})
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const handleChange = useCallback((
field: keyof UserForm,
value: string | number
) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Clear error when user starts typing
setErrors(prev => ({ ...prev, [field]: undefined }))
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validate
const result = userSchema.safeParse(formData)
if (!result.success) {
const fieldErrors: FormErrors = {}
result.error.errors.forEach(err => {
const field = err.path[0] as keyof UserForm
fieldErrors[field] = err.message
})
setErrors(fieldErrors)
return
}
// Submit
setIsSubmitting(true)
try {
await submitUser(result.data)
// Success handling
} catch (error) {
console.error(error)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={formData.name}
onChange={e => handleChange('name', e.target.value)}
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={e => handleChange('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
value={formData.age || ''}
onChange={e => handleChange('age', Number(e.target.value))}
/>
{errors.age && <span className="error">{errors.age}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}
```
## Example 3: Modal with Portal
```typescript
import { createPortal } from 'react-dom'
import { useEffect, useRef, useState } from 'react'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
title?: string
}
const Modal = ({ isOpen, onClose, children, title }: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null)
// Close on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
// Close on backdrop click
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === modalRef.current) {
onClose()
}
}
if (!isOpen) return null
return createPortal(
<div
ref={modalRef}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={handleBackdropClick}
>
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex justify-between items-center mb-4">
{title && <h2 className="text-xl font-bold">{title}</h2>}
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
aria-label="Close modal"
>
</button>
</div>
{children}
</div>
</div>,
document.body
)
}
// Usage
const App = () => {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="My Modal">
<p>Modal content goes here</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</Modal>
</>
)
}
```
## Example 4: Infinite Scroll
```typescript
import { useState, useEffect, useRef, useCallback } from 'react'
interface InfiniteScrollProps<T> {
fetchData: (page: number) => Promise<T[]>
renderItem: (item: T, index: number) => React.ReactNode
loader?: React.ReactNode
endMessage?: React.ReactNode
}
const InfiniteScroll = <T extends { id: string | number },>({
fetchData,
renderItem,
loader = <div>Loading...</div>,
endMessage = <div>No more items</div>
}: InfiniteScrollProps<T>) => {
const [items, setItems] = useState<T[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const observerRef = useRef<IntersectionObserver | null>(null)
const loadMoreRef = useRef<HTMLDivElement>(null)
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
try {
const newItems = await fetchData(page)
if (newItems.length === 0) {
setHasMore(false)
} else {
setItems(prev => [...prev, ...newItems])
setPage(prev => prev + 1)
}
} catch (error) {
console.error('Failed to load items:', error)
} finally {
setLoading(false)
}
}, [page, loading, hasMore, fetchData])
// Set up intersection observer
useEffect(() => {
observerRef.current = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
loadMore()
}
},
{ threshold: 0.1 }
)
const currentRef = loadMoreRef.current
if (currentRef) {
observerRef.current.observe(currentRef)
}
return () => {
if (observerRef.current && currentRef) {
observerRef.current.unobserve(currentRef)
}
}
}, [loadMore])
// Initial load
useEffect(() => {
loadMore()
}, [])
return (
<div>
{items.map((item, index) => (
<div key={item.id}>
{renderItem(item, index)}
</div>
))}
<div ref={loadMoreRef}>
{loading && loader}
{!loading && !hasMore && endMessage}
</div>
</div>
)
}
// Usage
const PostsList = () => {
const fetchPosts = async (page: number) => {
const response = await fetch(`/api/posts?page=${page}`)
return response.json()
}
return (
<InfiniteScroll<Post>
fetchData={fetchPosts}
renderItem={(post) => <PostCard post={post} />}
/>
)
}
```
## Example 5: Dark Mode Toggle
```typescript
import { createContext, useContext, useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType | null>(null)
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Theme>(() => {
// Check localStorage and system preference
const saved = localStorage.getItem('theme') as Theme | null
if (saved) return saved
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
})
useEffect(() => {
// Update DOM and localStorage
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// Usage
const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme()
return (
<button onClick={toggleTheme} aria-label="Toggle theme">
{theme === 'light' ? '🌙' : '☀️'}
</button>
)
}
```
## Example 6: Debounced Search
```typescript
import { useState, useEffect, useMemo } from 'react'
const useDebounce = <T,>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Product[]>([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(query, 500)
useEffect(() => {
if (!debouncedQuery) {
setResults([])
return
}
const searchProducts = async () => {
setLoading(true)
try {
const response = await fetch(`/api/search?q=${debouncedQuery}`)
const data = await response.json()
setResults(data)
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}
searchProducts()
}, [debouncedQuery])
return (
<div>
<input
type="search"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
{loading && <Spinner />}
{!loading && results.length > 0 && (
<div>
{results.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
{!loading && query && results.length === 0 && (
<p>No results found for "{query}"</p>
)}
</div>
)
}
```
## Example 7: Tabs Component
```typescript
import { createContext, useContext, useState, useId } from 'react'
interface TabsContextType {
activeTab: string
setActiveTab: (id: string) => void
tabsId: string
}
const TabsContext = createContext<TabsContextType | null>(null)
const useTabs = () => {
const context = useContext(TabsContext)
if (!context) throw new Error('Tabs compound components must be used within Tabs')
return context
}
interface TabsProps {
children: React.ReactNode
defaultValue: string
className?: string
}
const Tabs = ({ children, defaultValue, className }: TabsProps) => {
const [activeTab, setActiveTab] = useState(defaultValue)
const tabsId = useId()
return (
<TabsContext.Provider value={{ activeTab, setActiveTab, tabsId }}>
<div className={className}>
{children}
</div>
</TabsContext.Provider>
)
}
const TabsList = ({ children, className }: {
children: React.ReactNode
className?: string
}) => (
<div role="tablist" className={className}>
{children}
</div>
)
interface TabsTriggerProps {
value: string
children: React.ReactNode
className?: string
}
const TabsTrigger = ({ value, children, className }: TabsTriggerProps) => {
const { activeTab, setActiveTab, tabsId } = useTabs()
const isActive = activeTab === value
return (
<button
role="tab"
id={`${tabsId}-tab-${value}`}
aria-controls={`${tabsId}-panel-${value}`}
aria-selected={isActive}
onClick={() => setActiveTab(value)}
className={`${className} ${isActive ? 'active' : ''}`}
>
{children}
</button>
)
}
interface TabsContentProps {
value: string
children: React.ReactNode
className?: string
}
const TabsContent = ({ value, children, className }: TabsContentProps) => {
const { activeTab, tabsId } = useTabs()
if (activeTab !== value) return null
return (
<div
role="tabpanel"
id={`${tabsId}-panel-${value}`}
aria-labelledby={`${tabsId}-tab-${value}`}
className={className}
>
{children}
</div>
)
}
// Export compound component
export { Tabs, TabsList, TabsTrigger, TabsContent }
// Usage
const App = () => (
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<h2>Profile Content</h2>
</TabsContent>
<TabsContent value="settings">
<h2>Settings Content</h2>
</TabsContent>
<TabsContent value="notifications">
<h2>Notifications Content</h2>
</TabsContent>
</Tabs>
)
```
## Example 8: Error Boundary
```typescript
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
this.props.onError?.(error, errorInfo)
}
reset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.reset)
}
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<pre>{this.state.error.message}</pre>
</details>
<button onClick={this.reset}>Try again</button>
</div>
)
}
return this.props.children
}
}
// Usage
const App = () => (
<ErrorBoundary
fallback={(error, reset) => (
<div>
<h1>Oops! Something went wrong</h1>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)}
onError={(error, errorInfo) => {
// Send to error tracking service
console.error('Error logged:', error, errorInfo)
}}
>
<YourApp />
</ErrorBoundary>
)
```
## Example 9: Custom Hook for Local Storage
```typescript
import { useState, useEffect, useCallback } from 'react'
const useLocalStorage = <T,>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void, () => void] => {
// Get initial value from localStorage
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(`Error loading ${key} from localStorage:`, error)
return initialValue
}
})
// Update localStorage when value changes
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
// Dispatch storage event for other tabs
window.dispatchEvent(new Event('storage'))
} catch (error) {
console.error(`Error saving ${key} to localStorage:`, error)
}
}, [key, storedValue])
// Remove from localStorage
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key)
setStoredValue(initialValue)
} catch (error) {
console.error(`Error removing ${key} from localStorage:`, error)
}
}, [key, initialValue])
// Listen for changes in other tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue))
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [key])
return [storedValue, setValue, removeValue]
}
// Usage
const UserPreferences = () => {
const [preferences, setPreferences, clearPreferences] = useLocalStorage('user-prefs', {
theme: 'light',
language: 'en',
notifications: true
})
return (
<div>
<label>
<input
type="checkbox"
checked={preferences.notifications}
onChange={e => setPreferences({
...preferences,
notifications: e.target.checked
})}
/>
Enable notifications
</label>
<button onClick={clearPreferences}>
Reset to defaults
</button>
</div>
)
}
```
## Example 10: Optimistic Updates with useOptimistic
```typescript
'use client'
import { useOptimistic } from 'react'
import { likePost, unlikePost } from './actions'
interface Post {
id: string
content: string
likes: number
isLiked: boolean
}
const PostCard = ({ post }: { post: Post }) => {
const [optimisticPost, addOptimistic] = useOptimistic(
post,
(currentPost, update: Partial<Post>) => ({
...currentPost,
...update
})
)
const handleLike = async () => {
// Optimistically update UI
addOptimistic({
likes: optimisticPost.likes + 1,
isLiked: true
})
try {
// Send server request
await likePost(post.id)
} catch (error) {
// Server will send correct state via revalidation
console.error('Failed to like post:', error)
}
}
const handleUnlike = async () => {
addOptimistic({
likes: optimisticPost.likes - 1,
isLiked: false
})
try {
await unlikePost(post.id)
} catch (error) {
console.error('Failed to unlike post:', error)
}
}
return (
<div className="post-card">
<p>{optimisticPost.content}</p>
<button
onClick={optimisticPost.isLiked ? handleUnlike : handleLike}
className={optimisticPost.isLiked ? 'liked' : ''}
>
❤️ {optimisticPost.likes}
</button>
</div>
)
}
```
## References
These examples demonstrate:
- Custom hooks for reusable logic
- Form handling with validation
- Portal usage for modals
- Infinite scroll with Intersection Observer
- Context for global state
- Debouncing for performance
- Compound components pattern
- Error boundaries
- LocalStorage integration
- Optimistic updates (React 19)

View File

@@ -0,0 +1,291 @@
# React Hooks Quick Reference
## State Hooks
### useState
```typescript
const [state, setState] = useState<Type>(initialValue)
const [count, setCount] = useState(0)
// Functional update
setCount(prev => prev + 1)
// Lazy initialization
const [state, setState] = useState(() => expensiveComputation())
```
### useReducer
```typescript
type State = { count: number }
type Action = { type: 'increment' } | { type: 'decrement' }
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'increment': return { count: state.count + 1 }
case 'decrement': return { count: state.count - 1 }
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 })
dispatch({ type: 'increment' })
```
### useActionState (React 19)
```typescript
const [state, formAction, isPending] = useActionState(
async (previousState, formData: FormData) => {
// Server action
return await processForm(formData)
},
initialState
)
<form action={formAction}>
<button disabled={isPending}>Submit</button>
</form>
```
## Effect Hooks
### useEffect
```typescript
useEffect(() => {
// Side effect
const subscription = api.subscribe()
// Cleanup
return () => subscription.unsubscribe()
}, [dependencies])
```
**Timing**: After render & paint
**Use for**: Data fetching, subscriptions, DOM mutations
### useLayoutEffect
```typescript
useLayoutEffect(() => {
// Runs before paint
const height = ref.current.offsetHeight
setHeight(height)
}, [])
```
**Timing**: After render, before paint
**Use for**: DOM measurements, preventing flicker
### useInsertionEffect
```typescript
useInsertionEffect(() => {
// Insert styles before any DOM reads
const style = document.createElement('style')
style.textContent = css
document.head.appendChild(style)
return () => document.head.removeChild(style)
}, [css])
```
**Timing**: Before any DOM mutations
**Use for**: CSS-in-JS libraries
## Performance Hooks
### useMemo
```typescript
const memoizedValue = useMemo(() => {
return expensiveComputation(a, b)
}, [a, b])
```
**Use for**: Expensive calculations, stable object references
### useCallback
```typescript
const memoizedCallback = useCallback(() => {
doSomething(a, b)
}, [a, b])
```
**Use for**: Passing callbacks to optimized components
## Ref Hooks
### useRef
```typescript
// DOM reference
const ref = useRef<HTMLDivElement>(null)
ref.current?.focus()
// Mutable value (doesn't trigger re-render)
const countRef = useRef(0)
countRef.current += 1
```
### useImperativeHandle
```typescript
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => inputRef.current && (inputRef.current.value = '')
}), [])
```
## Context Hook
### useContext
```typescript
const value = useContext(MyContext)
```
Must be used within a Provider.
## Transition Hooks
### useTransition
```typescript
const [isPending, startTransition] = useTransition()
startTransition(() => {
setState(newValue) // Non-urgent update
})
```
### useDeferredValue
```typescript
const [input, setInput] = useState('')
const deferredInput = useDeferredValue(input)
// Use deferredInput for expensive operations
const results = useMemo(() => search(deferredInput), [deferredInput])
```
## Optimistic Updates (React 19)
### useOptimistic
```typescript
const [optimisticState, addOptimistic] = useOptimistic(
actualState,
(currentState, optimisticValue) => {
return [...currentState, optimisticValue]
}
)
```
## Other Hooks
### useId
```typescript
const id = useId()
<label htmlFor={id}>Name</label>
<input id={id} />
```
### useSyncExternalStore
```typescript
const state = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot
)
```
### useDebugValue
```typescript
useDebugValue(isOnline ? 'Online' : 'Offline')
```
### use (React 19)
```typescript
// Read context or promise
const value = use(MyContext)
const data = use(fetchPromise) // Must be in Suspense
```
## Form Hooks (React DOM)
### useFormStatus
```typescript
import { useFormStatus } from 'react-dom'
const { pending, data, method, action } = useFormStatus()
```
## Hook Rules
1. **Only call at top level** - Not in loops, conditions, or nested functions
2. **Only call from React functions** - Components or custom hooks
3. **Custom hooks start with "use"** - Naming convention
4. **Same hooks in same order** - Every render must call same hooks
## Dependencies Best Practices
1. **Include all used values** - Variables, props, state from component scope
2. **Use ESLint plugin** - `eslint-plugin-react-hooks` enforces rules
3. **Functions as dependencies** - Wrap with useCallback or define outside component
4. **Object/array dependencies** - Use useMemo for stable references
## Common Patterns
### Fetching Data
```typescript
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
return () => controller.abort()
}, [])
```
### Debouncing
```typescript
const [value, setValue] = useState('')
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, 500)
return () => clearTimeout(timer)
}, [value])
```
### Previous Value
```typescript
const usePrevious = <T,>(value: T): T | undefined => {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
})
return ref.current
}
```
### Interval
```typescript
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
```
### Event Listeners
```typescript
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
```

View File

@@ -0,0 +1,658 @@
# React Performance Optimization Guide
## Overview
This guide covers performance optimization strategies for React 19 applications.
## Measurement & Profiling
### React DevTools Profiler
Record performance data:
1. Open React DevTools
2. Go to Profiler tab
3. Click record button
4. Interact with app
5. Stop recording
6. Analyze flame graph and ranked chart
### Profiler Component
```typescript
import { Profiler } from 'react'
const App = () => {
const onRender = (
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) => {
console.log({
component: id,
phase,
actualDuration, // Time spent rendering this update
baseDuration // Estimated time without memoization
})
}
return (
<Profiler id="App" onRender={onRender}>
<YourApp />
</Profiler>
)
}
```
### Performance Metrics
```typescript
// Custom performance tracking
const startTime = performance.now()
// ... do work
const endTime = performance.now()
console.log(`Operation took ${endTime - startTime}ms`)
// React rendering metrics
import { unstable_trace as trace } from 'react'
trace('expensive-operation', async () => {
await performExpensiveOperation()
})
```
## Memoization Strategies
### React.memo
Prevent unnecessary re-renders:
```typescript
// Basic memoization
const ExpensiveComponent = memo(({ data }: Props) => {
return <div>{processData(data)}</div>
})
// Custom comparison
const MemoizedComponent = memo(
({ user }: Props) => <UserCard user={user} />,
(prevProps, nextProps) => {
// Return true if props are equal (skip render)
return prevProps.user.id === nextProps.user.id
}
)
```
**When to use:**
- Component renders often with same props
- Rendering is expensive
- Component receives complex prop objects
**When NOT to use:**
- Props change frequently
- Component is already fast
- Premature optimization
### useMemo
Memoize computed values:
```typescript
const SortedList = ({ items, filter }: Props) => {
// Without memoization - runs every render
const filteredItems = items.filter(item => item.type === filter)
const sortedItems = filteredItems.sort((a, b) => a.name.localeCompare(b.name))
// With memoization - only runs when dependencies change
const sortedFilteredItems = useMemo(() => {
const filtered = items.filter(item => item.type === filter)
return filtered.sort((a, b) => a.name.localeCompare(b.name))
}, [items, filter])
return (
<ul>
{sortedFilteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
```
**When to use:**
- Expensive calculations (sorting, filtering large arrays)
- Creating stable object references
- Computed values used as dependencies
### useCallback
Memoize callback functions:
```typescript
const Parent = () => {
const [count, setCount] = useState(0)
// Without useCallback - new function every render
const handleClick = () => {
setCount(c => c + 1)
}
// With useCallback - stable function reference
const handleClickMemo = useCallback(() => {
setCount(c => c + 1)
}, [])
return <MemoizedChild onClick={handleClickMemo} />
}
const MemoizedChild = memo(({ onClick }: Props) => {
return <button onClick={onClick}>Click</button>
})
```
**When to use:**
- Passing callbacks to memoized components
- Callback is used in dependency array
- Callback is expensive to create
## React Compiler (Automatic Optimization)
### Enable React Compiler
React 19 can automatically optimize without manual memoization:
```javascript
// babel.config.js
module.exports = {
plugins: [
['react-compiler', {
compilationMode: 'all', // Optimize all components
}]
]
}
```
### Compilation Modes
```javascript
{
compilationMode: 'annotation', // Only components with "use memo"
compilationMode: 'all', // All components (recommended)
compilationMode: 'infer' // Based on component complexity
}
```
### Directives
```typescript
// Force memoization
'use memo'
const Component = ({ data }: Props) => {
return <div>{data}</div>
}
// Prevent memoization
'use no memo'
const SimpleComponent = ({ text }: Props) => {
return <span>{text}</span>
}
```
## State Management Optimization
### State Colocation
Keep state as close as possible to where it's used:
```typescript
// Bad - state too high
const App = () => {
const [showModal, setShowModal] = useState(false)
return (
<>
<Header />
<Content />
<Modal show={showModal} onClose={() => setShowModal(false)} />
</>
)
}
// Good - state colocated
const App = () => {
return (
<>
<Header />
<Content />
<ModalContainer />
</>
)
}
const ModalContainer = () => {
const [showModal, setShowModal] = useState(false)
return <Modal show={showModal} onClose={() => setShowModal(false)} />
}
```
### Split Context
Avoid unnecessary re-renders by splitting context:
```typescript
// Bad - single context causes all consumers to re-render
const AppContext = createContext({ user, theme, settings })
// Good - split into separate contexts
const UserContext = createContext(user)
const ThemeContext = createContext(theme)
const SettingsContext = createContext(settings)
```
### Context with useMemo
```typescript
const ThemeProvider = ({ children }: Props) => {
const [theme, setTheme] = useState('light')
// Memoize context value to prevent unnecessary re-renders
const value = useMemo(() => ({
theme,
setTheme
}), [theme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
```
## Code Splitting & Lazy Loading
### React.lazy
Split components into separate bundles:
```typescript
import { lazy, Suspense } from 'react'
// Lazy load components
const Dashboard = lazy(() => import('./Dashboard'))
const Settings = lazy(() => import('./Settings'))
const Profile = lazy(() => import('./Profile'))
const App = () => {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
)
}
```
### Route-based Splitting
```typescript
// App.tsx
const routes = [
{ path: '/', component: lazy(() => import('./pages/Home')) },
{ path: '/about', component: lazy(() => import('./pages/About')) },
{ path: '/products', component: lazy(() => import('./pages/Products')) },
]
const App = () => (
<Suspense fallback={<PageLoader />}>
<Routes>
{routes.map(({ path, component: Component }) => (
<Route key={path} path={path} element={<Component />} />
))}
</Routes>
</Suspense>
)
```
### Component-based Splitting
```typescript
// Split expensive components
const HeavyChart = lazy(() => import('./HeavyChart'))
const Dashboard = () => {
const [showChart, setShowChart] = useState(false)
return (
<>
<button onClick={() => setShowChart(true)}>
Load Chart
</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</>
)
}
```
## List Rendering Optimization
### Keys
Always use stable, unique keys:
```typescript
// Bad - index as key (causes issues on reorder/insert)
{items.map((item, index) => (
<Item key={index} data={item} />
))}
// Good - unique ID as key
{items.map(item => (
<Item key={item.id} data={item} />
))}
// For static lists without IDs
{items.map(item => (
<Item key={`${item.name}-${item.category}`} data={item} />
))}
```
### Virtualization
For long lists, render only visible items:
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
const VirtualList = ({ items }: { items: Item[] }) => {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
overscan: 5 // Render 5 extra items above/below viewport
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative'
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`
}}
>
<Item data={items[virtualItem.index]} />
</div>
))}
</div>
</div>
)
}
```
### Pagination
```typescript
const PaginatedList = ({ items }: Props) => {
const [page, setPage] = useState(1)
const itemsPerPage = 20
const paginatedItems = useMemo(() => {
const start = (page - 1) * itemsPerPage
const end = start + itemsPerPage
return items.slice(start, end)
}, [items, page, itemsPerPage])
return (
<>
{paginatedItems.map(item => (
<Item key={item.id} data={item} />
))}
<Pagination
page={page}
total={Math.ceil(items.length / itemsPerPage)}
onChange={setPage}
/>
</>
)
}
```
## Transitions & Concurrent Features
### useTransition
Keep UI responsive during expensive updates:
```typescript
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const handleSearch = (value: string) => {
setQuery(value) // Urgent - update input immediately
// Non-urgent - can be interrupted
startTransition(() => {
const filtered = expensiveFilter(items, value)
setResults(filtered)
})
}
return (
<>
<input value={query} onChange={e => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
}
```
### useDeferredValue
Defer non-urgent renders:
```typescript
const SearchPage = () => {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
// Input updates immediately
// Results update with deferred value (can be interrupted)
const results = useMemo(() => {
return expensiveFilter(items, deferredQuery)
}, [deferredQuery])
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ResultsList results={results} />
</>
)
}
```
## Image & Asset Optimization
### Lazy Load Images
```typescript
const LazyImage = ({ src, alt }: Props) => {
const [isLoaded, setIsLoaded] = useState(false)
return (
<div className="relative">
{!isLoaded && <ImageSkeleton />}
<img
src={src}
alt={alt}
loading="lazy" // Native lazy loading
onLoad={() => setIsLoaded(true)}
className={isLoaded ? 'opacity-100' : 'opacity-0'}
/>
</div>
)
}
```
### Next.js Image Component
```typescript
import Image from 'next/image'
const OptimizedImage = () => (
<Image
src="/hero.jpg"
alt="Hero"
width={800}
height={600}
priority // Load immediately for above-fold images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
)
```
## Bundle Size Optimization
### Tree Shaking
Import only what you need:
```typescript
// Bad - imports entire library
import _ from 'lodash'
// Good - import only needed functions
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'
// Even better - use native methods when possible
const debounce = (fn, delay) => {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => fn(...args), delay)
}
}
```
### Analyze Bundle
```bash
# Next.js
ANALYZE=true npm run build
# Create React App
npm install --save-dev webpack-bundle-analyzer
```
### Dynamic Imports
```typescript
// Load library only when needed
const handleExport = async () => {
const { jsPDF } = await import('jspdf')
const doc = new jsPDF()
doc.save('report.pdf')
}
```
## Common Performance Pitfalls
### 1. Inline Object Creation
```typescript
// Bad - new object every render
<Component style={{ margin: 10 }} />
// Good - stable reference
const style = { margin: 10 }
<Component style={style} />
// Or use useMemo
const style = useMemo(() => ({ margin: 10 }), [])
```
### 2. Inline Functions
```typescript
// Bad - new function every render (if child is memoized)
<MemoizedChild onClick={() => handleClick(id)} />
// Good
const handleClickMemo = useCallback(() => handleClick(id), [id])
<MemoizedChild onClick={handleClickMemo} />
```
### 3. Spreading Props
```typescript
// Bad - causes re-renders even when props unchanged
<Component {...props} />
// Good - pass only needed props
<Component value={props.value} onChange={props.onChange} />
```
### 4. Large Context
```typescript
// Bad - everything re-renders on any state change
const AppContext = createContext({ user, theme, cart, settings, ... })
// Good - split into focused contexts
const UserContext = createContext(user)
const ThemeContext = createContext(theme)
const CartContext = createContext(cart)
```
## Performance Checklist
- [ ] Measure before optimizing (use Profiler)
- [ ] Use React DevTools to identify slow components
- [ ] Implement code splitting for large routes
- [ ] Lazy load below-the-fold content
- [ ] Virtualize long lists
- [ ] Memoize expensive calculations
- [ ] Split large contexts
- [ ] Colocate state close to usage
- [ ] Use transitions for non-urgent updates
- [ ] Optimize images and assets
- [ ] Analyze and minimize bundle size
- [ ] Remove console.logs in production
- [ ] Use production build for testing
- [ ] Monitor real-world performance metrics
## References
- React Performance: https://react.dev/learn/render-and-commit
- React Profiler: https://react.dev/reference/react/Profiler
- React Compiler: https://react.dev/reference/react-compiler
- Web Vitals: https://web.dev/vitals/

View File

@@ -0,0 +1,656 @@
# React Server Components & Server Functions
## Overview
React Server Components (RSC) allow components to render on the server, improving performance and enabling direct data access. Server Functions allow client components to call server-side functions.
## Server Components
### What are Server Components?
Components that run **only on the server**:
- Can access databases directly
- Zero bundle size (code stays on server)
- Better performance (less JavaScript to client)
- Automatic code splitting
### Creating Server Components
```typescript
// app/products/page.tsx
// Server Component by default in App Router
import { db } from '@/lib/db'
const ProductsPage = async () => {
// Direct database access
const products = await db.product.findMany({
where: { active: true },
include: { category: true }
})
return (
<div>
<h1>Products</h1>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
export default ProductsPage
```
### Server Component Rules
**Can do:**
- Access databases and APIs directly
- Use server-only modules (fs, path, etc.)
- Keep secrets secure (API keys, tokens)
- Reduce client bundle size
- Use async/await at top level
**Cannot do:**
- Use hooks (useState, useEffect, etc.)
- Use browser APIs (window, document)
- Attach event handlers (onClick, etc.)
- Use Context
### Mixing Server and Client Components
```typescript
// Server Component (default)
const Page = async () => {
const data = await fetchData()
return (
<div>
<ServerComponent data={data} />
{/* Client component for interactivity */}
<ClientComponent initialData={data} />
</div>
)
}
// Client Component
'use client'
import { useState } from 'react'
const ClientComponent = ({ initialData }) => {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
)
}
```
### Server Component Patterns
#### Data Fetching
```typescript
// app/user/[id]/page.tsx
interface PageProps {
params: { id: string }
}
const UserPage = async ({ params }: PageProps) => {
const user = await db.user.findUnique({
where: { id: params.id }
})
if (!user) {
notFound() // Next.js 404
}
return <UserProfile user={user} />
}
```
#### Parallel Data Fetching
```typescript
const DashboardPage = async () => {
// Fetch in parallel
const [user, orders, stats] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchStats()
])
return (
<>
<UserHeader user={user} />
<OrdersList orders={orders} />
<StatsWidget stats={stats} />
</>
)
}
```
#### Streaming with Suspense
```typescript
const Page = () => {
return (
<>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</>
)
}
const Products = async () => {
const products = await fetchProducts() // Slow query
return <ProductsList products={products} />
}
```
## Server Functions (Server Actions)
### What are Server Functions?
Functions that run on the server but can be called from client components:
- Marked with `'use server'` directive
- Can mutate data
- Integrated with forms
- Type-safe with TypeScript
### Creating Server Functions
#### File-level directive
```typescript
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = Number(formData.get('price'))
const product = await db.product.create({
data: { name, price }
})
revalidatePath('/products')
return product
}
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } })
revalidatePath('/products')
}
```
#### Function-level directive
```typescript
// Inside a Server Component
const MyComponent = async () => {
async function handleSubmit(formData: FormData) {
'use server'
const email = formData.get('email') as string
await saveEmail(email)
}
return <form action={handleSubmit}>...</form>
}
```
### Using Server Functions
#### With Forms
```typescript
'use client'
import { createProduct } from './actions'
const ProductForm = () => {
return (
<form action={createProduct}>
<input name="name" required />
<input name="price" type="number" required />
<button type="submit">Create</button>
</form>
)
}
```
#### With useActionState
```typescript
'use client'
import { useActionState } from 'react'
import { createProduct } from './actions'
type FormState = {
message: string
success: boolean
} | null
const ProductForm = () => {
const [state, formAction, isPending] = useActionState<FormState>(
async (previousState, formData: FormData) => {
try {
await createProduct(formData)
return { message: 'Product created!', success: true }
} catch (error) {
return { message: 'Failed to create product', success: false }
}
},
null
)
return (
<form action={formAction}>
<input name="name" required />
<input name="price" type="number" required />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
{state?.message && (
<p className={state.success ? 'text-green-600' : 'text-red-600'}>
{state.message}
</p>
)}
</form>
)
}
```
#### Programmatic Invocation
```typescript
'use client'
import { deleteProduct } from './actions'
const DeleteButton = ({ productId }: { productId: string }) => {
const [isPending, setIsPending] = useState(false)
const handleDelete = async () => {
setIsPending(true)
try {
await deleteProduct(productId)
} catch (error) {
console.error(error)
} finally {
setIsPending(false)
}
}
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
)
}
```
### Server Function Patterns
#### Validation with Zod
```typescript
'use server'
import { z } from 'zod'
const ProductSchema = z.object({
name: z.string().min(3),
price: z.number().positive(),
description: z.string().optional()
})
export async function createProduct(formData: FormData) {
const rawData = {
name: formData.get('name'),
price: Number(formData.get('price')),
description: formData.get('description')
}
// Validate
const result = ProductSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
// Create product
const product = await db.product.create({
data: result.data
})
revalidatePath('/products')
return { success: true, product }
}
```
#### Authentication Check
```typescript
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function createOrder(formData: FormData) {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
const order = await db.order.create({
data: {
userId: session.user.id,
// ... other fields
}
})
return order
}
```
#### Error Handling
```typescript
'use server'
export async function updateProfile(formData: FormData) {
try {
const userId = await getCurrentUserId()
const profile = await db.user.update({
where: { id: userId },
data: {
name: formData.get('name') as string,
bio: formData.get('bio') as string
}
})
revalidatePath('/profile')
return { success: true, profile }
} catch (error) {
console.error('Failed to update profile:', error)
return {
success: false,
error: 'Failed to update profile. Please try again.'
}
}
}
```
#### Optimistic Updates
```typescript
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
const Post = ({ post }: { post: Post }) => {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(currentLikes) => currentLikes + 1
)
const handleLike = async () => {
addOptimisticLike(null)
await likePost(post.id)
}
return (
<div>
<p>{post.content}</p>
<button onClick={handleLike}>
{optimisticLikes}
</button>
</div>
)
}
```
## Data Mutations & Revalidation
### revalidatePath
Invalidate cached data for a path:
```typescript
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({ data: {...} })
// Revalidate the posts page
revalidatePath('/posts')
// Revalidate with layout
revalidatePath('/posts', 'layout')
}
```
### revalidateTag
Invalidate cached data by tag:
```typescript
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data })
// Revalidate all queries tagged with 'products'
revalidateTag('products')
}
```
### redirect
Redirect after mutation:
```typescript
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: {...} })
// Redirect to the new post
redirect(`/posts/${post.id}`)
}
```
## Caching with Server Components
### cache Function
Deduplicate requests within a render:
```typescript
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return await db.user.findUnique({ where: { id } })
})
// Called multiple times but only fetches once per render
const Page = async () => {
const user1 = await getUser('123')
const user2 = await getUser('123') // Uses cached result
return <div>...</div>
}
```
### Next.js fetch Caching
```typescript
// Cached by default
const data = await fetch('https://api.example.com/data')
// Revalidate every 60 seconds
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// Never cache
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
// Tag for revalidation
const data = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
})
```
## Best Practices
### 1. Component Placement
- Keep interactive components client-side
- Use server components for data fetching
- Place 'use client' as deep as possible in tree
### 2. Data Fetching
- Fetch in parallel when possible
- Use Suspense for streaming
- Cache expensive operations
### 3. Server Functions
- Validate all inputs
- Check authentication/authorization
- Handle errors gracefully
- Return serializable data only
### 4. Performance
- Minimize client JavaScript
- Use streaming for slow queries
- Implement proper caching
- Optimize database queries
### 5. Security
- Never expose secrets to client
- Validate server function inputs
- Use environment variables
- Implement rate limiting
## Common Patterns
### Layout with Dynamic Data
```typescript
// app/layout.tsx
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
const user = await getCurrentUser()
return (
<html>
<body>
<Header user={user} />
{children}
<Footer />
</body>
</html>
)
}
```
### Loading States
```typescript
// app/products/loading.tsx
export default function Loading() {
return <ProductsSkeleton />
}
// app/products/page.tsx
const ProductsPage = async () => {
const products = await fetchProducts()
return <ProductsList products={products} />
}
```
### Error Boundaries
```typescript
// app/products/error.tsx
'use client'
export default function Error({
error,
reset
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
```
### Search with Server Functions
```typescript
'use client'
import { searchProducts } from './actions'
import { useDeferredValue, useState, useEffect } from 'react'
const SearchPage = () => {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const deferredQuery = useDeferredValue(query)
useEffect(() => {
if (deferredQuery) {
searchProducts(deferredQuery).then(setResults)
}
}, [deferredQuery])
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<ResultsList results={results} />
</>
)
}
```
## Troubleshooting
### Common Issues
1. **"Cannot use hooks in Server Component"**
- Add 'use client' directive
- Move state logic to client component
2. **"Functions cannot be passed to Client Components"**
- Use Server Functions instead
- Pass data, not functions
3. **Hydration mismatches**
- Ensure server and client render same HTML
- Use useEffect for browser-only code
4. **Slow initial load**
- Implement Suspense boundaries
- Use streaming rendering
- Optimize database queries
## References
- React Server Components: https://react.dev/reference/rsc/server-components
- Server Functions: https://react.dev/reference/rsc/server-functions
- Next.js App Router: https://nextjs.org/docs/app

View File

@@ -199,4 +199,4 @@
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
limitations under the License.

View File

@@ -0,0 +1,209 @@
---
name: skill-creator
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
license: Complete terms in LICENSE.txt
---
# Skill Creator
This skill provides guidance for creating effective skills.
## About Skills
Skills are modular, self-contained packages that extend Claude's capabilities by providing
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
domains or tasks—they transform Claude from a general-purpose agent into a specialized agent
equipped with procedural knowledge that no model can fully possess.
### What Skills Provide
1. Specialized workflows - Multi-step procedures for specific domains
2. Tool integrations - Instructions for working with specific file formats or APIs
3. Domain expertise - Company-specific knowledge, schemas, business logic
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
### Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ └── description: (required)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
#### SKILL.md (required)
**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when Claude will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when...").
#### Bundled Resources (optional)
##### Scripts (`scripts/`)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments
##### References (`references/`)
Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking.
- **When to include**: For documentation that Claude should reference while working
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
##### Assets (`assets/`)
Files not intended to be loaded into context, but rather used within the output Claude produces.
- **When to include**: When the skill needs files that will be used in the final output
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context
### Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
1. **Metadata (name + description)** - Always in context (~100 words)
2. **SKILL.md body** - When skill triggers (<5k words)
3. **Bundled resources** - As needed by Claude (Unlimited*)
*Unlimited because scripts can be executed without reading into context window.
## Skill Creation Process
To create a skill, follow the "Skill Creation Process" in order, skipping steps only if there is a clear reason why they are not applicable.
### Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
### Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
1. Considering how to execute on the example from scratch
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
1. Rotating a PDF requires re-writing the same code each time
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
### Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
```bash
scripts/init_skill.py <skill-name> --path <output-directory>
```
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates example resource directories: `scripts/`, `references/`, and `assets/`
- Adds example files in each directory that can be customized or deleted
After initialization, customize or remove the generated SKILL.md and example files as needed.
### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Focus on including information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively.
#### Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
Also, delete any example files and directories not needed for the skill. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.
#### Update SKILL.md
**Writing Style:** Write the entire skill using **imperative/infinitive form** (verb-first instructions), not second person. Use objective, instructional language (e.g., "To accomplish X, do Y" rather than "You should do X" or "If you need to do X"). This maintains consistency and clarity for AI consumption.
To complete SKILL.md, answer the following questions:
1. What is the purpose of the skill, in a few sentences?
2. When should the skill be used?
3. In practice, how should Claude use the skill? All reusable skill contents developed above should be referenced so that Claude knows how to use them.
### Step 5: Packaging a Skill
Once the skill is ready, it should be packaged into a distributable zip file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
```bash
scripts/package_skill.py <path/to/skill-folder>
```
Optional output directory specification:
```bash
scripts/package_skill.py <path/to/skill-folder> ./dist
```
The packaging script will:
1. **Validate** the skill automatically, checking:
- YAML frontmatter format and required fields
- Skill naming conventions and directory structure
- Description completeness and quality
- File organization and resource references
2. **Package** the skill if validation passes, creating a zip file named after the skill (e.g., `my-skill.zip`) that includes all files and maintains the proper directory structure for distribution.
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
### Step 6: Iterate
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
**Iteration workflow:**
1. Use the skill on real tasks
2. Notice struggles or inefficiencies
3. Identify how SKILL.md or bundled resources should be updated
4. Implement changes and test again

View File

@@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
Skill Initializer - Creates a new skill from template
Usage:
init_skill.py <skill-name> --path <path>
Examples:
init_skill.py my-new-skill --path skills/public
init_skill.py my-api-helper --path skills/private
init_skill.py custom-skill --path /custom/location
"""
import sys
from pathlib import Path
SKILL_TEMPLATE = """---
name: {skill_name}
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
---
# {skill_title}
## Overview
[TODO: 1-2 sentences explaining what this skill enables]
## Structuring This Skill
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
**1. Workflow-Based** (best for sequential processes)
- Works well when there are clear step-by-step procedures
- Example: DOCX skill with "Workflow Decision Tree""Reading""Creating""Editing"
- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
**2. Task-Based** (best for tool collections)
- Works well when the skill offers different operations/capabilities
- Example: PDF skill with "Quick Start""Merge PDFs""Split PDFs""Extract Text"
- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
**3. Reference/Guidelines** (best for standards or specifications)
- Works well for brand guidelines, coding standards, or requirements
- Example: Brand styling with "Brand Guidelines""Colors""Typography""Features"
- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
**4. Capabilities-Based** (best for integrated systems)
- Works well when the skill provides multiple interrelated features
- Example: Product Management with "Core Capabilities" → numbered capability list
- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
## [TODO: Replace with the first main section based on chosen structure]
[TODO: Add content here. See examples in existing skills:
- Code samples for technical skills
- Decision trees for complex workflows
- Concrete examples with realistic user requests
- References to scripts/templates/references as needed]
## Resources
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
### scripts/
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
**Examples from other skills:**
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments.
### references/
Documentation and reference material intended to be loaded into context to inform Claude's process and thinking.
**Examples from other skills:**
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
- BigQuery: API reference documentation and query examples
- Finance: Schema documentation, company policies
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working.
### assets/
Files not intended to be loaded into context, but rather used within the output Claude produces.
**Examples from other skills:**
- Brand styling: PowerPoint template files (.pptx), logo files
- Frontend builder: HTML/React boilerplate project directories
- Typography: Font files (.ttf, .woff2)
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
---
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
"""
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
"""
Example helper script for {skill_name}
This is a placeholder script that can be executed directly.
Replace with actual implementation or delete if not needed.
Example real scripts from other skills:
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
"""
def main():
print("This is an example script for {skill_name}")
# TODO: Add actual script logic here
# This could be data processing, file conversion, API calls, etc.
if __name__ == "__main__":
main()
'''
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - Comprehensive guide for status updates
- product-management/references/context_building.md - Deep-dive on gathering context
- bigquery/references/ - API references and query examples
## When Reference Docs Are Useful
Reference docs are ideal for:
- Comprehensive API documentation
- Detailed workflow guides
- Complex multi-step processes
- Information too lengthy for main SKILL.md
- Content that's only needed for specific use cases
## Structure Suggestions
### API Reference Example
- Overview
- Authentication
- Endpoints with examples
- Error codes
- Rate limits
### Workflow Guide Example
- Prerequisites
- Step-by-step instructions
- Common patterns
- Troubleshooting
- Best practices
"""
EXAMPLE_ASSET = """# Example Asset File
This placeholder represents where asset files would be stored.
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
Asset files are NOT intended to be loaded into context, but rather used within
the output Claude produces.
Example asset files from other skills:
- Brand guidelines: logo.png, slides_template.pptx
- Frontend builder: hello-world/ directory with HTML/React boilerplate
- Typography: custom-font.ttf, font-family.woff2
- Data: sample_data.csv, test_dataset.json
## Common Asset Types
- Templates: .pptx, .docx, boilerplate directories
- Images: .png, .jpg, .svg, .gif
- Fonts: .ttf, .otf, .woff, .woff2
- Boilerplate code: Project directories, starter files
- Icons: .ico, .svg
- Data files: .csv, .json, .xml, .yaml
Note: This is a text placeholder. Actual assets can be any file type.
"""
def title_case_skill_name(skill_name):
"""Convert hyphenated skill name to Title Case for display."""
return ' '.join(word.capitalize() for word in skill_name.split('-'))
def init_skill(skill_name, path):
"""
Initialize a new skill directory with template SKILL.md.
Args:
skill_name: Name of the skill
path: Path where the skill directory should be created
Returns:
Path to created skill directory, or None if error
"""
# Determine skill directory path
skill_dir = Path(path).resolve() / skill_name
# Check if directory already exists
if skill_dir.exists():
print(f"❌ Error: Skill directory already exists: {skill_dir}")
return None
# Create skill directory
try:
skill_dir.mkdir(parents=True, exist_ok=False)
print(f"✅ Created skill directory: {skill_dir}")
except Exception as e:
print(f"❌ Error creating directory: {e}")
return None
# Create SKILL.md from template
skill_title = title_case_skill_name(skill_name)
skill_content = SKILL_TEMPLATE.format(
skill_name=skill_name,
skill_title=skill_title
)
skill_md_path = skill_dir / 'SKILL.md'
try:
skill_md_path.write_text(skill_content)
print("✅ Created SKILL.md")
except Exception as e:
print(f"❌ Error creating SKILL.md: {e}")
return None
# Create resource directories with example files
try:
# Create scripts/ directory with example script
scripts_dir = skill_dir / 'scripts'
scripts_dir.mkdir(exist_ok=True)
example_script = scripts_dir / 'example.py'
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
example_script.chmod(0o755)
print("✅ Created scripts/example.py")
# Create references/ directory with example reference doc
references_dir = skill_dir / 'references'
references_dir.mkdir(exist_ok=True)
example_reference = references_dir / 'api_reference.md'
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
print("✅ Created references/api_reference.md")
# Create assets/ directory with example asset placeholder
assets_dir = skill_dir / 'assets'
assets_dir.mkdir(exist_ok=True)
example_asset = assets_dir / 'example_asset.txt'
example_asset.write_text(EXAMPLE_ASSET)
print("✅ Created assets/example_asset.txt")
except Exception as e:
print(f"❌ Error creating resource directories: {e}")
return None
# Print next steps
print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}")
print("\nNext steps:")
print("1. Edit SKILL.md to complete the TODO items and update the description")
print("2. Customize or delete the example files in scripts/, references/, and assets/")
print("3. Run the validator when ready to check the skill structure")
return skill_dir
def main():
if len(sys.argv) < 4 or sys.argv[2] != '--path':
print("Usage: init_skill.py <skill-name> --path <path>")
print("\nSkill name requirements:")
print(" - Hyphen-case identifier (e.g., 'data-analyzer')")
print(" - Lowercase letters, digits, and hyphens only")
print(" - Max 40 characters")
print(" - Must match directory name exactly")
print("\nExamples:")
print(" init_skill.py my-new-skill --path skills/public")
print(" init_skill.py my-api-helper --path skills/private")
print(" init_skill.py custom-skill --path /custom/location")
sys.exit(1)
skill_name = sys.argv[1]
path = sys.argv[3]
print(f"🚀 Initializing skill: {skill_name}")
print(f" Location: {path}")
print()
result = init_skill(skill_name, path)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Skill Packager - Creates a distributable zip file of a skill folder
Usage:
python utils/package_skill.py <path/to/skill-folder> [output-directory]
Example:
python utils/package_skill.py skills/public/my-skill
python utils/package_skill.py skills/public/my-skill ./dist
"""
import sys
import zipfile
from pathlib import Path
from quick_validate import validate_skill
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a zip file.
Args:
skill_path: Path to the skill folder
output_dir: Optional output directory for the zip file (defaults to current directory)
Returns:
Path to the created zip file, or None if error
"""
skill_path = Path(skill_path).resolve()
# Validate skill folder exists
if not skill_path.exists():
print(f"❌ Error: Skill folder not found: {skill_path}")
return None
if not skill_path.is_dir():
print(f"❌ Error: Path is not a directory: {skill_path}")
return None
# Validate SKILL.md exists
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
print(f"❌ Error: SKILL.md not found in {skill_path}")
return None
# Run validation before packaging
print("🔍 Validating skill...")
valid, message = validate_skill(skill_path)
if not valid:
print(f"❌ Validation failed: {message}")
print(" Please fix the validation errors before packaging.")
return None
print(f"{message}\n")
# Determine output location
skill_name = skill_path.name
if output_dir:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
else:
output_path = Path.cwd()
zip_filename = output_path / f"{skill_name}.zip"
# Create the zip file
try:
with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Walk through the skill directory
for file_path in skill_path.rglob('*'):
if file_path.is_file():
# Calculate the relative path within the zip
arcname = file_path.relative_to(skill_path.parent)
zipf.write(file_path, arcname)
print(f" Added: {arcname}")
print(f"\n✅ Successfully packaged skill to: {zip_filename}")
return zip_filename
except Exception as e:
print(f"❌ Error creating zip file: {e}")
return None
def main():
if len(sys.argv) < 2:
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
print("\nExample:")
print(" python utils/package_skill.py skills/public/my-skill")
print(" python utils/package_skill.py skills/public/my-skill ./dist")
sys.exit(1)
skill_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"📦 Packaging skill: {skill_path}")
if output_dir:
print(f" Output directory: {output_dir}")
print()
result = package_skill(skill_path, output_dir)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Quick validation script for skills - minimal version
"""
import sys
import os
import re
from pathlib import Path
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
# Check SKILL.md exists
skill_md = skill_path / 'SKILL.md'
if not skill_md.exists():
return False, "SKILL.md not found"
# Read and validate frontmatter
content = skill_md.read_text()
if not content.startswith('---'):
return False, "No YAML frontmatter found"
# Extract frontmatter
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
frontmatter = match.group(1)
# Check required fields
if 'name:' not in frontmatter:
return False, "Missing 'name' in frontmatter"
if 'description:' not in frontmatter:
return False, "Missing 'description' in frontmatter"
# Extract name for validation
name_match = re.search(r'name:\s*(.+)', frontmatter)
if name_match:
name = name_match.group(1).strip()
# Check naming convention (hyphen-case: lowercase with hyphens)
if not re.match(r'^[a-z0-9-]+$', name):
return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)"
if name.startswith('-') or name.endswith('-') or '--' in name:
return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens"
# Extract and validate description
desc_match = re.search(r'description:\s*(.+)', frontmatter)
if desc_match:
description = desc_match.group(1).strip()
# Check for angle brackets
if '<' in description or '>' in description:
return False, "Description cannot contain angle brackets (< or >)"
return True, "Skill is valid!"
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python quick_validate.py <skill_directory>")
sys.exit(1)
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)

View File

@@ -0,0 +1,133 @@
# TypeScript Claude Skill
Comprehensive TypeScript skill for type-safe development with modern JavaScript/TypeScript applications.
## Overview
This skill provides in-depth knowledge about TypeScript's type system, patterns, best practices, and integration with popular frameworks like React. It covers everything from basic types to advanced type manipulation techniques.
## Files
### Core Documentation
- **SKILL.md** - Main skill file with workflows and when to use this skill
- **quick-reference.md** - Quick lookup guide for common TypeScript syntax and patterns
### Reference Materials
- **references/type-system.md** - Comprehensive guide to TypeScript's type system
- **references/utility-types.md** - Complete reference for built-in and custom utility types
- **references/common-patterns.md** - Real-world TypeScript patterns and idioms
### Examples
- **examples/type-system-basics.ts** - Fundamental TypeScript concepts
- **examples/advanced-types.ts** - Generics, conditional types, mapped types
- **examples/react-patterns.ts** - Type-safe React components and hooks
- **examples/README.md** - Guide to using the examples
## Usage
### When to Use This Skill
Reference this skill when:
- Writing or refactoring TypeScript code
- Designing type-safe APIs and interfaces
- Working with advanced type system features
- Configuring TypeScript projects
- Troubleshooting type errors
- Implementing type-safe patterns with libraries
- Converting JavaScript to TypeScript
### Quick Start
For quick lookups, start with `quick-reference.md` which provides concise syntax and patterns.
For learning or deep dives:
1. **Fundamentals**: Start with `references/type-system.md`
2. **Utilities**: Learn about transformations in `references/utility-types.md`
3. **Patterns**: Study real-world patterns in `references/common-patterns.md`
4. **Practice**: Explore code examples in `examples/`
## Key Topics Covered
### Type System
- Primitive types and special types
- Object types (interfaces, type aliases)
- Union and intersection types
- Literal types and template literal types
- Type inference and narrowing
- Generic types with constraints
- Conditional types and mapped types
- Recursive types
### Advanced Features
- Type guards and type predicates
- Assertion functions
- Branded types for nominal typing
- Key remapping and filtering
- Distributive conditional types
- Type-level programming
### Utility Types
- Built-in utilities (Partial, Pick, Omit, etc.)
- Custom utility type patterns
- Deep transformations
- Type composition
### React Integration
- Component props typing
- Generic components
- Hooks with TypeScript
- Context with type safety
- Event handlers
- Ref typing
### Best Practices
- Type safety patterns
- Error handling
- Code organization
- Integration with Zod for runtime validation
- Named return variables (Go-style)
- Discriminated unions for state management
## Integration with Project Stack
This skill is designed to work seamlessly with:
- **React 19**: Type-safe component development
- **TanStack Ecosystem**: Typed queries, routing, forms, and stores
- **Zod**: Runtime validation with type inference
- **Radix UI**: Component prop typing
- **Tailwind CSS**: Type-safe className composition
## Examples
All examples are self-contained and demonstrate practical patterns:
- Based on real-world usage
- Follow project best practices
- Include comprehensive comments
- Can be run with `ts-node`
- Ready to adapt to your needs
## Configuration
The skill includes guidance on TypeScript configuration with recommended settings for:
- Strict type checking
- Module resolution
- JSX support
- Path aliases
- Declaration files
## Contributing
When adding new patterns or examples:
1. Follow existing file structure
2. Include comprehensive comments
3. Demonstrate real-world usage
4. Add to appropriate reference file
5. Update this README if needed
## Resources
- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/)
- [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/)
- [Type Challenges](https://github.com/type-challenges/type-challenges)
- [TSConfig Reference](https://www.typescriptlang.org/tsconfig)

View File

@@ -0,0 +1,359 @@
---
name: typescript
description: This skill should be used when working with TypeScript code, including type definitions, type inference, generics, utility types, and TypeScript configuration. Provides comprehensive knowledge of TypeScript patterns, best practices, and advanced type system features.
---
# TypeScript Skill
This skill provides comprehensive knowledge and patterns for working with TypeScript effectively in modern applications.
## When to Use This Skill
Use this skill when:
- Writing or refactoring TypeScript code
- Designing type-safe APIs and interfaces
- Working with advanced type system features (generics, conditional types, mapped types)
- Configuring TypeScript projects (tsconfig.json)
- Troubleshooting type errors
- Implementing type-safe patterns with libraries (React, TanStack, etc.)
- Converting JavaScript code to TypeScript
## Core Concepts
### Type System Fundamentals
TypeScript provides static typing for JavaScript with a powerful type system that includes:
- Primitive types (string, number, boolean, null, undefined, symbol, bigint)
- Object types (interfaces, type aliases, classes)
- Array and tuple types
- Union and intersection types
- Literal types and template literal types
- Type inference and type narrowing
- Generic types with constraints
- Conditional types and mapped types
### Type Inference
Leverage TypeScript's type inference to write less verbose code:
- Let TypeScript infer return types when obvious
- Use type inference for variable declarations
- Rely on generic type inference in function calls
- Use `as const` for immutable literal types
### Type Safety Patterns
Implement type-safe patterns:
- Use discriminated unions for state management
- Implement type guards for runtime type checking
- Use branded types for nominal typing
- Leverage conditional types for API design
- Use template literal types for string manipulation
## Key Workflows
### 1. Designing Type-Safe APIs
When designing APIs, follow these patterns:
**Interface vs Type Alias:**
- Use `interface` for object shapes that may be extended
- Use `type` for unions, intersections, and complex type operations
- Use `type` with mapped types and conditional types
**Generic Constraints:**
```typescript
// Use extends for generic constraints
function getValue<T extends { id: string }>(item: T): string {
return item.id
}
```
**Discriminated Unions:**
```typescript
// Use for type-safe state machines
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; error: Error }
```
### 2. Working with Utility Types
Use built-in utility types for common transformations:
- `Partial<T>` - Make all properties optional
- `Required<T>` - Make all properties required
- `Readonly<T>` - Make all properties readonly
- `Pick<T, K>` - Select specific properties
- `Omit<T, K>` - Exclude specific properties
- `Record<K, T>` - Create object type with specific keys
- `Exclude<T, U>` - Exclude types from union
- `Extract<T, U>` - Extract types from union
- `NonNullable<T>` - Remove null/undefined
- `ReturnType<T>` - Get function return type
- `Parameters<T>` - Get function parameter types
- `Awaited<T>` - Unwrap Promise type
### 3. Advanced Type Patterns
**Mapped Types:**
```typescript
// Transform object types
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
type ReadonlyDeep<T> = {
readonly [K in keyof T]: T[K] extends object
? ReadonlyDeep<T[K]>
: T[K]
}
```
**Conditional Types:**
```typescript
// Type-level logic
type IsArray<T> = T extends Array<any> ? true : false
type Flatten<T> = T extends Array<infer U> ? U : T
```
**Template Literal Types:**
```typescript
// String manipulation at type level
type EventName<T extends string> = `on${Capitalize<T>}`
type Route = `/api/${'users' | 'posts'}/${string}`
```
### 4. Type Narrowing
Use type guards and narrowing techniques:
**typeof guards:**
```typescript
if (typeof value === 'string') {
// value is string here
}
```
**instanceof guards:**
```typescript
if (error instanceof Error) {
// error is Error here
}
```
**Custom type guards:**
```typescript
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value
}
```
**Discriminated unions:**
```typescript
function handle(state: State) {
switch (state.status) {
case 'idle':
// state is { status: 'idle' }
break
case 'success':
// state is { status: 'success'; data: Data }
console.log(state.data)
break
}
}
```
### 5. Working with External Libraries
**Typing Third-Party Libraries:**
- Install type definitions: `npm install --save-dev @types/package-name`
- Create custom declarations in `.d.ts` files when types unavailable
- Use module augmentation to extend existing type definitions
**Declaration Files:**
```typescript
// globals.d.ts
declare global {
interface Window {
myCustomProperty: string
}
}
export {}
```
### 6. TypeScript Configuration
Configure `tsconfig.json` for strict type checking:
**Essential Strict Options:**
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
}
}
```
## Best Practices
### 1. Prefer Type Inference Over Explicit Types
Let TypeScript infer types when they're obvious from context.
### 2. Use Strict Mode
Enable strict type checking to catch more errors at compile time.
### 3. Avoid `any` Type
Use `unknown` for truly unknown types, then narrow with type guards.
### 4. Use Const Assertions
Use `as const` for immutable values and narrow literal types.
### 5. Leverage Discriminated Unions
Use for state machines and variant types for better type safety.
### 6. Create Reusable Generic Types
Extract common type patterns into reusable generics.
### 7. Use Branded Types for Nominal Typing
Create distinct types for values with same structure but different meaning.
### 8. Document Complex Types
Add JSDoc comments to explain non-obvious type decisions.
### 9. Use Type-Only Imports
Use `import type` for type-only imports to aid tree-shaking.
### 10. Handle Errors with Type Guards
Use type guards to safely work with error objects.
## Common Patterns
### React Component Props
```typescript
// Use interface for component props
interface ButtonProps {
variant?: 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
onClick?: () => void
children: React.ReactNode
}
export function Button({ variant = 'primary', size = 'md', onClick, children }: ButtonProps) {
// implementation
}
```
### API Response Types
```typescript
// Use discriminated unions for API responses
type ApiResponse<T> =
| { success: true; data: T }
| { success: false; error: string }
// Helper for safe API calls
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
try {
const response = await fetch(url)
const data = await response.json()
return { success: true, data }
} catch (error) {
return { success: false, error: String(error) }
}
}
```
### Store/State Types
```typescript
// Use interfaces for state objects
interface AppState {
user: User | null
isAuthenticated: boolean
theme: 'light' | 'dark'
}
// Use type for actions (discriminated union)
type AppAction =
| { type: 'LOGIN'; payload: User }
| { type: 'LOGOUT' }
| { type: 'SET_THEME'; payload: 'light' | 'dark' }
```
## References
For detailed information on specific topics, refer to:
- `references/type-system.md` - Deep dive into TypeScript's type system
- `references/utility-types.md` - Complete guide to built-in utility types
- `references/advanced-types.md` - Advanced type patterns and techniques
- `references/tsconfig-reference.md` - Comprehensive tsconfig.json reference
- `references/common-patterns.md` - Common TypeScript patterns and idioms
- `examples/` - Practical code examples
## Troubleshooting
### Common Type Errors
**Type 'X' is not assignable to type 'Y':**
- Check if types are compatible
- Use type assertions when you know better than the compiler
- Consider using union types or widening the target type
**Object is possibly 'null' or 'undefined':**
- Use optional chaining: `object?.property`
- Use nullish coalescing: `value ?? defaultValue`
- Add type guards or null checks
**Type 'any' implicitly has...**
- Enable strict mode and fix type definitions
- Add explicit type annotations
- Use `unknown` instead of `any` when appropriate
**Cannot find module or its type declarations:**
- Install type definitions: `@types/package-name`
- Create custom `.d.ts` declaration file
- Add to `types` array in tsconfig.json
## Integration with Project Stack
### React 19
Use TypeScript with React 19 features:
- Type component props with interfaces
- Use generic types for hooks
- Type context providers properly
- Use `React.FC` sparingly (prefer explicit typing)
### TanStack Ecosystem
Type TanStack libraries properly:
- TanStack Query: Type query keys and data
- TanStack Router: Use typed route definitions
- TanStack Form: Type form values and validation
- TanStack Store: Type state and actions
### Zod Integration
Combine Zod with TypeScript:
- Use `z.infer<typeof schema>` to extract types from schemas
- Let Zod handle runtime validation
- Use TypeScript for compile-time type checking
## Resources
The TypeScript documentation provides comprehensive information:
- Handbook: https://www.typescriptlang.org/docs/handbook/
- Type manipulation: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
- Utility types: https://www.typescriptlang.org/docs/handbook/utility-types.html
- TSConfig reference: https://www.typescriptlang.org/tsconfig

View File

@@ -0,0 +1,45 @@
# TypeScript Examples
This directory contains practical TypeScript examples demonstrating various patterns and features.
## Examples
1. **type-system-basics.ts** - Fundamental TypeScript types and features
2. **advanced-types.ts** - Generics, conditional types, and mapped types
3. **react-patterns.ts** - Type-safe React components and hooks
4. **api-patterns.ts** - API response handling with type safety
5. **validation.ts** - Runtime validation with Zod and TypeScript
## How to Use
Each example file is self-contained and demonstrates specific TypeScript concepts. They're based on real-world patterns used in the Plebeian Market application and follow best practices for:
- Type safety
- Error handling
- Code organization
- Reusability
- Maintainability
## Running Examples
These examples are TypeScript files that can be:
- Copied into your project
- Used as reference for patterns
- Modified for your specific needs
- Run with `ts-node` for testing
```bash
# Run an example
npx ts-node examples/type-system-basics.ts
```
## Learning Path
1. Start with `type-system-basics.ts` to understand fundamentals
2. Move to `advanced-types.ts` for complex type patterns
3. Explore `react-patterns.ts` for component typing
4. Study `api-patterns.ts` for type-safe API handling
5. Review `validation.ts` for runtime safety
Each example builds on previous concepts, so following this order is recommended for learners.

View File

@@ -0,0 +1,478 @@
/**
* Advanced TypeScript Types
*
* This file demonstrates advanced TypeScript features including:
* - Generics with constraints
* - Conditional types
* - Mapped types
* - Template literal types
* - Recursive types
* - Utility type implementations
*/
// ============================================================================
// Generics Basics
// ============================================================================
// Generic function
function identity<T>(value: T): T {
return value
}
const stringValue = identity('hello') // Type: string
const numberValue = identity(42) // Type: number
// Generic interface
interface Box<T> {
value: T
}
const stringBox: Box<string> = { value: 'hello' }
const numberBox: Box<number> = { value: 42 }
// Generic class
class Stack<T> {
private items: T[] = []
push(item: T): void {
this.items.push(item)
}
pop(): T | undefined {
return this.items.pop()
}
peek(): T | undefined {
return this.items[this.items.length - 1]
}
isEmpty(): boolean {
return this.items.length === 0
}
}
const numberStack = new Stack<number>()
numberStack.push(1)
numberStack.push(2)
numberStack.pop() // Type: number | undefined
// ============================================================================
// Generic Constraints
// ============================================================================
// Constrain to specific type
interface HasLength {
length: number
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length)
}
logLength('string') // OK
logLength([1, 2, 3]) // OK
logLength({ length: 10 }) // OK
// logLength(42) // Error: number doesn't have length
// Constrain to object keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
interface User {
id: string
name: string
age: number
}
const user: User = { id: '1', name: 'Alice', age: 30 }
const userName = getProperty(user, 'name') // Type: string
// const invalid = getProperty(user, 'invalid') // Error
// Multiple type parameters with constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 }
}
const merged = merge({ a: 1 }, { b: 2 }) // Type: { a: number } & { b: number }
// ============================================================================
// Conditional Types
// ============================================================================
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Nested conditional types
type TypeName<T> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: T extends undefined
? 'undefined'
: T extends Function
? 'function'
: 'object'
type T1 = TypeName<string> // "string"
type T2 = TypeName<number> // "number"
type T3 = TypeName<() => void> // "function"
// Distributive conditional types
type ToArray<T> = T extends any ? T[] : never
type StrArrOrNumArr = ToArray<string | number> // string[] | number[]
// infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
type Str = Flatten<string[]> // string
type Num = Flatten<number> // number
// Return type extraction
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function exampleFn(): string {
return 'hello'
}
type ExampleReturn = MyReturnType<typeof exampleFn> // string
// Parameters extraction
type MyParameters<T> = T extends (...args: infer P) => any ? P : never
function createUser(name: string, age: number): User {
return { id: '1', name, age }
}
type CreateUserParams = MyParameters<typeof createUser> // [string, number]
// ============================================================================
// Mapped Types
// ============================================================================
// Make all properties optional
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
interface Person {
name: string
age: number
email: string
}
type PartialPerson = MyPartial<Person>
// {
// name?: string
// age?: number
// email?: string
// }
// Make all properties required
type MyRequired<T> = {
[K in keyof T]-?: T[K]
}
// Make all properties readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K]
}
// Pick specific properties
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
type UserProfile = MyPick<User, 'id' | 'name'>
// { id: string; name: string }
// Omit specific properties
type MyOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
}
type UserWithoutAge = MyOmit<User, 'age'>
// { id: string; name: string }
// Transform property types
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
type NullablePerson = Nullable<Person>
// {
// name: string | null
// age: number | null
// email: string | null
// }
// ============================================================================
// Key Remapping
// ============================================================================
// Add prefix to keys
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type PersonGetters = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// getEmail: () => string
// }
// Filter keys by type
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface Model {
id: number
name: string
description: string
price: number
}
type StringFields = PickByType<Model, string>
// { name: string; description: string }
// Remove specific key
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, 'kind'>]: T[K]
}
// ============================================================================
// Template Literal Types
// ============================================================================
// Event name generation
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // "onClick"
type SubmitEvent = EventName<'submit'> // "onSubmit"
// Combining literals
type Color = 'red' | 'green' | 'blue'
type Shade = 'light' | 'dark'
type ColorShade = `${Shade}-${Color}`
// "light-red" | "light-green" | "light-blue" | "dark-red" | "dark-green" | "dark-blue"
// CSS properties
type CSSProperty = 'margin' | 'padding'
type Side = 'top' | 'right' | 'bottom' | 'left'
type CSSPropertyWithSide = `${CSSProperty}-${Side}`
// "margin-top" | "margin-right" | ... | "padding-left"
// Route generation
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/products' | '/orders'
type ApiRoute = `${HttpMethod} ${Endpoint}`
// "GET /users" | "POST /users" | ... | "DELETE /orders"
// ============================================================================
// Recursive Types
// ============================================================================
// JSON value type
type JSONValue = string | number | boolean | null | JSONObject | JSONArray
interface JSONObject {
[key: string]: JSONValue
}
interface JSONArray extends Array<JSONValue> {}
// Tree structure
interface TreeNode<T> {
value: T
children?: TreeNode<T>[]
}
const tree: TreeNode<number> = {
value: 1,
children: [
{ value: 2, children: [{ value: 4 }, { value: 5 }] },
{ value: 3, children: [{ value: 6 }] },
],
}
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
interface NestedConfig {
api: {
url: string
timeout: number
}
features: {
darkMode: boolean
}
}
type ImmutableConfig = DeepReadonly<NestedConfig>
// All properties at all levels are readonly
// Deep partial
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
// ============================================================================
// Advanced Utility Types
// ============================================================================
// Exclude types from union
type MyExclude<T, U> = T extends U ? never : T
type T4 = MyExclude<'a' | 'b' | 'c', 'a'> // "b" | "c"
// Extract types from union
type MyExtract<T, U> = T extends U ? T : never
type T5 = MyExtract<'a' | 'b' | 'c', 'a' | 'f'> // "a"
// NonNullable
type MyNonNullable<T> = T extends null | undefined ? never : T
type T6 = MyNonNullable<string | null | undefined> // string
// Record
type MyRecord<K extends keyof any, T> = {
[P in K]: T
}
type PageInfo = MyRecord<string, number>
// Awaited
type MyAwaited<T> = T extends Promise<infer U> ? MyAwaited<U> : T
type T7 = MyAwaited<Promise<string>> // string
type T8 = MyAwaited<Promise<Promise<number>>> // number
// ============================================================================
// Branded Types
// ============================================================================
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
type UserId = Brand<string, 'UserId'>
type ProductId = Brand<string, 'ProductId'>
function makeUSD(amount: number): USD {
return amount as USD
}
function makeUserId(id: string): UserId {
return id as UserId
}
const usd = makeUSD(100)
const userId = makeUserId('user-123')
// Type-safe operations
function addMoney(a: USD, b: USD): USD {
return (a + b) as USD
}
// Prevents mixing different branded types
// const total = addMoney(usd, eur) // Error
// ============================================================================
// Union to Intersection
// ============================================================================
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never
type Union = { a: string } | { b: number }
type Intersection = UnionToIntersection<Union>
// { a: string } & { b: number }
// ============================================================================
// Advanced Generic Patterns
// ============================================================================
// Constraining multiple related types
function merge<
T extends Record<string, any>,
U extends Record<string, any>,
K extends keyof T & keyof U,
>(obj1: T, obj2: U, conflictKeys: K[]): T & U {
const result = { ...obj1, ...obj2 }
conflictKeys.forEach((key) => {
// Handle conflicts
})
return result as T & U
}
// Builder pattern with fluent API
class QueryBuilder<T, Selected extends keyof T = never> {
private selectFields: Set<keyof T> = new Set()
select<K extends keyof T>(
...fields: K[]
): QueryBuilder<T, Selected | K> {
fields.forEach((field) => this.selectFields.add(field))
return this as any
}
execute(): Pick<T, Selected> {
// Execute query
return {} as Pick<T, Selected>
}
}
// Usage
interface Product {
id: string
name: string
price: number
description: string
}
const result = new QueryBuilder<Product>()
.select('id', 'name')
.select('price')
.execute()
// Type: { id: string; name: string; price: number }
// ============================================================================
// Exports
// ============================================================================
export type {
Box,
HasLength,
IsString,
Flatten,
MyPartial,
MyRequired,
MyReadonly,
Nullable,
DeepReadonly,
DeepPartial,
Brand,
USD,
EUR,
UserId,
ProductId,
JSONValue,
TreeNode,
}
export { Stack, identity, getProperty, merge, makeUSD, makeUserId }

View File

@@ -0,0 +1,555 @@
/**
* TypeScript React Patterns
*
* This file demonstrates type-safe React patterns including:
* - Component props typing
* - Hooks with TypeScript
* - Context with type safety
* - Generic components
* - Event handlers
* - Ref types
*/
import { createContext, useContext, useEffect, useReducer, useRef, useState } from 'react'
import type { ReactNode, InputHTMLAttributes, FormEvent, ChangeEvent } from 'react'
// ============================================================================
// Component Props Patterns
// ============================================================================
// Basic component with props
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
onClick?: () => void
children: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button
className={`btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}
// Props extending HTML attributes
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helperText?: string
}
export function Input({ label, error, helperText, ...inputProps }: InputProps) {
return (
<div className="input-wrapper">
{label && <label>{label}</label>}
<input className={error ? 'input-error' : ''} {...inputProps} />
{error && <span className="error">{error}</span>}
{helperText && <span className="helper">{helperText}</span>}
</div>
)
}
// Generic component
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => ReactNode
keyExtractor: (item: T, index: number) => string
emptyMessage?: string
}
export function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = 'No items',
}: ListProps<T>) {
if (items.length === 0) {
return <div>{emptyMessage}</div>
}
return (
<ul>
{items.map((item, index) => (
<li key={keyExtractor(item, index)}>{renderItem(item, index)}</li>
))}
</ul>
)
}
// Component with children render prop
interface ContainerProps {
isLoading: boolean
error: Error | null
children: (props: { retry: () => void }) => ReactNode
}
export function Container({ isLoading, error, children }: ContainerProps) {
const retry = () => {
// Retry logic
}
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return <>{children({ retry })}</>
}
// ============================================================================
// Hooks Patterns
// ============================================================================
// useState with explicit type
function useCounter(initialValue: number = 0) {
const [count, setCount] = useState<number>(initialValue)
const increment = () => setCount((c) => c + 1)
const decrement = () => setCount((c) => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
// useState with union type
type LoadingState = 'idle' | 'loading' | 'success' | 'error'
function useLoadingState() {
const [state, setState] = useState<LoadingState>('idle')
const startLoading = () => setState('loading')
const setSuccess = () => setState('success')
const setError = () => setState('error')
const reset = () => setState('idle')
return { state, startLoading, setSuccess, setError, reset }
}
// Custom hook with options
interface UseFetchOptions<T> {
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
data: T | undefined
loading: boolean
error: Error | null
refetch: () => Promise<void>
}
function useFetch<T>(url: string, options?: UseFetchOptions<T>): UseFetchReturn<T> {
const [data, setData] = useState<T | undefined>(options?.initialData)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const json = await response.json()
setData(json)
options?.onSuccess?.(json)
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
options?.onError?.(error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [url])
return { data, loading, error, refetch: fetchData }
}
// useReducer with discriminated unions
interface User {
id: string
name: string
email: string
}
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; error: Error }
| { type: 'RESET' }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload }
case 'FETCH_ERROR':
return { status: 'error', error: action.error }
case 'RESET':
return { status: 'idle' }
}
}
function useFetchWithReducer<T>(url: string) {
const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })
useEffect(() => {
let isCancelled = false
const fetchData = async () => {
dispatch({ type: 'FETCH_START' })
try {
const response = await fetch(url)
const data = await response.json()
if (!isCancelled) {
dispatch({ type: 'FETCH_SUCCESS', payload: data })
}
} catch (error) {
if (!isCancelled) {
dispatch({
type: 'FETCH_ERROR',
error: error instanceof Error ? error : new Error(String(error)),
})
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [url])
return state
}
// ============================================================================
// Context Patterns
// ============================================================================
// Type-safe context
interface AuthContextType {
user: User | null
isAuthenticated: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: string, password: string) => {
// Login logic
const userData = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}).then((r) => r.json())
setUser(userData)
}
const logout = () => {
setUser(null)
}
const value: AuthContextType = {
user,
isAuthenticated: user !== null,
login,
logout,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// Custom hook with error handling
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
// ============================================================================
// Event Handler Patterns
// ============================================================================
interface FormData {
name: string
email: string
message: string
}
function ContactForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: '',
})
// Type-safe change handler
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData((prev) => ({
...prev,
[name]: value,
}))
}
// Type-safe submit handler
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
console.log('Submitting:', formData)
}
// Specific field handler
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
/>
<input
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Message"
/>
<button type="submit">Submit</button>
</form>
)
}
// ============================================================================
// Ref Patterns
// ============================================================================
function FocusInput() {
// useRef with DOM element
const inputRef = useRef<HTMLInputElement>(null)
const focusInput = () => {
inputRef.current?.focus()
}
return (
<div>
<input ref={inputRef} />
<button onClick={focusInput}>Focus Input</button>
</div>
)
}
function Timer() {
// useRef for mutable value
const countRef = useRef<number>(0)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const startTimer = () => {
intervalRef.current = setInterval(() => {
countRef.current += 1
console.log(countRef.current)
}, 1000)
}
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
return (
<div>
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
)
}
// ============================================================================
// Generic Component Patterns
// ============================================================================
// Select component with generic options
interface SelectProps<T> {
options: T[]
value: T
onChange: (value: T) => void
getLabel: (option: T) => string
getValue: (option: T) => string
}
export function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
}: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selectedValue = e.target.value
const option = options.find((opt) => getValue(opt) === selectedValue)
if (option) {
onChange(option)
}
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
)
}
// Data table component
interface Column<T> {
key: keyof T
header: string
render?: (value: T[keyof T], row: T) => ReactNode
}
interface TableProps<T> {
data: T[]
columns: Column<T>[]
keyExtractor: (row: T) => string
}
export function Table<T>({ data, columns, keyExtractor }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map((col) => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={keyExtractor(row)}>
{columns.map((col) => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
// ============================================================================
// Higher-Order Component Pattern
// ============================================================================
interface WithLoadingProps {
isLoading: boolean
}
function withLoading<P extends object>(
Component: React.ComponentType<P>,
): React.FC<P & WithLoadingProps> {
return ({ isLoading, ...props }: WithLoadingProps & P) => {
if (isLoading) {
return <div>Loading...</div>
}
return <Component {...(props as P)} />
}
}
// Usage
interface UserListProps {
users: User[]
}
const UserList: React.FC<UserListProps> = ({ users }) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
const UserListWithLoading = withLoading(UserList)
// ============================================================================
// Exports
// ============================================================================
export {
useCounter,
useLoadingState,
useFetch,
useFetchWithReducer,
ContactForm,
FocusInput,
Timer,
}
export type {
ButtonProps,
InputProps,
ListProps,
UseFetchOptions,
UseFetchReturn,
FetchState,
FetchAction,
AuthContextType,
SelectProps,
Column,
TableProps,
}

View File

@@ -0,0 +1,361 @@
/**
* TypeScript Type System Basics
*
* This file demonstrates fundamental TypeScript concepts including:
* - Primitive types
* - Object types (interfaces, type aliases)
* - Union and intersection types
* - Type inference and narrowing
* - Function types
*/
// ============================================================================
// Primitive Types
// ============================================================================
const message: string = 'Hello, TypeScript!'
const count: number = 42
const isActive: boolean = true
const nothing: null = null
const notDefined: undefined = undefined
// ============================================================================
// Object Types
// ============================================================================
// Interface definition
interface User {
id: string
name: string
email: string
age?: number // Optional property
readonly createdAt: Date // Readonly property
}
// Type alias definition
type Product = {
id: string
name: string
price: number
category: string
}
// Creating objects
const user: User = {
id: '1',
name: 'Alice',
email: 'alice@example.com',
createdAt: new Date(),
}
const product: Product = {
id: 'p1',
name: 'Laptop',
price: 999,
category: 'electronics',
}
// ============================================================================
// Union Types
// ============================================================================
type Status = 'idle' | 'loading' | 'success' | 'error'
type ID = string | number
function formatId(id: ID): string {
if (typeof id === 'string') {
return id.toUpperCase()
}
return id.toString()
}
// Discriminated unions
type ApiResponse =
| { success: true; data: User }
| { success: false; error: string }
function handleResponse(response: ApiResponse) {
if (response.success) {
// TypeScript knows response.data exists here
console.log(response.data.name)
} else {
// TypeScript knows response.error exists here
console.error(response.error)
}
}
// ============================================================================
// Intersection Types
// ============================================================================
type Timestamped = {
createdAt: Date
updatedAt: Date
}
type TimestampedUser = User & Timestamped
const timestampedUser: TimestampedUser = {
id: '1',
name: 'Bob',
email: 'bob@example.com',
createdAt: new Date(),
updatedAt: new Date(),
}
// ============================================================================
// Array Types
// ============================================================================
const numbers: number[] = [1, 2, 3, 4, 5]
const strings: Array<string> = ['a', 'b', 'c']
const users: User[] = [user, timestampedUser]
// Readonly arrays
const immutableNumbers: readonly number[] = [1, 2, 3]
// immutableNumbers.push(4) // Error: push does not exist on readonly array
// ============================================================================
// Tuple Types
// ============================================================================
type Point = [number, number]
type NamedPoint = [x: number, y: number, z?: number]
const point: Point = [10, 20]
const namedPoint: NamedPoint = [10, 20, 30]
// ============================================================================
// Function Types
// ============================================================================
// Function declaration
function add(a: number, b: number): number {
return a + b
}
// Arrow function
const subtract = (a: number, b: number): number => a - b
// Function type alias
type MathOperation = (a: number, b: number) => number
const multiply: MathOperation = (a, b) => a * b
// Optional parameters
function greet(name: string, greeting?: string): string {
return `${greeting ?? 'Hello'}, ${name}!`
}
// Default parameters
function createUser(name: string, role: string = 'user'): User {
return {
id: Math.random().toString(),
name,
email: `${name.toLowerCase()}@example.com`,
createdAt: new Date(),
}
}
// Rest parameters
function sum(...numbers: number[]): number {
return numbers.reduce((acc, n) => acc + n, 0)
}
// ============================================================================
// Type Inference
// ============================================================================
// Type is inferred as string
let inferredString = 'hello'
// Type is inferred as number
let inferredNumber = 42
// Type is inferred as { name: string; age: number }
let inferredObject = {
name: 'Alice',
age: 30,
}
// Return type is inferred as number
function inferredReturn(a: number, b: number) {
return a + b
}
// ============================================================================
// Type Narrowing
// ============================================================================
// typeof guard
function processValue(value: string | number) {
if (typeof value === 'string') {
// value is string here
return value.toUpperCase()
}
// value is number here
return value.toFixed(2)
}
// Truthiness narrowing
function printName(name: string | null | undefined) {
if (name) {
// name is string here
console.log(name.toUpperCase())
}
}
// Equality narrowing
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y are both string here
console.log(x.toUpperCase(), y.toLowerCase())
}
}
// in operator narrowing
type Fish = { swim: () => void }
type Bird = { fly: () => void }
function move(animal: Fish | Bird) {
if ('swim' in animal) {
// animal is Fish here
animal.swim()
} else {
// animal is Bird here
animal.fly()
}
}
// instanceof narrowing
function processError(error: Error | string) {
if (error instanceof Error) {
// error is Error here
console.error(error.message)
} else {
// error is string here
console.error(error)
}
}
// ============================================================================
// Type Predicates (Custom Type Guards)
// ============================================================================
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
)
}
function processData(data: unknown) {
if (isUser(data)) {
// data is User here
console.log(data.name)
}
}
// ============================================================================
// Const Assertions
// ============================================================================
// Without const assertion
const mutableConfig = {
host: 'localhost',
port: 8080,
}
// mutableConfig.host = 'example.com' // OK
// With const assertion
const immutableConfig = {
host: 'localhost',
port: 8080,
} as const
// immutableConfig.host = 'example.com' // Error: cannot assign to readonly property
// Array with const assertion
const directions = ['north', 'south', 'east', 'west'] as const
// Type: readonly ["north", "south", "east", "west"]
// ============================================================================
// Literal Types
// ============================================================================
type Direction = 'north' | 'south' | 'east' | 'west'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6
function move(direction: Direction, steps: number) {
console.log(`Moving ${direction} by ${steps} steps`)
}
move('north', 10) // OK
// move('up', 10) // Error: "up" is not assignable to Direction
// ============================================================================
// Index Signatures
// ============================================================================
interface StringMap {
[key: string]: string
}
const translations: StringMap = {
hello: 'Hola',
goodbye: 'Adiós',
thanks: 'Gracias',
}
// ============================================================================
// Utility Functions
// ============================================================================
// Type-safe object keys
function getObjectKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>
}
// Type-safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const userName = getProperty(user, 'name') // Type: string
const userAge = getProperty(user, 'age') // Type: number | undefined
// ============================================================================
// Named Return Values (Go-style)
// ============================================================================
function parseJSON(json: string): { data: unknown | null; err: Error | null } {
let data: unknown | null = null
let err: Error | null = null
try {
data = JSON.parse(json)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, err }
}
// Usage
const { data, err } = parseJSON('{"name": "Alice"}')
if (err) {
console.error('Failed to parse JSON:', err.message)
} else {
console.log('Parsed data:', data)
}
// ============================================================================
// Exports
// ============================================================================
export type { User, Product, Status, ID, ApiResponse, TimestampedUser }
export { formatId, handleResponse, processValue, isUser, getProperty, parseJSON }

View File

@@ -0,0 +1,395 @@
# TypeScript Quick Reference
Quick lookup guide for common TypeScript patterns and syntax.
## Basic Types
```typescript
// Primitives
string, number, boolean, null, undefined, symbol, bigint
// Special types
any // Avoid - disables type checking
unknown // Type-safe alternative to any
void // No return value
never // Never returns
// Arrays
number[]
Array<string>
readonly number[]
// Tuples
[string, number]
[x: number, y: number]
// Objects
{ name: string; age: number }
Record<string, number>
```
## Type Declarations
```typescript
// Interface
interface User {
id: string
name: string
age?: number // Optional
readonly createdAt: Date // Readonly
}
// Type alias
type Status = 'idle' | 'loading' | 'success' | 'error'
type ID = string | number
type Point = { x: number; y: number }
// Function type
type Callback = (data: string) => void
type MathOp = (a: number, b: number) => number
```
## Union & Intersection
```typescript
// Union (OR)
string | number
type Result = Success | Error
// Intersection (AND)
A & B
type Combined = User & Timestamped
// Discriminated union
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: Data }
| { status: 'error'; error: Error }
```
## Generics
```typescript
// Generic function
function identity<T>(value: T): T
// Generic interface
interface Box<T> { value: T }
// Generic with constraint
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K]
// Multiple type parameters
function merge<T, U>(a: T, b: U): T & U
// Default type parameter
interface Response<T = unknown> { data: T }
```
## Utility Types
```typescript
Partial<T> // Make all optional
Required<T> // Make all required
Readonly<T> // Make all readonly
Pick<T, K> // Select properties
Omit<T, K> // Exclude properties
Record<K, T> // Object with specific keys
Exclude<T, U> // Remove from union
Extract<T, U> // Extract from union
NonNullable<T> // Remove null/undefined
ReturnType<T> // Get function return type
Parameters<T> // Get function parameters
Awaited<T> // Unwrap Promise
```
## Type Guards
```typescript
// typeof
if (typeof value === 'string') { }
// instanceof
if (error instanceof Error) { }
// in operator
if ('property' in object) { }
// Custom type guard
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value
}
// Assertion function
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') throw new Error()
}
```
## Advanced Types
```typescript
// Conditional types
type IsString<T> = T extends string ? true : false
// Mapped types
type Nullable<T> = { [K in keyof T]: T[K] | null }
// Template literal types
type EventName<T extends string> = `on${Capitalize<T>}`
// Key remapping
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
// infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
```
## Functions
```typescript
// Function declaration
function add(a: number, b: number): number { return a + b }
// Arrow function
const subtract = (a: number, b: number): number => a - b
// Optional parameters
function greet(name: string, greeting?: string): string { }
// Default parameters
function create(name: string, role = 'user'): User { }
// Rest parameters
function sum(...numbers: number[]): number { }
// Overloads
function format(value: string): string
function format(value: number): string
function format(value: string | number): string { }
```
## Classes
```typescript
class User {
// Properties
private id: string
public name: string
protected age: number
readonly createdAt: Date
// Constructor
constructor(name: string) {
this.name = name
this.createdAt = new Date()
}
// Methods
greet(): string {
return `Hello, ${this.name}`
}
// Static
static create(name: string): User {
return new User(name)
}
// Getters/Setters
get displayName(): string {
return this.name.toUpperCase()
}
}
// Inheritance
class Admin extends User {
constructor(name: string, public permissions: string[]) {
super(name)
}
}
// Abstract class
abstract class Animal {
abstract makeSound(): void
}
```
## React Patterns
```typescript
// Component props
interface ButtonProps {
variant?: 'primary' | 'secondary'
onClick?: () => void
children: React.ReactNode
}
export function Button({ variant = 'primary', onClick, children }: ButtonProps) { }
// Generic component
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
}
export function List<T>({ items, renderItem }: ListProps<T>) { }
// Hooks
const [state, setState] = useState<string>('')
const [data, setData] = useState<User | null>(null)
// Context
interface AuthContextType {
user: User | null
login: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
```
## Common Patterns
### Result Type
```typescript
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
```
### Option Type
```typescript
type Option<T> = Some<T> | None
interface Some<T> { _tag: 'Some'; value: T }
interface None { _tag: 'None' }
```
### Branded Types
```typescript
type Brand<K, T> = K & { __brand: T }
type UserId = Brand<string, 'UserId'>
```
### Named Returns (Go-style)
```typescript
function parseJSON(json: string): { data: unknown | null; err: Error | null } {
let data: unknown | null = null
let err: Error | null = null
try {
data = JSON.parse(json)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, err }
}
```
## Type Assertions
```typescript
// as syntax (preferred)
const value = input as string
// Angle bracket syntax (not in JSX)
const value = <string>input
// as const
const config = { host: 'localhost' } as const
// Non-null assertion (use sparingly)
const element = document.getElementById('app')!
```
## Type Narrowing
```typescript
// Control flow
if (value !== null) {
// value is non-null here
}
// Switch with discriminated unions
switch (state.status) {
case 'success':
console.log(state.data) // TypeScript knows data exists
break
case 'error':
console.log(state.error) // TypeScript knows error exists
break
}
// Optional chaining
user?.profile?.name
// Nullish coalescing
const name = user?.name ?? 'Anonymous'
```
## Module Syntax
```typescript
// Named exports
export function helper() { }
export const CONFIG = { }
// Default export
export default class App { }
// Type-only imports/exports
import type { User } from './types'
export type { User }
// Namespace imports
import * as utils from './utils'
```
## TSConfig Essentials
```json
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}
```
## Common Errors & Fixes
| Error | Fix |
|-------|-----|
| Type 'X' is not assignable to type 'Y' | Check type compatibility, use type assertion if needed |
| Object is possibly 'null' | Use optional chaining `?.` or null check |
| Cannot find module | Install `@types/package-name` |
| Implicit any | Add type annotation or enable strict mode |
| Property does not exist | Check object shape, use type guard |
## Best Practices
1. Enable `strict` mode in tsconfig.json
2. Avoid `any`, use `unknown` instead
3. Use discriminated unions for state
4. Leverage type inference
5. Use `const` assertions for immutable data
6. Create custom type guards for runtime safety
7. Use utility types instead of recreating
8. Document complex types with JSDoc
9. Prefer interfaces for objects, types for unions
10. Use branded types for domain-specific primitives

View File

@@ -0,0 +1,756 @@
# TypeScript Common Patterns Reference
This document contains commonly used TypeScript patterns and idioms from real-world applications.
## React Patterns
### Component Props
```typescript
// Basic props with children
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'tertiary'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
onClick?: () => void
children: React.ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children,
}: ButtonProps) {
return (
<button className={`btn-${variant} btn-${size}`} disabled={disabled} onClick={onClick}>
{children}
</button>
)
}
// Props extending HTML attributes
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export function Input({ label, error, ...inputProps }: InputProps) {
return (
<div>
{label && <label>{label}</label>}
<input {...inputProps} />
{error && <span>{error}</span>}
</div>
)
}
// Generic component props
interface ListProps<T> {
items: T[]
renderItem: (item: T) => React.ReactNode
keyExtractor: (item: T) => string
}
export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map((item) => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
)
}
```
### Hooks
```typescript
// Custom hook with return type
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
return initialValue
}
})
const setValue = (value: T) => {
setStoredValue(value)
window.localStorage.setItem(key, JSON.stringify(value))
}
return [storedValue, setValue]
}
// Hook with options object
interface UseFetchOptions<T> {
initialData?: T
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
function useFetch<T>(url: string, options?: UseFetchOptions<T>) {
const [data, setData] = useState<T | undefined>(options?.initialData)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let isCancelled = false
const fetchData = async () => {
setLoading(true)
try {
const response = await fetch(url)
const json = await response.json()
if (!isCancelled) {
setData(json)
options?.onSuccess?.(json)
}
} catch (err) {
if (!isCancelled) {
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
options?.onError?.(error)
}
} finally {
if (!isCancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
isCancelled = true
}
}, [url])
return { data, loading, error }
}
```
### Context
```typescript
// Type-safe context
interface AuthContextType {
user: User | null
login: (email: string, password: string) => Promise<void>
logout: () => void
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: string, password: string) => {
// Login logic
const user = await api.login(email, password)
setUser(user)
}
const logout = () => {
setUser(null)
}
const value: AuthContextType = {
user,
login,
logout,
isAuthenticated: user !== null,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
// Custom hook with proper error handling
export function useAuth(): AuthContextType {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
```
## API Response Patterns
### Result Type Pattern
```typescript
// Discriminated union for API responses
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E }
// Helper functions
function success<T>(data: T): Result<T> {
return { success: true, data }
}
function failure<E = Error>(error: E): Result<never, E> {
return { success: false, error }
}
// Usage
async function fetchUser(id: string): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
return failure(new Error(`HTTP ${response.status}`))
}
const data = await response.json()
return success(data)
} catch (error) {
return failure(error instanceof Error ? error : new Error(String(error)))
}
}
// Consuming the result
const result = await fetchUser('123')
if (result.success) {
console.log(result.data.name) // Type-safe access
} else {
console.error(result.error.message) // Type-safe error handling
}
```
### Option Type Pattern
```typescript
// Option/Maybe type for nullable values
type Option<T> = Some<T> | None
interface Some<T> {
readonly _tag: 'Some'
readonly value: T
}
interface None {
readonly _tag: 'None'
}
// Constructors
function some<T>(value: T): Option<T> {
return { _tag: 'Some', value }
}
function none(): Option<never> {
return { _tag: 'None' }
}
// Helper functions
function isSome<T>(option: Option<T>): option is Some<T> {
return option._tag === 'Some'
}
function isNone<T>(option: Option<T>): option is None {
return option._tag === 'None'
}
function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
return isSome(option) ? some(fn(option.value)) : none()
}
function getOrElse<T>(option: Option<T>, defaultValue: T): T {
return isSome(option) ? option.value : defaultValue
}
// Usage
function findUser(id: string): Option<User> {
const user = users.find((u) => u.id === id)
return user ? some(user) : none()
}
const user = findUser('123')
const userName = getOrElse(map(user, (u) => u.name), 'Unknown')
```
## State Management Patterns
### Discriminated Union for State
```typescript
// State machine using discriminated unions
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
// Reducer pattern
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; error: Error }
| { type: 'RESET' }
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload }
case 'FETCH_ERROR':
return { status: 'error', error: action.error }
case 'RESET':
return { status: 'idle' }
}
}
// Usage in component
function UserProfile({ userId }: { userId: string }) {
const [state, dispatch] = useReducer(fetchReducer<User>, { status: 'idle' })
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetchUser(userId)
.then((user) => dispatch({ type: 'FETCH_SUCCESS', payload: user }))
.catch((error) => dispatch({ type: 'FETCH_ERROR', error }))
}, [userId])
switch (state.status) {
case 'idle':
return <div>Ready to load</div>
case 'loading':
return <div>Loading...</div>
case 'success':
return <div>{state.data.name}</div>
case 'error':
return <div>Error: {state.error.message}</div>
}
}
```
### Store Pattern
```typescript
// Type-safe store implementation
interface Store<T> {
getState: () => T
setState: (partial: Partial<T>) => void
subscribe: (listener: (state: T) => void) => () => void
}
function createStore<T>(initialState: T): Store<T> {
let state = initialState
const listeners = new Set<(state: T) => void>()
return {
getState: () => state,
setState: (partial) => {
state = { ...state, ...partial }
listeners.forEach((listener) => listener(state))
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
// Usage
interface AppState {
user: User | null
theme: 'light' | 'dark'
}
const store = createStore<AppState>({
user: null,
theme: 'light',
})
// React hook integration
function useStore<T, U>(store: Store<T>, selector: (state: T) => U): U {
const [value, setValue] = useState(() => selector(store.getState()))
useEffect(() => {
const unsubscribe = store.subscribe((state) => {
setValue(selector(state))
})
return unsubscribe
}, [store, selector])
return value
}
// Usage in component
function ThemeToggle() {
const theme = useStore(store, (state) => state.theme)
return (
<button
onClick={() => store.setState({ theme: theme === 'light' ? 'dark' : 'light' })}
>
Toggle Theme
</button>
)
}
```
## Form Patterns
### Form State Management
```typescript
// Generic form state
interface FormState<T> {
values: T
errors: Partial<Record<keyof T, string>>
touched: Partial<Record<keyof T, boolean>>
isSubmitting: boolean
}
// Form hook
function useForm<T extends Record<string, any>>(
initialValues: T,
validate: (values: T) => Partial<Record<keyof T, string>>,
) {
const [state, setState] = useState<FormState<T>>({
values: initialValues,
errors: {},
touched: {},
isSubmitting: false,
})
const handleChange = <K extends keyof T>(field: K, value: T[K]) => {
setState((prev) => ({
...prev,
values: { ...prev.values, [field]: value },
errors: { ...prev.errors, [field]: undefined },
}))
}
const handleBlur = <K extends keyof T>(field: K) => {
setState((prev) => ({
...prev,
touched: { ...prev.touched, [field]: true },
}))
}
const handleSubmit = async (onSubmit: (values: T) => Promise<void>) => {
const errors = validate(state.values)
if (Object.keys(errors).length > 0) {
setState((prev) => ({
...prev,
errors,
touched: Object.keys(state.values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{},
),
}))
return
}
setState((prev) => ({ ...prev, isSubmitting: true }))
try {
await onSubmit(state.values)
} finally {
setState((prev) => ({ ...prev, isSubmitting: false }))
}
}
return {
values: state.values,
errors: state.errors,
touched: state.touched,
isSubmitting: state.isSubmitting,
handleChange,
handleBlur,
handleSubmit,
}
}
// Usage
interface LoginFormValues {
email: string
password: string
}
function LoginForm() {
const form = useForm<LoginFormValues>(
{ email: '', password: '' },
(values) => {
const errors: Partial<Record<keyof LoginFormValues, string>> = {}
if (!values.email) {
errors.email = 'Email is required'
}
if (!values.password) {
errors.password = 'Password is required'
}
return errors
},
)
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit(async (values) => {
await login(values.email, values.password)
})
}}
>
<input
value={form.values.email}
onChange={(e) => form.handleChange('email', e.target.value)}
onBlur={() => form.handleBlur('email')}
/>
{form.touched.email && form.errors.email && <span>{form.errors.email}</span>}
<input
type="password"
value={form.values.password}
onChange={(e) => form.handleChange('password', e.target.value)}
onBlur={() => form.handleBlur('password')}
/>
{form.touched.password && form.errors.password && (
<span>{form.errors.password}</span>
)}
<button type="submit" disabled={form.isSubmitting}>
Login
</button>
</form>
)
}
```
## Validation Patterns
### Zod Integration
```typescript
import { z } from 'zod'
// Schema definition
const userSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(120),
role: z.enum(['admin', 'user', 'guest']),
})
// Extract type from schema
type User = z.infer<typeof userSchema>
// Validation function
function validateUser(data: unknown): Result<User> {
const result = userSchema.safeParse(data)
if (result.success) {
return { success: true, data: result.data }
}
return {
success: false,
error: new Error(result.error.errors.map((e) => e.message).join(', ')),
}
}
// API integration
async function createUser(data: unknown): Promise<Result<User>> {
const validation = validateUser(data)
if (!validation.success) {
return validation
}
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validation.data),
})
if (!response.ok) {
return failure(new Error(`HTTP ${response.status}`))
}
const user = await response.json()
return success(user)
} catch (error) {
return failure(error instanceof Error ? error : new Error(String(error)))
}
}
```
## Builder Pattern
```typescript
// Fluent builder pattern
class QueryBuilder<T> {
private filters: Array<(item: T) => boolean> = []
private sortFn?: (a: T, b: T) => number
private limitValue?: number
where(predicate: (item: T) => boolean): this {
this.filters.push(predicate)
return this
}
sortBy(compareFn: (a: T, b: T) => number): this {
this.sortFn = compareFn
return this
}
limit(count: number): this {
this.limitValue = count
return this
}
execute(data: T[]): T[] {
let result = data
// Apply filters
this.filters.forEach((filter) => {
result = result.filter(filter)
})
// Apply sorting
if (this.sortFn) {
result = result.sort(this.sortFn)
}
// Apply limit
if (this.limitValue !== undefined) {
result = result.slice(0, this.limitValue)
}
return result
}
}
// Usage
interface Product {
id: string
name: string
price: number
category: string
}
const products: Product[] = [
/* ... */
]
const query = new QueryBuilder<Product>()
.where((p) => p.category === 'electronics')
.where((p) => p.price < 1000)
.sortBy((a, b) => a.price - b.price)
.limit(10)
.execute(products)
```
## Factory Pattern
```typescript
// Abstract factory pattern with TypeScript
interface Button {
render: () => string
onClick: () => void
}
interface ButtonFactory {
createButton: (label: string, onClick: () => void) => Button
}
class PrimaryButton implements Button {
constructor(private label: string, private clickHandler: () => void) {}
render() {
return `<button class="primary">${this.label}</button>`
}
onClick() {
this.clickHandler()
}
}
class SecondaryButton implements Button {
constructor(private label: string, private clickHandler: () => void) {}
render() {
return `<button class="secondary">${this.label}</button>`
}
onClick() {
this.clickHandler()
}
}
class PrimaryButtonFactory implements ButtonFactory {
createButton(label: string, onClick: () => void): Button {
return new PrimaryButton(label, onClick)
}
}
class SecondaryButtonFactory implements ButtonFactory {
createButton(label: string, onClick: () => void): Button {
return new SecondaryButton(label, onClick)
}
}
// Usage
function createUI(factory: ButtonFactory) {
const button = factory.createButton('Click me', () => console.log('Clicked!'))
return button.render()
}
```
## Named Return Variables Pattern
```typescript
// Following Go-style named returns
function parseUser(data: unknown): { user: User | null; err: Error | null } {
let user: User | null = null
let err: Error | null = null
try {
user = userSchema.parse(data)
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { user, err }
}
// With explicit naming
function fetchData(url: string): {
data: unknown | null
status: number
err: Error | null
} {
let data: unknown | null = null
let status = 0
let err: Error | null = null
try {
const response = fetch(url)
// Process response
} catch (error) {
err = error instanceof Error ? error : new Error(String(error))
}
return { data, status, err }
}
```
## Best Practices
1. **Use discriminated unions** for type-safe state management
2. **Leverage generic types** for reusable components and hooks
3. **Extract types from Zod schemas** for runtime + compile-time safety
4. **Use Result/Option types** for explicit error handling
5. **Create builder patterns** for complex object construction
6. **Use factory patterns** for flexible object creation
7. **Type context properly** to catch usage errors at compile time
8. **Prefer const assertions** for immutable configurations
9. **Use branded types** for domain-specific primitives
10. **Document patterns** with JSDoc for team knowledge sharing

View File

@@ -0,0 +1,804 @@
# TypeScript Type System Reference
## Overview
TypeScript's type system is structural (duck-typed) rather than nominal. Two types are compatible if their structure matches, regardless of their names.
## Primitive Types
### Basic Primitives
```typescript
let str: string = 'hello'
let num: number = 42
let bool: boolean = true
let nul: null = null
let undef: undefined = undefined
let sym: symbol = Symbol('key')
let big: bigint = 100n
```
### Special Types
**any** - Disables type checking (avoid when possible):
```typescript
let anything: any = 'string'
anything = 42 // OK
anything.nonExistent() // OK at compile time, error at runtime
```
**unknown** - Type-safe alternative to any (requires type checking):
```typescript
let value: unknown = 'string'
// value.toUpperCase() // Error: must narrow type first
if (typeof value === 'string') {
value.toUpperCase() // OK after narrowing
}
```
**void** - Absence of a value (function return type):
```typescript
function log(message: string): void {
console.log(message)
}
```
**never** - Value that never occurs (exhaustive checks, infinite loops):
```typescript
function throwError(message: string): never {
throw new Error(message)
}
function exhaustiveCheck(value: never): never {
throw new Error(`Unhandled case: ${value}`)
}
```
## Object Types
### Interfaces
```typescript
// Basic interface
interface User {
id: string
name: string
email: string
}
// Optional properties
interface Product {
id: string
name: string
description?: string // Optional
}
// Readonly properties
interface Config {
readonly apiUrl: string
readonly timeout: number
}
// Index signatures
interface Dictionary {
[key: string]: string
}
// Method signatures
interface Calculator {
add(a: number, b: number): number
subtract(a: number, b: number): number
}
// Extending interfaces
interface Employee extends User {
role: string
department: string
}
// Multiple inheritance
interface Admin extends User, Employee {
permissions: string[]
}
```
### Type Aliases
```typescript
// Basic type alias
type ID = string | number
// Object type
type Point = {
x: number
y: number
}
// Union type
type Status = 'idle' | 'loading' | 'success' | 'error'
// Intersection type
type Timestamped = {
createdAt: Date
updatedAt: Date
}
type TimestampedUser = User & Timestamped
// Function type
type Callback = (data: string) => void
// Generic type alias
type Result<T> = { success: true; data: T } | { success: false; error: string }
```
### Interface vs Type Alias
**Use interface when:**
- Defining object shapes
- Need declaration merging
- Building public API types that others might extend
**Use type when:**
- Creating unions or intersections
- Working with mapped types
- Need conditional types
- Defining primitive aliases
## Array and Tuple Types
### Arrays
```typescript
// Array syntax
let numbers: number[] = [1, 2, 3]
let strings: Array<string> = ['a', 'b', 'c']
// Readonly arrays
let immutable: readonly number[] = [1, 2, 3]
let alsoImmutable: ReadonlyArray<string> = ['a', 'b']
```
### Tuples
```typescript
// Fixed-length, mixed-type arrays
type Point = [number, number]
type NamedPoint = [x: number, y: number]
// Optional elements
type OptionalTuple = [string, number?]
// Rest elements
type StringNumberBooleans = [string, number, ...boolean[]]
// Readonly tuples
type ReadonlyPair = readonly [string, number]
```
## Union and Intersection Types
### Union Types
```typescript
// Value can be one of several types
type StringOrNumber = string | number
function format(value: StringOrNumber): string {
if (typeof value === 'string') {
return value
}
return value.toString()
}
// Discriminated unions
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'rectangle'; width: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'square':
return shape.size ** 2
case 'rectangle':
return shape.width * shape.height
}
}
```
### Intersection Types
```typescript
// Combine multiple types
type Draggable = {
drag: () => void
}
type Resizable = {
resize: () => void
}
type UIWidget = Draggable & Resizable
const widget: UIWidget = {
drag: () => console.log('dragging'),
resize: () => console.log('resizing'),
}
```
## Literal Types
### String Literal Types
```typescript
type Direction = 'north' | 'south' | 'east' | 'west'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
function move(direction: Direction) {
// direction can only be one of the four values
}
```
### Number Literal Types
```typescript
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6
type PowerOfTwo = 1 | 2 | 4 | 8 | 16 | 32
```
### Boolean Literal Types
```typescript
type Yes = true
type No = false
```
### Template Literal Types
```typescript
// String manipulation at type level
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // "onClick"
// Combining literals
type Color = 'red' | 'blue' | 'green'
type Shade = 'light' | 'dark'
type ColorShade = `${Shade}-${Color}` // "light-red" | "light-blue" | ...
// Extract patterns
type EmailLocaleIDs = 'welcome_email' | 'email_heading'
type FooterLocaleIDs = 'footer_title' | 'footer_sendoff'
type AllLocaleIDs = `${EmailLocaleIDs | FooterLocaleIDs}_id`
```
## Type Inference
### Automatic Inference
```typescript
// Type inferred as string
let message = 'hello'
// Type inferred as number[]
let numbers = [1, 2, 3]
// Type inferred as { name: string; age: number }
let person = {
name: 'Alice',
age: 30,
}
// Return type inferred
function add(a: number, b: number) {
return a + b // Returns number
}
```
### Const Assertions
```typescript
// Without const assertion
let colors1 = ['red', 'green', 'blue'] // Type: string[]
// With const assertion
let colors2 = ['red', 'green', 'blue'] as const // Type: readonly ["red", "green", "blue"]
// Object with const assertion
const config = {
host: 'localhost',
port: 8080,
} as const // All properties become readonly with literal types
```
### Type Inference in Generics
```typescript
// Generic type inference from usage
function identity<T>(value: T): T {
return value
}
let str = identity('hello') // T inferred as string
let num = identity(42) // T inferred as number
// Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second]
}
let p = pair('hello', 42) // [string, number]
```
## Type Narrowing
### typeof Guards
```typescript
function padLeft(value: string, padding: string | number) {
if (typeof padding === 'number') {
// padding is number here
return ' '.repeat(padding) + value
}
// padding is string here
return padding + value
}
```
### instanceof Guards
```typescript
class Dog {
bark() {
console.log('Woof!')
}
}
class Cat {
meow() {
console.log('Meow!')
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark()
} else {
animal.meow()
}
}
```
### in Operator
```typescript
type Fish = { swim: () => void }
type Bird = { fly: () => void }
function move(animal: Fish | Bird) {
if ('swim' in animal) {
animal.swim()
} else {
animal.fly()
}
}
```
### Equality Narrowing
```typescript
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y are both string here
x.toUpperCase()
y.toLowerCase()
}
}
```
### Control Flow Analysis
```typescript
function example(value: string | null) {
if (value === null) {
return
}
// value is string here (null eliminated)
console.log(value.toUpperCase())
}
```
### Type Predicates (Custom Type Guards)
```typescript
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function example(value: unknown) {
if (isString(value)) {
// value is string here
console.log(value.toUpperCase())
}
}
// More complex example
interface User {
id: string
name: string
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof (value as User).id === 'string' &&
typeof (value as User).name === 'string'
)
}
```
### Assertion Functions
```typescript
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message || 'Assertion failed')
}
}
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Value must be a string')
}
}
function example(value: unknown) {
assertIsString(value)
// value is string here
console.log(value.toUpperCase())
}
```
## Generic Types
### Basic Generics
```typescript
// Generic function
function first<T>(items: T[]): T | undefined {
return items[0]
}
// Generic interface
interface Box<T> {
value: T
}
// Generic type alias
type Result<T> = { success: true; data: T } | { success: false; error: string }
// Generic class
class Stack<T> {
private items: T[] = []
push(item: T) {
this.items.push(item)
}
pop(): T | undefined {
return this.items.pop()
}
}
```
### Generic Constraints
```typescript
// Constrain to specific type
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
// Constrain to interface
interface HasLength {
length: number
}
function logLength<T extends HasLength>(item: T): void {
console.log(item.length)
}
logLength('string') // OK
logLength([1, 2, 3]) // OK
logLength({ length: 10 }) // OK
// logLength(42) // Error: number doesn't have length
```
### Default Generic Parameters
```typescript
interface Response<T = unknown> {
data: T
status: number
}
// Uses default
let response1: Response = { data: 'anything', status: 200 }
// Explicitly typed
let response2: Response<User> = { data: user, status: 200 }
```
### Generic Utility Functions
```typescript
// Pick specific properties
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>
keys.forEach((key) => {
result[key] = obj[key]
})
return result
}
// Map array
function map<T, U>(items: T[], fn: (item: T) => U): U[] {
return items.map(fn)
}
```
## Advanced Type Features
### Conditional Types
```typescript
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// Distributive conditional types
type ToArray<T> = T extends any ? T[] : never
type StrArrOrNumArr = ToArray<string | number> // string[] | number[]
// Infer keyword
type Flatten<T> = T extends Array<infer U> ? U : T
type Str = Flatten<string[]> // string
type Num = Flatten<number> // number
// ReturnType implementation
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
```
### Mapped Types
```typescript
// Make all properties optional
type Partial<T> = {
[K in keyof T]?: T[K]
}
// Make all properties required
type Required<T> = {
[K in keyof T]-?: T[K]
}
// Make all properties readonly
type Readonly<T> = {
readonly [K in keyof T]: T[K]
}
// Transform keys
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
interface Person {
name: string
age: number
}
type PersonGetters = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// }
```
### Key Remapping
```typescript
// Filter keys
type RemoveKindField<T> = {
[K in keyof T as Exclude<K, 'kind'>]: T[K]
}
// Conditional key inclusion
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface Model {
id: number
name: string
age: number
email: string
}
type StringFields = PickByType<Model, string> // { name: string, email: string }
```
### Recursive Types
```typescript
// JSON value type
type JSONValue = string | number | boolean | null | JSONObject | JSONArray
interface JSONObject {
[key: string]: JSONValue
}
interface JSONArray extends Array<JSONValue> {}
// Tree structure
interface TreeNode<T> {
value: T
children?: TreeNode<T>[]
}
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
```
## Type Compatibility
### Structural Typing
```typescript
interface Point {
x: number
y: number
}
interface Named {
name: string
}
// Compatible if structure matches
let point: Point = { x: 0, y: 0 }
let namedPoint = { x: 0, y: 0, name: 'origin' }
point = namedPoint // OK: namedPoint has x and y
```
### Variance
**Covariance** (return types):
```typescript
interface Animal {
name: string
}
interface Dog extends Animal {
breed: string
}
let getDog: () => Dog
let getAnimal: () => Animal
getAnimal = getDog // OK: Dog is assignable to Animal
```
**Contravariance** (parameter types):
```typescript
let handleAnimal: (animal: Animal) => void
let handleDog: (dog: Dog) => void
handleDog = handleAnimal // OK: can pass Dog to function expecting Animal
```
## Index Types
### Index Signatures
```typescript
// String index
interface StringMap {
[key: string]: string
}
// Number index
interface NumberArray {
[index: number]: number
}
// Combine with named properties
interface MixedInterface {
length: number
[index: number]: string
}
```
### keyof Operator
```typescript
interface Person {
name: string
age: number
}
type PersonKeys = keyof Person // "name" | "age"
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
```
### Indexed Access Types
```typescript
interface Person {
name: string
age: number
address: {
street: string
city: string
}
}
type Name = Person['name'] // string
type Age = Person['age'] // number
type Address = Person['address'] // { street: string; city: string }
type AddressCity = Person['address']['city'] // string
// Access multiple keys
type NameOrAge = Person['name' | 'age'] // string | number
```
## Branded Types
```typescript
// Create nominal types from structural types
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>
function makeUSD(amount: number): USD {
return amount as USD
}
function makeEUR(amount: number): EUR {
return amount as EUR
}
let usd = makeUSD(100)
let eur = makeEUR(100)
// usd = eur // Error: different brands
```
## Best Practices
1. **Prefer type inference** - Let TypeScript infer types when obvious
2. **Use strict null checks** - Enable strictNullChecks for better safety
3. **Avoid `any`** - Use `unknown` and narrow with type guards
4. **Use discriminated unions** - Better than loose unions for state
5. **Leverage const assertions** - Get narrow literal types
6. **Use branded types** - When structural typing isn't enough
7. **Document complex types** - Add JSDoc comments
8. **Extract reusable types** - DRY principle applies to types too
9. **Use utility types** - Leverage built-in transformation types
10. **Test your types** - Use type assertions to verify type correctness

View File

@@ -0,0 +1,666 @@
# TypeScript Utility Types Reference
TypeScript provides several built-in utility types that help transform and manipulate types. These are implemented using advanced type features like mapped types and conditional types.
## Property Modifiers
### Partial\<T\>
Makes all properties in `T` optional.
```typescript
interface User {
id: string
name: string
email: string
age: number
}
type PartialUser = Partial<User>
// {
// id?: string
// name?: string
// email?: string
// age?: number
// }
// Useful for update operations
function updateUser(id: string, updates: Partial<User>) {
// Only update provided fields
}
updateUser('123', { name: 'Alice' }) // OK
updateUser('123', { name: 'Alice', age: 30 }) // OK
```
### Required\<T\>
Makes all properties in `T` required (removes optionality).
```typescript
interface Config {
host?: string
port?: number
timeout?: number
}
type RequiredConfig = Required<Config>
// {
// host: string
// port: number
// timeout: number
// }
function initServer(config: RequiredConfig) {
// All properties are guaranteed to exist
console.log(config.host, config.port, config.timeout)
}
```
### Readonly\<T\>
Makes all properties in `T` readonly.
```typescript
interface MutablePoint {
x: number
y: number
}
type ImmutablePoint = Readonly<MutablePoint>
// {
// readonly x: number
// readonly y: number
// }
const point: ImmutablePoint = { x: 0, y: 0 }
// point.x = 10 // Error: Cannot assign to 'x' because it is a read-only property
```
### Mutable\<T\> (Custom)
Removes readonly modifiers (not built-in, but useful pattern).
```typescript
type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}
interface ReadonlyPerson {
readonly name: string
readonly age: number
}
type MutablePerson = Mutable<ReadonlyPerson>
// {
// name: string
// age: number
// }
```
## Property Selection
### Pick\<T, K\>
Creates a type by picking specific properties from `T`.
```typescript
interface User {
id: string
name: string
email: string
password: string
createdAt: Date
}
type UserProfile = Pick<User, 'id' | 'name' | 'email'>
// {
// id: string
// name: string
// email: string
// }
// Useful for API responses
function getUserProfile(id: string): UserProfile {
// Return only safe properties
}
```
### Omit\<T, K\>
Creates a type by omitting specific properties from `T`.
```typescript
interface User {
id: string
name: string
email: string
password: string
}
type UserWithoutPassword = Omit<User, 'password'>
// {
// id: string
// name: string
// email: string
// }
// Useful for public user data
function publishUser(user: User): UserWithoutPassword {
const { password, ...publicData } = user
return publicData
}
```
## Union Type Utilities
### Exclude\<T, U\>
Excludes types from `T` that are assignable to `U`.
```typescript
type T1 = Exclude<'a' | 'b' | 'c', 'a'> // "b" | "c"
type T2 = Exclude<string | number | boolean, boolean> // string | number
type EventType = 'click' | 'scroll' | 'mousemove' | 'keypress'
type UIEvent = Exclude<EventType, 'scroll'> // "click" | "mousemove" | "keypress"
```
### Extract\<T, U\>
Extracts types from `T` that are assignable to `U`.
```typescript
type T1 = Extract<'a' | 'b' | 'c', 'a' | 'f'> // "a"
type T2 = Extract<string | number | boolean, boolean> // boolean
type Shape = 'circle' | 'square' | 'triangle' | 'rectangle'
type RoundedShape = Extract<Shape, 'circle'> // "circle"
```
### NonNullable\<T\>
Excludes `null` and `undefined` from `T`.
```typescript
type T1 = NonNullable<string | null | undefined> // string
type T2 = NonNullable<string | number | null> // string | number
function processValue(value: string | null | undefined) {
if (value !== null && value !== undefined) {
const nonNull: NonNullable<typeof value> = value
// nonNull is guaranteed to be string
}
}
```
## Object Construction
### Record\<K, T\>
Constructs an object type with keys of type `K` and values of type `T`.
```typescript
type PageInfo = Record<string, number>
// { [key: string]: number }
const pages: PageInfo = {
home: 1,
about: 2,
contact: 3,
}
// Useful for mapped objects
type UserRole = 'admin' | 'user' | 'guest'
type RolePermissions = Record<UserRole, string[]>
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read'],
}
// With specific keys
type ThemeColors = Record<'primary' | 'secondary' | 'accent', string>
const colors: ThemeColors = {
primary: '#007bff',
secondary: '#6c757d',
accent: '#28a745',
}
```
## Function Utilities
### Parameters\<T\>
Extracts the parameter types of a function type as a tuple.
```typescript
function createUser(name: string, age: number, email: string) {
// ...
}
type CreateUserParams = Parameters<typeof createUser>
// [name: string, age: number, email: string]
// Useful for higher-order functions
function withLogging<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
console.log('Calling with:', args)
return fn(...args)
}
```
### ConstructorParameters\<T\>
Extracts the parameter types of a constructor function type.
```typescript
class User {
constructor(public name: string, public age: number) {}
}
type UserConstructorParams = ConstructorParameters<typeof User>
// [name: string, age: number]
function createUser(...args: UserConstructorParams): User {
return new User(...args)
}
```
### ReturnType\<T\>
Extracts the return type of a function type.
```typescript
function createUser() {
return {
id: '123',
name: 'Alice',
email: 'alice@example.com',
}
}
type User = ReturnType<typeof createUser>
// {
// id: string
// name: string
// email: string
// }
// Useful with async functions
async function fetchData() {
return { success: true, data: [1, 2, 3] }
}
type FetchResult = ReturnType<typeof fetchData>
// Promise<{ success: boolean; data: number[] }>
type UnwrappedResult = Awaited<FetchResult>
// { success: boolean; data: number[] }
```
### InstanceType\<T\>
Extracts the instance type of a constructor function type.
```typescript
class User {
name: string
constructor(name: string) {
this.name = name
}
}
type UserInstance = InstanceType<typeof User>
// User
function processUser(user: UserInstance) {
console.log(user.name)
}
```
### ThisParameterType\<T\>
Extracts the type of the `this` parameter for a function type.
```typescript
function toHex(this: Number) {
return this.toString(16)
}
type ThisType = ThisParameterType<typeof toHex> // Number
```
### OmitThisParameter\<T\>
Removes the `this` parameter from a function type.
```typescript
function toHex(this: Number) {
return this.toString(16)
}
type PlainFunction = OmitThisParameter<typeof toHex>
// () => string
```
## String Manipulation
### Uppercase\<S\>
Converts string literal type to uppercase.
```typescript
type Greeting = 'hello'
type LoudGreeting = Uppercase<Greeting> // "HELLO"
// Useful for constants
type HttpMethod = 'get' | 'post' | 'put' | 'delete'
type HttpMethodUppercase = Uppercase<HttpMethod>
// "GET" | "POST" | "PUT" | "DELETE"
```
### Lowercase\<S\>
Converts string literal type to lowercase.
```typescript
type Greeting = 'HELLO'
type QuietGreeting = Lowercase<Greeting> // "hello"
```
### Capitalize\<S\>
Capitalizes the first letter of a string literal type.
```typescript
type Event = 'click' | 'scroll' | 'mousemove'
type EventHandler = `on${Capitalize<Event>}`
// "onClick" | "onScroll" | "onMousemove"
```
### Uncapitalize\<S\>
Uncapitalizes the first letter of a string literal type.
```typescript
type Greeting = 'Hello'
type LowerGreeting = Uncapitalize<Greeting> // "hello"
```
## Async Utilities
### Awaited\<T\>
Unwraps the type of a Promise (recursively).
```typescript
type T1 = Awaited<Promise<string>> // string
type T2 = Awaited<Promise<Promise<number>>> // number
type T3 = Awaited<boolean | Promise<string>> // boolean | string
// Useful with async functions
async function fetchUser() {
return { id: '123', name: 'Alice' }
}
type User = Awaited<ReturnType<typeof fetchUser>>
// { id: string; name: string }
```
## Custom Utility Types
### DeepPartial\<T\>
Makes all properties and nested properties optional.
```typescript
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
interface User {
id: string
profile: {
name: string
address: {
street: string
city: string
}
}
}
type PartialUser = DeepPartial<User>
// All properties at all levels are optional
```
### DeepReadonly\<T\>
Makes all properties and nested properties readonly.
```typescript
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
interface User {
id: string
profile: {
name: string
address: {
street: string
city: string
}
}
}
type ImmutableUser = DeepReadonly<User>
// All properties at all levels are readonly
```
### PartialBy\<T, K\>
Makes specific properties optional.
```typescript
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
interface User {
id: string
name: string
email: string
age: number
}
type UserWithOptionalEmail = PartialBy<User, 'email' | 'age'>
// {
// id: string
// name: string
// email?: string
// age?: number
// }
```
### RequiredBy\<T, K\>
Makes specific properties required.
```typescript
type RequiredBy<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
interface User {
id?: string
name?: string
email?: string
}
type UserWithRequiredId = RequiredBy<User, 'id'>
// {
// id: string
// name?: string
// email?: string
// }
```
### PickByType\<T, U\>
Picks properties by their value type.
```typescript
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
interface User {
id: string
name: string
age: number
active: boolean
}
type StringProperties = PickByType<User, string>
// { id: string; name: string }
type NumberProperties = PickByType<User, number>
// { age: number }
```
### OmitByType\<T, U\>
Omits properties by their value type.
```typescript
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K]
}
interface User {
id: string
name: string
age: number
active: boolean
}
type NonStringProperties = OmitByType<User, string>
// { age: number; active: boolean }
```
### Prettify\<T\>
Flattens intersections for better IDE tooltips.
```typescript
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
type A = { a: string }
type B = { b: number }
type C = A & B
type PrettyC = Prettify<C>
// Displays as: { a: string; b: number }
// Instead of: A & B
```
### ValueOf\<T\>
Gets the union of all value types.
```typescript
type ValueOf<T> = T[keyof T]
interface Colors {
red: '#ff0000'
green: '#00ff00'
blue: '#0000ff'
}
type ColorValue = ValueOf<Colors>
// "#ff0000" | "#00ff00" | "#0000ff"
```
### Nullable\<T\>
Makes type nullable.
```typescript
type Nullable<T> = T | null
type NullableString = Nullable<string> // string | null
```
### Maybe\<T\>
Makes type nullable or undefined.
```typescript
type Maybe<T> = T | null | undefined
type MaybeString = Maybe<string> // string | null | undefined
```
### UnionToIntersection\<U\>
Converts union to intersection (advanced).
```typescript
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never
type Union = { a: string } | { b: number }
type Intersection = UnionToIntersection<Union>
// { a: string } & { b: number }
```
## Combining Utility Types
Utility types can be composed for powerful transformations:
```typescript
// Make specific properties optional and readonly
type PartialReadonly<T, K extends keyof T> = Readonly<Pick<T, K>> &
Partial<Omit<T, K>>
interface User {
id: string
name: string
email: string
password: string
}
type SafeUser = PartialReadonly<User, 'id' | 'name'>
// {
// readonly id: string
// readonly name: string
// email?: string
// password?: string
// }
// Pick and make readonly
type ReadonlyPick<T, K extends keyof T> = Readonly<Pick<T, K>>
// Omit and make required
type RequiredOmit<T, K extends keyof T> = Required<Omit<T, K>>
```
## Best Practices
1. **Use built-in utilities first** - They're well-tested and optimized
2. **Compose utilities** - Combine utilities for complex transformations
3. **Create custom utilities** - For patterns you use frequently
4. **Name utilities clearly** - Make intent obvious from the name
5. **Document complex utilities** - Add JSDoc for non-obvious transformations
6. **Test utility types** - Use type assertions to verify behavior
7. **Avoid over-engineering** - Don't create utilities for one-off uses
8. **Consider readability** - Sometimes explicit types are clearer
9. **Use Prettify** - For better IDE tooltips with intersections
10. **Leverage keyof** - For type-safe property selection

View File

@@ -1,18 +1,87 @@
# Exclude heavy or host-specific data from Docker build context
# Fixes: failed to solve: error from sender: open cmd/benchmark/data/postgres: permission denied
# Build artifacts
orly
test-build
*.exe
*.dll
*.so
*.dylib
# Benchmark data and reports (mounted at runtime via volumes)
cmd/benchmark/data/
cmd/benchmark/reports/
# Test files
*_test.go
# VCS and OS cruft
.git
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
**/.DS_Store
**/Thumbs.db
# Go build cache and binaries
**/bin/
**/dist/
**/build/
**/*.out
# Docker files (except the one we're using)
Dockerfile*
!scripts/Dockerfile.deploy-test
docker-compose.yml
.dockerignore
# Node modules (will be installed during build)
app/web/node_modules/
app/web/dist/
app/web/bun.lockb
# Go modules cache
go.sum
# Logs and temp files
*.log
tmp/
temp/
# Database files
*.db
*.badger
# Certificates and keys
*.pem
*.key
*.crt
# Environment files
.env
.env.local
.env.production
# Documentation that's not needed for deployment test
docs/
*.md
*.adoc
!README.adoc
# Scripts we don't need for testing
scripts/benchmark.sh
scripts/reload.sh
scripts/run-*.sh
scripts/test.sh
scripts/runtests.sh
scripts/sprocket/
# Benchmark and test data
cmd/benchmark/
reports/
*.txt
*.conf
*.jsonl
# Policy test files
POLICY_*.md
test_policy.sh
test-*.sh
# Other build artifacts
tee

View File

@@ -1,6 +1,9 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
#
# NOTE: All builds use CGO_ENABLED=0 since p8k library uses purego (not CGO)
# The library dynamically loads libsecp256k1 at runtime via purego
#
# Release Process:
# 1. Update the version in the pkg/version/version file (e.g. v1.2.3)
# 2. Create and push a tag matching the version:
@@ -16,12 +19,34 @@ name: Go
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.25"
- name: Build (Pure Go + purego)
run: CGO_ENABLED=0 go build -v ./...
- name: Test (Pure Go + purego)
run: |
# Copy the libsecp256k1.so to root directory so tests can find it
cp pkg/crypto/p8k/libsecp256k1.so .
CGO_ENABLED=0 go test -v $(go list ./... | xargs -n1 sh -c 'ls $0/*_test.go 1>/dev/null 2>&1 && echo $0' | grep .)
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
@@ -30,79 +55,34 @@ jobs:
with:
go-version: '1.25'
- name: Install libsecp256k1
run: ./scripts/ubuntu_install_libsecp256k1.sh
- name: Build Release Binaries (Pure Go + purego)
if: startsWith(github.ref, 'refs/tags/v')
run: |
# Extract version from tag (e.g., v1.2.3 -> 1.2.3)
VERSION=${GITHUB_REF#refs/tags/v}
echo "Building release binaries for version $VERSION (pure Go + purego)"
- name: Build with cgo
run: go build -v ./...
# Create directory for binaries
mkdir -p release-binaries
- name: Test with cgo
run: go test -v ./...
# Copy the pre-compiled libsecp256k1.so for Linux AMD64
cp pkg/crypto/p8k/libsecp256k1.so release-binaries/libsecp256k1-linux-amd64.so
- name: Set CGO off
run: echo "CGO_ENABLED=0" >> $GITHUB_ENV
# Build for Linux AMD64 (pure Go + purego dynamic loading)
echo "Building Linux AMD64 (pure Go + purego dynamic loading)..."
GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -ldflags "-s -w" -o release-binaries/orly-${VERSION}-linux-amd64 .
# Create checksums
cd release-binaries
sha256sum * > SHA256SUMS.txt
cd ..
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
# release:
# needs: build
# runs-on: ubuntu-latest
# permissions:
# contents: write
# packages: write
#
# steps:
# - uses: actions/checkout@v4
#
# - name: Set up Go
# uses: actions/setup-go@v4
# with:
# go-version: '1.25'
#
# - name: Install libsecp256k1
# run: ./scripts/ubuntu_install_libsecp256k1.sh
#
# - name: Build Release Binaries
# if: startsWith(github.ref, 'refs/tags/v')
# run: |
# # Extract version from tag (e.g., v1.2.3 -> 1.2.3)
# VERSION=${GITHUB_REF#refs/tags/v}
# echo "Building release binaries for version $VERSION"
#
# # Create directory for binaries
# mkdir -p release-binaries
#
# # Build for different platforms
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o release-binaries/orly-${VERSION}-linux-amd64 .
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-linux-arm64 .
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-amd64 .
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-darwin-arm64 .
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/orly-${VERSION}-windows-amd64.exe .
#
# # Build cmd executables
# for cmd in lerproxy nauth nurl vainstr walletcli; do
# echo "Building $cmd"
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o release-binaries/${cmd}-${VERSION}-linux-amd64 ./cmd/${cmd}
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-linux-arm64 ./cmd/${cmd}
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-darwin-amd64 ./cmd/${cmd}
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-darwin-arm64 ./cmd/${cmd}
# GOEXPERIMENT=greenteagc,jsonv2 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o release-binaries/${cmd}-${VERSION}-windows-amd64.exe ./cmd/${cmd}
# done
#
# # Create checksums
# cd release-binaries
# sha256sum * > SHA256SUMS.txt
# cd ..
#
# - name: Create GitHub Release
# if: startsWith(github.ref, 'refs/tags/v')
# uses: softprops/action-gh-release@v1
# with:
# files: release-binaries/*
# draft: false
# prerelease: false
# generate_release_notes: true
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v1
with:
files: release-binaries/*
draft: false
prerelease: false
generate_release_notes: true

3642
.gitignore vendored

File diff suppressed because it is too large Load Diff

395
CLAUDE.md Normal file
View File

@@ -0,0 +1,395 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ORLY is a high-performance Nostr relay written in Go, designed for personal relays, small communities, and business deployments. It emphasizes low latency, custom cryptography optimizations, and embedded database performance.
**Key Technologies:**
- **Language**: Go 1.25.3+
- **Database**: Badger v4 (embedded key-value store)
- **Cryptography**: Custom p8k library using purego for secp256k1 operations (no CGO)
- **Web UI**: Svelte frontend embedded in the binary
- **WebSocket**: gorilla/websocket for Nostr protocol
- **Performance**: SIMD-accelerated SHA256 and hex encoding
## Build Commands
### Basic Build
```bash
# Build relay binary only
go build -o orly
# Pure Go build (no CGO) - this is the standard approach
CGO_ENABLED=0 go build -o orly
```
### Build with Web UI
```bash
# Recommended: Use the provided script
./scripts/update-embedded-web.sh
# Manual build
cd app/web
bun install
bun run build
cd ../../
go build -o orly
```
### Development Mode (Web UI Hot Reload)
```bash
# Terminal 1: Start relay with dev proxy
export ORLY_WEB_DISABLE_EMBEDDED=true
export ORLY_WEB_DEV_PROXY_URL=localhost:5000
./orly &
# Terminal 2: Start dev server
cd app/web && bun run dev
```
## Testing
### Run All Tests
```bash
# Standard test run
./scripts/test.sh
# Or manually with purego setup
CGO_ENABLED=0 go test ./...
# Note: libsecp256k1.so must be available for crypto tests
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}$(pwd)/pkg/crypto/p8k"
```
### Run Specific Package Tests
```bash
# Test database package
cd pkg/database && go test -v ./...
# Test protocol package
cd pkg/protocol && go test -v ./...
# Test with specific test function
go test -v -run TestSaveEvent ./pkg/database
```
### Relay Protocol Testing
```bash
# Test relay protocol compliance
go run cmd/relay-tester/main.go -url ws://localhost:3334
# List available tests
go run cmd/relay-tester/main.go -list
# Run specific test
go run cmd/relay-tester/main.go -url ws://localhost:3334 -test "Basic Event"
```
### Benchmarking
```bash
# Run benchmarks in specific package
go test -bench=. -benchmem ./pkg/database
# Crypto benchmarks
cd pkg/crypto/p8k && make bench
```
## Running the Relay
### Basic Run
```bash
# Build and run
go build -o orly && ./orly
# With environment variables
export ORLY_LOG_LEVEL=debug
export ORLY_PORT=3334
./orly
```
### Get Relay Identity
```bash
# Print relay identity secret and pubkey
./orly identity
```
### Common Configuration
```bash
# TLS with Let's Encrypt
export ORLY_TLS_DOMAINS=relay.example.com
# Admin configuration
export ORLY_ADMINS=npub1...
# Follows ACL mode
export ORLY_ACL_MODE=follows
# Enable sprocket event processing
export ORLY_SPROCKET_ENABLED=true
# Enable policy system
export ORLY_POLICY_ENABLED=true
```
## Code Architecture
### Repository Structure
**Root Entry Point:**
- `main.go` - Application entry point with signal handling, profiling setup, and database initialization
- `app/main.go` - Core relay server initialization and lifecycle management
**Core Packages:**
**`app/`** - HTTP/WebSocket server and handlers
- `server.go` - Main Server struct and HTTP request routing
- `handle-*.go` - Nostr protocol message handlers (EVENT, REQ, COUNT, CLOSE, AUTH, DELETE)
- `handle-websocket.go` - WebSocket connection lifecycle and frame handling
- `listener.go` - Network listener setup
- `sprocket.go` - External event processing script manager
- `publisher.go` - Event broadcast to active subscriptions
- `payment_processor.go` - NWC integration for subscription payments
- `blossom.go` - Blob storage service initialization
- `web.go` - Embedded web UI serving and dev proxy
- `config/` - Environment variable configuration using go-simpler.org/env
**`pkg/database/`** - Badger-based event storage
- `database.go` - Database initialization with cache tuning
- `save-event.go` - Event storage with index updates
- `query-events.go` - Main query execution engine
- `query-for-*.go` - Specialized query builders for different filter patterns
- `indexes/` - Index key construction for efficient lookups
- `export.go` / `import.go` - Event export/import in JSONL format
- `subscriptions.go` - Active subscription tracking
- `identity.go` - Relay identity key management
- `migrations.go` - Database schema migration runner
**`pkg/protocol/`** - Nostr protocol implementation
- `ws/` - WebSocket message framing and parsing
- `auth/` - NIP-42 authentication challenge/response
- `publish/` - Event publisher for broadcasting to subscriptions
- `relayinfo/` - NIP-11 relay information document
- `directory/` - Distributed directory service (NIP-XX)
- `nwc/` - Nostr Wallet Connect client
- `blossom/` - Blob storage protocol
**`pkg/encoders/`** - Optimized Nostr data encoding/decoding
- `event/` - Event JSON marshaling/unmarshaling with buffer pooling
- `filter/` - Filter parsing and validation
- `bech32encoding/` - npub/nsec/note encoding
- `hex/` - SIMD-accelerated hex encoding using templexxx/xhex
- `timestamp/`, `kind/`, `tag/` - Specialized field encoders
**`pkg/crypto/`** - Cryptographic operations
- `p8k/` - Pure Go secp256k1 using purego (no CGO) to dynamically load libsecp256k1.so
- `secp.go` - Dynamic library loading and function binding
- `schnorr.go` - Schnorr signature operations (NIP-01)
- `ecdh.go` - ECDH for encrypted DMs (NIP-04, NIP-44)
- `recovery.go` - Public key recovery from signatures
- `libsecp256k1.so` - Pre-compiled secp256k1 library
- `keys/` - Key derivation and conversion utilities
- `sha256/` - SIMD-accelerated SHA256 using minio/sha256-simd
**`pkg/acl/`** - Access control systems
- `acl.go` - ACL registry and interface
- `follows.go` - Follows-based whitelist (admins + their follows can write)
- `managed.go` - NIP-86 managed relay with role-based permissions
- `none.go` - Open relay (no restrictions)
**`pkg/policy/`** - Event filtering and validation policies
- Policy configuration loaded from `~/.config/ORLY/policy.json`
- Per-kind size limits, age restrictions, custom scripts
- See `docs/POLICY_USAGE_GUIDE.md` for configuration examples
**`pkg/sync/`** - Distributed synchronization
- `cluster_manager.go` - Active replication between relay peers
- `relay_group_manager.go` - Relay group configuration (NIP-XX)
- `manager.go` - Distributed directory consensus
**`pkg/spider/`** - Event syncing from other relays
- `spider.go` - Spider manager for "follows" mode
- Fetches events from admin relays for followed pubkeys
**`pkg/utils/`** - Shared utilities
- `atomic/` - Extended atomic operations
- `interrupt/` - Signal handling and graceful shutdown
- `apputil/` - Application-level utilities
**Web UI (`app/web/`):**
- Svelte-based admin interface
- Embedded in binary via `go:embed`
- Features: event browser, sprocket management, user admin, settings
**Command-line Tools (`cmd/`):**
- `relay-tester/` - Nostr protocol compliance testing
- `benchmark/` - Multi-relay performance comparison
- `stresstest/` - Load testing tool
- `aggregator/` - Event aggregation utility
- `convert/` - Data format conversion
- `policytest/` - Policy validation testing
### Important Patterns
**Pure Go with Purego:**
- All builds use `CGO_ENABLED=0`
- The p8k crypto library uses `github.com/ebitengine/purego` to dynamically load `libsecp256k1.so` at runtime
- This avoids CGO complexity while maintaining C library performance
- `libsecp256k1.so` must be in `LD_LIBRARY_PATH` or same directory as binary
**Database Query Pattern:**
- Filters are analyzed in `get-indexes-from-filter.go` to determine optimal query strategy
- Different query builders (`query-for-kinds.go`, `query-for-authors.go`, etc.) handle specific filter patterns
- All queries return event serials (uint64) for efficient joining
- Final events fetched via `fetch-events-by-serials.go`
**WebSocket Message Flow:**
1. `handle-websocket.go` accepts connection and spawns goroutine
2. Incoming frames parsed by `pkg/protocol/ws/`
3. Routed to handlers: `handle-event.go`, `handle-req.go`, `handle-count.go`, etc.
4. Events stored via `database.SaveEvent()`
5. Active subscriptions notified via `publishers.Publish()`
**Configuration System:**
- Uses `go-simpler.org/env` for struct tags
- All config in `app/config/config.go` with `ORLY_` prefix
- Supports XDG directories via `github.com/adrg/xdg`
- Default data directory: `~/.local/share/ORLY`
**Event Publishing:**
- `pkg/protocol/publish/` manages publisher registry
- Each WebSocket connection registers its subscriptions
- `publishers.Publish(event)` broadcasts to matching subscribers
- Efficient filter matching without re-querying database
**Embedded Assets:**
- Web UI built to `app/web/dist/`
- Embedded via `//go:embed` directive in `app/web.go`
- Served at root path `/` with API at `/api/*`
## Development Workflow
### Making Changes to Web UI
1. Edit files in `app/web/src/`
2. For hot reload: `cd app/web && bun run dev` (with `ORLY_WEB_DISABLE_EMBEDDED=true`)
3. For production build: `./scripts/update-embedded-web.sh`
### Adding New Nostr Protocol Handlers
1. Create `app/handle-<message-type>.go`
2. Add case in `app/handle-message.go` message router
3. Implement handler following existing patterns
4. Add tests in `app/<handler>_test.go`
### Adding Database Indexes
1. Define index in `pkg/database/indexes/`
2. Add migration in `pkg/database/migrations.go`
3. Update `save-event.go` to populate index
4. Add query builder in `pkg/database/query-for-<index>.go`
5. Update `get-indexes-from-filter.go` to use new index
### Environment Variables for Development
```bash
# Verbose logging
export ORLY_LOG_LEVEL=trace
export ORLY_DB_LOG_LEVEL=debug
# Enable profiling
export ORLY_PPROF=cpu
export ORLY_PPROF_HTTP=true # Serves on :6060
# Health check endpoint
export ORLY_HEALTH_PORT=8080
```
### Profiling
```bash
# CPU profiling
export ORLY_PPROF=cpu
./orly
# Profile written on shutdown
# HTTP pprof server
export ORLY_PPROF_HTTP=true
./orly
# Visit http://localhost:6060/debug/pprof/
# Memory profiling
export ORLY_PPROF=memory
export ORLY_PPROF_PATH=/tmp/profiles
```
## Deployment
### Automated Deployment
```bash
# Deploy with systemd service
./scripts/deploy.sh
```
This script:
1. Installs Go 1.25.0 if needed
2. Builds relay with embedded web UI
3. Installs to `~/.local/bin/orly`
4. Creates systemd service
5. Sets capabilities for port 443 binding
### systemd Service Management
```bash
# Start/stop/restart
sudo systemctl start orly
sudo systemctl stop orly
sudo systemctl restart orly
# Enable on boot
sudo systemctl enable orly
# View logs
sudo journalctl -u orly -f
```
### Manual Deployment
```bash
# Build for production
./scripts/update-embedded-web.sh
# Or build all platforms
./scripts/build-all-platforms.sh
```
## Key Dependencies
- `github.com/dgraph-io/badger/v4` - Embedded database
- `github.com/gorilla/websocket` - WebSocket server
- `github.com/minio/sha256-simd` - SIMD SHA256
- `github.com/templexxx/xhex` - SIMD hex encoding
- `github.com/ebitengine/purego` - CGO-free C library loading
- `go-simpler.org/env` - Environment variable configuration
- `lol.mleku.dev` - Custom logging library
## Testing Guidelines
- Test files use `_test.go` suffix
- Use `github.com/stretchr/testify` for assertions
- Database tests require temporary database setup (see `pkg/database/testmain_test.go`)
- WebSocket tests should use `relay-tester` package
- Always clean up resources in tests (database, connections, goroutines)
## Performance Considerations
- **Database Caching**: Tune `ORLY_DB_BLOCK_CACHE_MB` and `ORLY_DB_INDEX_CACHE_MB` for workload
- **Query Optimization**: Add indexes for common filter patterns
- **Memory Pooling**: Use buffer pools in encoders (see `pkg/encoders/event/`)
- **SIMD Operations**: Leverage minio/sha256-simd and templexxx/xhex
- **Goroutine Management**: Each WebSocket connection runs in its own goroutine
## Release Process
1. Update version in `pkg/version/version` file (e.g., v1.2.3)
2. Create and push tag:
```bash
git tag v1.2.3
git push origin v1.2.3
```
3. GitHub Actions workflow builds binaries for multiple platforms
4. Release created automatically with binaries and checksums

357
INDEX.md Normal file
View File

@@ -0,0 +1,357 @@
# Strfry WebSocket Implementation Analysis - Document Index
## Overview
This collection provides a comprehensive, in-depth analysis of the strfry Nostr relay implementation, specifically focusing on its WebSocket handling architecture and performance optimizations.
**Total Documentation:** 2,416 lines across 4 documents
**Source:** https://github.com/hoytech/strfry
**Analysis Date:** November 6, 2025
---
## Document Guide
### 1. README_STRFRY_ANALYSIS.md (277 lines)
**Start here for context**
Provides:
- Overview of all analysis documents
- Key findings summary (architecture, library, message flow)
- Critical optimizations list (8 major techniques)
- File structure and organization
- Configuration reference
- Performance metrics table
- Nostr protocol support summary
- 10 key insights
- Building and testing instructions
**Reading Time:** 10-15 minutes
**Best For:** Getting oriented, understanding the big picture
---
### 2. strfry_websocket_quick_reference.md (270 lines)
**Quick lookup for specific topics**
Contains:
- Architecture points with file references
- Critical data structures table
- Thread pool architecture
- Event batching optimization details
- Connection lifecycle (4 stages with line numbers)
- 8 performance techniques with locations
- Configuration parameters (relay.conf)
- Bandwidth tracking code
- Nostr message types
- Filter processing pipeline
- File sizes and complexity table
- Error handling strategies
- 15 scalability features
**Use When:** Looking for specific implementation details, file locations, or configuration options
**Best For:**
- Developers implementing similar systems
- Performance tuning reference
- Quick lookup by topic
---
### 3. strfry_websocket_code_flow.md (731 lines)
**Step-by-step code execution traces**
Provides complete flow documentation for:
1. **Connection Establishment** - IP resolution, metadata allocation
2. **Incoming Message Processing** - Reception through ingestion
3. **Event Submission** - Validation, duplicate checking, queueing
4. **Subscription Requests (REQ)** - Filter parsing, query scheduling
5. **Event Broadcasting** - The critical batching optimization
6. **Connection Disconnection** - Statistics, cleanup, thread notification
7. **Thread Pool Dispatch** - Deterministic routing pattern
8. **Message Type Dispatch** - std::variant pattern
9. **Subscription Lifecycle** - Complete visual diagram
10. **Error Handling** - Exception propagation patterns
Each section includes:
- Exact file paths and line numbers
- Full code examples with inline comments
- Step-by-step numbered execution trace
- Performance impact analysis
**Code Examples:** 250+ lines of actual source code
**Use When:** Understanding how specific operations work
**Best For:**
- Learning the complete message lifecycle
- Understanding threading model
- Studying performance optimization techniques
- Code review and auditing
---
### 4. strfry_websocket_analysis.md (1138 lines)
**Complete reference guide**
Comprehensive coverage of:
**Section 1: WebSocket Library & Connection Setup**
- Library choice (uWebSockets fork)
- Event multiplexing (epoll/IOCP)
- Server connection setup (compression, PING, binding)
- Individual connection management
- Client connection wrapper (WSConnection.h)
- Configuration parameters
**Section 2: Message Parsing and Serialization**
- Incoming message reception
- JSON parsing and command routing
- Event processing and serialization
- REQ (subscription) request parsing
- Nostr protocol message structures
**Section 3: Event Handling and Subscription Management**
- Subscription data structure
- ReqWorker (initial query processing)
- ReqMonitor (live event streaming)
- ActiveMonitors (indexed subscription tracking)
**Section 4: Connection Management and Cleanup**
- Graceful connection disconnection
- Connection statistics tracking
- Thread-safe closure flow
**Section 5: Performance Optimizations Specific to C++**
- Event batching for broadcast (memory layout analysis)
- String view usage for zero-copy
- Move semantics for message queues
- Variant-based polymorphism (no virtual dispatch)
- Memory pre-allocation and buffer reuse
- Protected queues with batch operations
- Lazy initialization and caching
- Compression with dictionary support
- Single-threaded event loop
- Lock-free inter-thread communication
- Template-based HTTP response caching
- Ring buffer implementation
**Section 6-8:** Architecture diagrams, configuration reference, file complexity analysis
**Code Examples:** 350+ lines with detailed annotations
**Use When:** Building a complete understanding
**Best For:**
- Implementation reference for similar systems
- Performance optimization inspiration
- Architecture study
- Educational resource
- Production code patterns
---
## Quick Navigation
### By Topic
**Architecture & Design**
- README_STRFRY_ANALYSIS.md - "Architecture" section
- strfry_websocket_code_flow.md - Section 9 (Lifecycle diagram)
**WebSocket/Network**
- strfry_websocket_analysis.md - Section 1
- strfry_websocket_quick_reference.md - Sections 1, 8
**Message Processing**
- strfry_websocket_analysis.md - Section 2
- strfry_websocket_code_flow.md - Sections 1-3
**Subscriptions & Filtering**
- strfry_websocket_analysis.md - Section 3
- strfry_websocket_quick_reference.md - Section 12
**Performance Optimization**
- strfry_websocket_analysis.md - Section 5 (most detailed)
- strfry_websocket_quick_reference.md - Section 8
- README_STRFRY_ANALYSIS.md - "Critical Optimizations" section
**Connection Management**
- strfry_websocket_analysis.md - Section 4
- strfry_websocket_code_flow.md - Section 6
**Error Handling**
- strfry_websocket_code_flow.md - Section 10
- strfry_websocket_quick_reference.md - Section 14
**Configuration**
- README_STRFRY_ANALYSIS.md - "Configuration" section
- strfry_websocket_quick_reference.md - Section 9
### By Audience
**System Designers**
1. Start: README_STRFRY_ANALYSIS.md
2. Deep dive: strfry_websocket_analysis.md sections 1, 3, 4
3. Reference: strfry_websocket_code_flow.md section 9
**Performance Engineers**
1. Start: strfry_websocket_quick_reference.md section 8
2. Deep dive: strfry_websocket_analysis.md section 5
3. Code examples: strfry_websocket_code_flow.md section 5
**Implementers (building similar systems)**
1. Overview: README_STRFRY_ANALYSIS.md
2. Architecture: strfry_websocket_code_flow.md
3. Reference: strfry_websocket_analysis.md
4. Tuning: strfry_websocket_quick_reference.md
**Students/Learning**
1. Start: README_STRFRY_ANALYSIS.md
2. Code flows: strfry_websocket_code_flow.md (sections 1-4)
3. Deep dive: strfry_websocket_analysis.md (one section at a time)
4. Reference: strfry_websocket_quick_reference.md
---
## Key Statistics
### Code Coverage
- **Total Source Files Analyzed:** 13 C++ files
- **Total Lines of Source Code:** 3,274 lines
- **Code Examples Provided:** 600+ lines
- **File:Line References:** 100+
### Documentation Volume
- **Total Documentation:** 2,416 lines
- **Code Examples:** 600+ lines (25% of total)
- **Diagrams:** 4 ASCII architecture diagrams
### Performance Optimizations Documented
- **Thread Pool Patterns:** 2 (deterministic dispatch, batch dispatch)
- **Memory Optimization Techniques:** 5 (move semantics, string_view, pre-allocation, etc.)
- **Synchronization Patterns:** 3 (batched queues, lock-free, hash-based)
- **Dispatch Patterns:** 2 (variant-based, callback-based)
---
## Source Code Files Referenced
**WebSocket & Connection (4 files)**
- WSConnection.h (175 lines) - Client wrapper
- RelayWebsocket.cpp (327 lines) - Server implementation
- RelayServer.h (231 lines) - Message definitions
**Message Processing (3 files)**
- RelayIngester.cpp (170 lines) - Parsing & validation
- RelayReqWorker.cpp (45 lines) - Query processing
- RelayReqMonitor.cpp (62 lines) - Live filtering
**Data Structures & Support (6 files)**
- Subscription.h (69 lines)
- ThreadPool.h (61 lines)
- ActiveMonitors.h (235 lines)
- Decompressor.h (68 lines)
- WriterPipeline.h (209 lines)
**Additional Components (2 files)**
- RelayWriter.cpp (113 lines) - DB writes
- RelayNegentropy.cpp (264 lines) - Sync protocol
---
## Key Takeaways
### Architecture Principles
1. Single-threaded I/O with epoll for connection multiplexing
2. Actor model with message-passing between threads
3. Deterministic routing for lock-free message dispatch
4. Separation of concerns (I/O, validation, storage, filtering)
### Performance Techniques
1. Event batching: serialize once, reuse for thousands
2. Move semantics: zero-copy thread communication
3. std::variant: type-safe dispatch without virtual functions
4. Pre-allocation: avoid hot-path allocations
5. Compression: built-in with custom dictionaries
### Scalability Features
1. Handles thousands of concurrent connections
2. Lock-free message passing (or very low contention)
3. CPU time budgeting for long queries
4. Graceful degradation and shutdown
5. Per-connection observability
---
## How to Use This Documentation
### For Quick Answers
```
Use strfry_websocket_quick_reference.md
- Index by section number
- Find file:line references
- Look up specific techniques
```
### For Understanding a Feature
```
1. Find reference in strfry_websocket_quick_reference.md
2. Read corresponding section in strfry_websocket_analysis.md
3. Study code flow in strfry_websocket_code_flow.md
4. Review source code at exact file:line locations
```
### For Building Similar Systems
```
1. Read README_STRFRY_ANALYSIS.md - Key Findings
2. Study strfry_websocket_analysis.md - Section 5 (Optimizations)
3. Implement patterns from strfry_websocket_code_flow.md
4. Reference strfry_websocket_quick_reference.md during implementation
```
---
## File Locations in This Repository
All analysis documents are in `/home/mleku/src/next.orly.dev/`:
```
├── README_STRFRY_ANALYSIS.md (277 lines) - Start here
├── strfry_websocket_quick_reference.md (270 lines) - Quick lookup
├── strfry_websocket_code_flow.md (731 lines) - Code flows
├── strfry_websocket_analysis.md (1138 lines) - Complete reference
└── INDEX.md (this file)
```
Original source cloned from: `https://github.com/hoytech/strfry`
Local clone location: `/tmp/strfry/`
---
## Document Integrity
All code examples are:
- Taken directly from source files
- Include exact line number references
- Annotated with execution flow
- Verified against original code
All file paths are absolute paths to the cloned repository.
---
## Additional Resources
**Nostr Protocol:** https://github.com/nostr-protocol/nostr
**uWebSockets:** https://github.com/uNetworking/uWebSockets
**LMDB:** http://www.lmdb.tech/doc/
**secp256k1:** https://github.com/bitcoin-core/secp256k1
**Negentropy:** https://github.com/hoytech/negentropy
---
**Analysis Completeness:** Comprehensive
**Last Updated:** November 6, 2025
**Coverage:** All WebSocket and connection handling code
Questions or corrections? Refer to the source code at `/tmp/strfry/` for the definitive reference.

53
app/blossom.go Normal file
View File

@@ -0,0 +1,53 @@
package app
import (
"context"
"net/http"
"strings"
"lol.mleku.dev/log"
"next.orly.dev/app/config"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
blossom "next.orly.dev/pkg/blossom"
)
// initializeBlossomServer creates and configures the Blossom blob storage server
func initializeBlossomServer(
ctx context.Context, cfg *config.C, db *database.D,
) (*blossom.Server, error) {
// Create blossom server configuration
blossomCfg := &blossom.Config{
BaseURL: "", // Will be set dynamically per request
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
AllowedMimeTypes: nil, // Allow all MIME types by default
RequireAuth: cfg.AuthRequired || cfg.AuthToWrite,
}
// Create blossom server with relay's ACL registry
bs := blossom.NewServer(db, acl.Registry, blossomCfg)
// Override baseURL getter to use request-based URL
// We'll need to modify the handler to inject the baseURL per request
// For now, we'll use a middleware approach
log.I.F("blossom server initialized with ACL mode: %s", cfg.ACLMode)
return bs, nil
}
// blossomHandler wraps the blossom server handler to inject baseURL per request
func (s *Server) blossomHandler(w http.ResponseWriter, r *http.Request) {
// Strip /blossom prefix and pass to blossom handler
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/blossom")
if !strings.HasPrefix(r.URL.Path, "/") {
r.URL.Path = "/" + r.URL.Path
}
// Set baseURL in request context for blossom server to use
baseURL := s.ServiceURL(r) + "/blossom"
type baseURLKey struct{}
r = r.WithContext(context.WithValue(r.Context(), baseURLKey{}, baseURL))
s.blossomServer.Handler().ServeHTTP(w, r)
}

View File

@@ -23,29 +23,65 @@ import (
// and default values. It defines parameters for app behaviour, storage
// locations, logging, and network settings used across the relay service.
type C struct {
AppName string `env:"ORLY_APP_NAME" usage:"set a name to display on information about the relay" default:"ORLY"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/ORLY"`
Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
HealthPort int `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
EnableShutdown bool `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
DBLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
OpenPprofWeb bool `env:"ORLY_OPEN_PPROF_WEB" default:"false" usage:"if true, automatically open the pprof web viewer when profiling is enabled"`
IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows,none" default:"none"`
SpiderMode string `env:"ORLY_SPIDER_MODE" usage:"spider mode: none,follow" default:"none"`
SpiderFrequency time.Duration `env:"ORLY_SPIDER_FREQUENCY" usage:"spider frequency in seconds" default:"1h"`
AppName string `env:"ORLY_APP_NAME" usage:"set a name to display on information about the relay" default:"ORLY"`
DataDir string `env:"ORLY_DATA_DIR" usage:"storage location for the event store" default:"~/.local/share/ORLY"`
Listen string `env:"ORLY_LISTEN" default:"0.0.0.0" usage:"network listen address"`
Port int `env:"ORLY_PORT" default:"3334" usage:"port to listen on"`
HealthPort int `env:"ORLY_HEALTH_PORT" default:"0" usage:"optional health check HTTP port; 0 disables"`
EnableShutdown bool `env:"ORLY_ENABLE_SHUTDOWN" default:"false" usage:"if true, expose /shutdown on the health port to gracefully stop the process (for profiling)"`
LogLevel string `env:"ORLY_LOG_LEVEL" default:"info" usage:"relay log level: fatal error warn info debug trace"`
DBLogLevel string `env:"ORLY_DB_LOG_LEVEL" default:"info" usage:"database log level: fatal error warn info debug trace"`
DBBlockCacheMB int `env:"ORLY_DB_BLOCK_CACHE_MB" default:"512" usage:"Badger block cache size in MB (higher improves read hit ratio)"`
DBIndexCacheMB int `env:"ORLY_DB_INDEX_CACHE_MB" default:"256" usage:"Badger index cache size in MB (improves index lookup performance)"`
LogToStdout bool `env:"ORLY_LOG_TO_STDOUT" default:"false" usage:"log to stdout instead of stderr"`
Pprof string `env:"ORLY_PPROF" usage:"enable pprof in modes: cpu,memory,allocation,heap,block,goroutine,threadcreate,mutex"`
PprofPath string `env:"ORLY_PPROF_PATH" usage:"optional directory to write pprof profiles into (inside container); default is temporary dir"`
PprofHTTP bool `env:"ORLY_PPROF_HTTP" default:"false" usage:"if true, expose net/http/pprof on port 6060"`
IPWhitelist []string `env:"ORLY_IP_WHITELIST" usage:"comma-separated list of IP addresses to allow access from, matches on prefixes to allow private subnets, eg 10.0.0 = 10.0.0.0/8"`
IPBlacklist []string `env:"ORLY_IP_BLACKLIST" usage:"comma-separated list of IP addresses to block; matches on prefixes to allow subnets, e.g. 192.168 = 192.168.0.0/16"`
Admins []string `env:"ORLY_ADMINS" usage:"comma-separated list of admin npubs"`
Owners []string `env:"ORLY_OWNERS" usage:"comma-separated list of owner npubs, who have full control of the relay for wipe and restart and other functions"`
ACLMode string `env:"ORLY_ACL_MODE" usage:"ACL mode: follows, managed (nip-86), none" default:"none"`
AuthRequired bool `env:"ORLY_AUTH_REQUIRED" usage:"require authentication for all requests (works with managed ACL)" default:"false"`
AuthToWrite bool `env:"ORLY_AUTH_TO_WRITE" usage:"require authentication only for write operations (EVENT), allow REQ/COUNT without auth" default:"false"`
BootstrapRelays []string `env:"ORLY_BOOTSTRAP_RELAYS" usage:"comma-separated list of bootstrap relay URLs for initial sync"`
NWCUri string `env:"ORLY_NWC_URI" usage:"NWC (Nostr Wallet Connect) connection string for Lightning payments"`
SubscriptionEnabled bool `env:"ORLY_SUBSCRIPTION_ENABLED" default:"false" usage:"enable subscription-based access control requiring payment for non-directory events"`
MonthlyPriceSats int64 `env:"ORLY_MONTHLY_PRICE_SATS" default:"6000" usage:"price in satoshis for one month subscription (default ~$2 USD)"`
RelayURL string `env:"ORLY_RELAY_URL" usage:"base URL for the relay dashboard (e.g., https://relay.example.com)"`
RelayAddresses []string `env:"ORLY_RELAY_ADDRESSES" usage:"comma-separated list of websocket addresses for this relay (e.g., wss://relay.example.com,wss://backup.example.com)"`
RelayPeers []string `env:"ORLY_RELAY_PEERS" usage:"comma-separated list of peer relay URLs for distributed synchronization (e.g., https://peer1.example.com,https://peer2.example.com)"`
RelayGroupAdmins []string `env:"ORLY_RELAY_GROUP_ADMINS" usage:"comma-separated list of npubs authorized to publish relay group configuration events"`
ClusterAdmins []string `env:"ORLY_CLUSTER_ADMINS" usage:"comma-separated list of npubs authorized to manage cluster membership"`
FollowListFrequency time.Duration `env:"ORLY_FOLLOW_LIST_FREQUENCY" usage:"how often to fetch admin follow lists (default: 1h)" default:"1h"`
// Blossom blob storage service level settings
BlossomServiceLevels string `env:"ORLY_BLOSSOM_SERVICE_LEVELS" usage:"comma-separated list of service levels in format: name:storage_mb_per_sat_per_month (e.g., basic:1,premium:10)"`
// Web UI and dev mode settings
WebDisableEmbedded bool `env:"ORLY_WEB_DISABLE" default:"false" usage:"disable serving the embedded web UI; useful for hot-reload during development"`
WebDevProxyURL string `env:"ORLY_WEB_DEV_PROXY_URL" usage:"when ORLY_WEB_DISABLE is true, reverse-proxy non-API paths to this dev server URL (e.g. http://localhost:5173)"`
// Sprocket settings
SprocketEnabled bool `env:"ORLY_SPROCKET_ENABLED" default:"false" usage:"enable sprocket event processing plugin system"`
// Spider settings
SpiderMode string `env:"ORLY_SPIDER_MODE" default:"none" usage:"spider mode for syncing events: none, follows"`
PolicyEnabled bool `env:"ORLY_POLICY_ENABLED" default:"false" usage:"enable policy-based event processing (configuration found in $HOME/.config/ORLY/policy.json)"`
// NIP-43 Relay Access Metadata and Requests
NIP43Enabled bool `env:"ORLY_NIP43_ENABLED" default:"false" usage:"enable NIP-43 relay access metadata and invite system"`
NIP43PublishEvents bool `env:"ORLY_NIP43_PUBLISH_EVENTS" default:"true" usage:"publish kind 8000/8001 events when members are added/removed"`
NIP43PublishMemberList bool `env:"ORLY_NIP43_PUBLISH_MEMBER_LIST" default:"true" usage:"publish kind 13534 membership list events"`
NIP43InviteExpiry time.Duration `env:"ORLY_NIP43_INVITE_EXPIRY" default:"24h" usage:"how long invite codes remain valid"`
// TLS configuration
TLSDomains []string `env:"ORLY_TLS_DOMAINS" usage:"comma-separated list of domains to respond to for TLS"`
Certs []string `env:"ORLY_CERTS" usage:"comma-separated list of paths to certificate root names (e.g., /path/to/cert will load /path/to/cert.pem and /path/to/cert.key)"`
// Cluster replication configuration
ClusterPropagatePrivilegedEvents bool `env:"ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS" default:"true" usage:"propagate privileged events (DMs, gift wraps, etc.) to relay peers for replication"`
}
// New creates and initializes a new configuration object for the relay
@@ -136,6 +172,21 @@ func GetEnv() (requested bool) {
return
}
// IdentityRequested checks if the first command line argument is "identity" and returns
// whether the relay identity should be printed and the program should exit.
//
// Return Values
// - requested: true if the 'identity' subcommand was provided, false otherwise.
func IdentityRequested() (requested bool) {
if len(os.Args) > 1 {
switch strings.ToLower(os.Args[1]) {
case "identity":
requested = true
}
}
return
}
// KV is a key/value pair.
type KV struct{ Key, Value string }
@@ -167,9 +218,7 @@ func (kv KVSlice) Swap(i, j int) { kv[i], kv[j] = kv[j], kv[i] }
// resulting slice remains sorted by keys as per the KVSlice implementation.
func (kv KVSlice) Compose(kv2 KVSlice) (out KVSlice) {
// duplicate the initial KVSlice
for _, p := range kv {
out = append(out, p)
}
out = append(out, kv...)
out:
for i, p := range kv2 {
for j, q := range out {
@@ -206,15 +255,14 @@ func EnvKV(cfg any) (m KVSlice) {
k := t.Field(i).Tag.Get("env")
v := reflect.ValueOf(cfg).Field(i).Interface()
var val string
switch v.(type) {
switch v := v.(type) {
case string:
val = v.(string)
val = v
case int, bool, time.Duration:
val = fmt.Sprint(v)
case []string:
arr := v.([]string)
if len(arr) > 0 {
val = strings.Join(arr, ",")
if len(v) > 0 {
val = strings.Join(v, ",")
}
}
// this can happen with embedded structs
@@ -286,5 +334,4 @@ func PrintHelp(cfg *C, printer io.Writer) {
fmt.Fprintf(printer, "\ncurrent configuration:\n\n")
PrintEnv(cfg, printer)
fmt.Fprintln(printer)
return
}

View File

@@ -25,7 +25,7 @@ func (l *Listener) HandleAuth(b []byte) (err error) {
var valid bool
if valid, err = auth.Validate(
env.Event, l.challenge.Load(),
l.ServiceURL(l.req),
l.WebSocketURL(l.req),
); err != nil {
e := err.Error()
if err = Ok.Error(l, env, e); chk.E(err) {
@@ -50,6 +50,34 @@ func (l *Listener) HandleAuth(b []byte) (err error) {
env.Event.Pubkey,
)
l.authedPubkey.Store(env.Event.Pubkey)
// Check if this is a first-time user and create welcome note
go l.handleFirstTimeUser(env.Event.Pubkey)
}
return
}
// handleFirstTimeUser checks if user is logging in for first time and creates welcome note
func (l *Listener) handleFirstTimeUser(pubkey []byte) {
// Check if this is a first-time user
isFirstTime, err := l.Server.D.IsFirstTimeUser(pubkey)
if err != nil {
log.E.F("failed to check first-time user status: %v", err)
return
}
if !isFirstTime {
return // Not a first-time user
}
// Get payment processor to create welcome note
if l.Server.paymentProcessor != nil {
// Set the dashboard URL based on the current HTTP request
dashboardURL := l.Server.DashboardURL(l.req)
l.Server.paymentProcessor.SetDashboardURL(dashboardURL)
if err := l.Server.paymentProcessor.CreateWelcomeNote(pubkey); err != nil {
log.E.F("failed to create welcome note for first-time user: %v", err)
}
}
}

View File

@@ -23,13 +23,30 @@ func (l *Listener) HandleClose(req []byte) (err error) {
if len(env.ID) == 0 {
return errors.New("CLOSE has no <id>")
}
subID := string(env.ID)
// Cancel the subscription goroutine by calling its cancel function
l.subscriptionsMu.Lock()
if cancelFunc, exists := l.subscriptions[subID]; exists {
log.D.F("cancelling subscription %s for %s", subID, l.remote)
cancelFunc()
delete(l.subscriptions, subID)
} else {
log.D.F("subscription %s not found for %s (already closed?)", subID, l.remote)
}
l.subscriptionsMu.Unlock()
// Also remove from publisher's tracking
l.publishers.Receive(
&W{
Cancel: true,
remote: l.remote,
Conn: l.conn,
Id: string(env.ID),
Id: subID,
},
)
log.D.F("CLOSE processed for subscription %s @ %s", subID, l.remote)
return
}

100
app/handle-count.go Normal file
View File

@@ -0,0 +1,100 @@
package app
import (
"context"
"errors"
"fmt"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/crypto/ec/schnorr"
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/envelopes/countenvelope"
"next.orly.dev/pkg/utils/normalize"
)
// HandleCount processes a COUNT envelope by parsing the request, verifying
// permissions, invoking the database CountEvents for each provided filter, and
// responding with a COUNT response containing the aggregate count.
func (l *Listener) HandleCount(msg []byte) (err error) {
log.D.F("HandleCount: START processing from %s", l.remote)
// Parse the COUNT request
env := countenvelope.New()
if _, err = env.Unmarshal(msg); chk.E(err) {
return normalize.Error.Errorf(err.Error())
}
log.D.C(func() string { return fmt.Sprintf("COUNT sub=%s filters=%d", env.Subscription, len(env.Filters)) })
// If ACL is active, auth is required, or AuthToWrite is enabled, send a challenge (same as REQ path)
if len(l.authedPubkey.Load()) != schnorr.PubKeyBytesLen && (acl.Registry.Active.Load() != "none" || l.Config.AuthRequired || l.Config.AuthToWrite) {
if err = authenvelope.NewChallengeWith(l.challenge.Load()).Write(l); chk.E(err) {
return
}
}
// Check read permissions
accessLevel := acl.Registry.GetAccessLevel(l.authedPubkey.Load(), l.remote)
// If auth is required but user is not authenticated, deny access
if l.Config.AuthRequired && len(l.authedPubkey.Load()) == 0 {
return errors.New("authentication required")
}
// If AuthToWrite is enabled, allow COUNT without auth (but still check ACL)
if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 {
// Allow unauthenticated COUNT when AuthToWrite is enabled
// but still respect ACL access levels if ACL is active
if acl.Registry.Active.Load() != "none" {
switch accessLevel {
case "none", "blocked", "banned":
return errors.New("auth required: user not authed or has no read access")
}
}
// Allow the request to proceed without authentication
} else {
// Only check ACL access level if not already handled by AuthToWrite
switch accessLevel {
case "none":
return errors.New("auth required: user not authed or has no read access")
default:
// allowed to read
}
}
// Use a bounded context for counting, isolated from the connection context
// to prevent count timeouts from affecting the long-lived websocket connection
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Aggregate count across all provided filters
var total int
var approx bool // database returns false per implementation
for _, f := range env.Filters {
if f == nil {
continue
}
var cnt int
var a bool
cnt, a, err = l.D.CountEvents(ctx, f)
if chk.E(err) {
return
}
total += cnt
approx = approx || a
}
// Build and send COUNT response
var res *countenvelope.Response
if res, err = countenvelope.NewResponseFrom(env.Subscription, total, approx); chk.E(err) {
return
}
if err = res.Write(l); chk.E(err) {
return
}
log.D.F("HandleCount: COMPLETED processing from %s count=%d approx=%v", l.remote, total, approx)
return nil
}

View File

@@ -1,8 +1,6 @@
package app
import (
"fmt"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/database/indexes/types"
@@ -24,23 +22,46 @@ func (l *Listener) GetSerialsFromFilter(f *filter.F) (
}
func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
// log.I.C(
// func() string {
// return fmt.Sprintf(
// "delete event\n%s", env.E.Serialize(),
// )
// },
// )
log.I.F("HandleDelete: processing delete event %0x from pubkey %0x", env.E.ID, env.E.Pubkey)
log.I.F("HandleDelete: delete event tags: %d tags", len(*env.E.Tags))
for i, t := range *env.E.Tags {
log.I.F("HandleDelete: tag %d: %s = %s", i, string(t.Key()), string(t.Value()))
}
// Debug: log admin and owner lists
log.I.F("HandleDelete: checking against %d admins and %d owners", len(l.Admins), len(l.Owners))
for i, pk := range l.Admins {
log.I.F("HandleDelete: admin[%d] = %0x (hex: %s)", i, pk, hex.Enc(pk))
}
for i, pk := range l.Owners {
log.I.F("HandleDelete: owner[%d] = %0x (hex: %s)", i, pk, hex.Enc(pk))
}
log.I.F("HandleDelete: delete event pubkey = %0x (hex: %s)", env.E.Pubkey, hex.Enc(env.E.Pubkey))
var ownerDelete bool
for _, pk := range l.Admins {
if utils.FastEqual(pk, env.E.Pubkey) {
ownerDelete = true
log.I.F("HandleDelete: delete event from admin/owner %0x", env.E.Pubkey)
break
}
}
if !ownerDelete {
for _, pk := range l.Owners {
if utils.FastEqual(pk, env.E.Pubkey) {
ownerDelete = true
log.I.F("HandleDelete: delete event from owner %0x", env.E.Pubkey)
break
}
}
}
if !ownerDelete {
log.I.F("HandleDelete: delete event from regular user %0x", env.E.Pubkey)
}
// process the tags in the delete event
var deleteErr error
var validDeletionFound bool
var deletionCount int
for _, t := range *env.E.Tags {
// first search for a tags, as these are the simplest to process
if utils.FastEqual(t.Key(), []byte("a")) {
@@ -109,8 +130,10 @@ func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
if err = l.DeleteEventBySerial(
l.Ctx(), s, ev,
); chk.E(err) {
log.E.F("HandleDelete: failed to delete event %s: %v", hex.Enc(ev.ID), err)
continue
}
deletionCount++
}
}
}
@@ -121,21 +144,27 @@ func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
if utils.FastEqual(t.Key(), []byte("e")) {
val := t.Value()
if len(val) == 0 {
log.W.F("HandleDelete: empty e-tag value")
continue
}
log.I.F("HandleDelete: processing e-tag with value: %s", string(val))
var dst []byte
if b, e := hex.Dec(string(val)); chk.E(e) {
log.E.F("HandleDelete: failed to decode hex event ID %s: %v", string(val), e)
continue
} else {
dst = b
log.I.F("HandleDelete: decoded event ID: %0x", dst)
}
f := &filter.F{
Ids: tag.NewFromBytesSlice(dst),
}
var sers types.Uint40s
if sers, err = l.GetSerialsFromFilter(f); chk.E(err) {
log.E.F("HandleDelete: failed to get serials from filter: %v", err)
continue
}
log.I.F("HandleDelete: found %d serials for event ID %s", len(sers), string(val))
// if found, delete them
if len(sers) > 0 {
// there should be only one event per serial, so we can just
@@ -145,17 +174,22 @@ func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
if ev, err = l.FetchEventBySerial(s); chk.E(err) {
continue
}
// check that the author is the same as the signer of the
// delete, for the e tag case the author is the signer of
// the event.
if !utils.FastEqual(env.E.Pubkey, ev.Pubkey) {
// Debug: log the comparison details
log.I.F("HandleDelete: checking deletion permission for event %s", hex.Enc(ev.ID))
log.I.F("HandleDelete: delete event pubkey = %s, target event pubkey = %s", hex.Enc(env.E.Pubkey), hex.Enc(ev.Pubkey))
log.I.F("HandleDelete: ownerDelete = %v, pubkey match = %v", ownerDelete, utils.FastEqual(env.E.Pubkey, ev.Pubkey))
// For admin/owner deletes: allow deletion regardless of pubkey match
// For regular users: allow deletion only if the signer is the author
if !ownerDelete && !utils.FastEqual(env.E.Pubkey, ev.Pubkey) {
log.W.F(
"HandleDelete: attempted deletion of event %s by different user - delete pubkey=%s, event pubkey=%s",
"HandleDelete: attempted deletion of event %s by unauthorized user - delete pubkey=%s, event pubkey=%s",
hex.Enc(ev.ID), hex.Enc(env.E.Pubkey),
hex.Enc(ev.Pubkey),
)
continue
}
log.I.F("HandleDelete: deletion authorized for event %s", hex.Enc(ev.ID))
validDeletionFound = true
// exclude delete events
if ev.Kind == kind.EventDeletion.K {
@@ -166,8 +200,10 @@ func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
hex.Enc(ev.ID), hex.Enc(env.E.Pubkey),
)
if err = l.DeleteEventBySerial(l.Ctx(), s, ev); chk.E(err) {
log.E.F("HandleDelete: failed to delete event %s: %v", hex.Enc(ev.ID), err)
continue
}
deletionCount++
}
continue
}
@@ -200,23 +236,35 @@ func (l *Listener) HandleDelete(env *eventenvelope.Submission) (err error) {
if ev, err = l.FetchEventBySerial(s); chk.E(err) {
continue
}
// check that the author is the same as the signer of the
// delete, for the k tag case the author is the signer of
// the event.
if !utils.FastEqual(env.E.Pubkey, ev.Pubkey) {
// For admin/owner deletes: allow deletion regardless of pubkey match
// For regular users: allow deletion only if the signer is the author
if !ownerDelete && !utils.FastEqual(env.E.Pubkey, ev.Pubkey) {
continue
}
validDeletionFound = true
log.I.F(
"HandleDelete: deleting event %s via k-tag by authorized user %s",
hex.Enc(ev.ID), hex.Enc(env.E.Pubkey),
)
if err = l.DeleteEventBySerial(l.Ctx(), s, ev); chk.E(err) {
log.E.F("HandleDelete: failed to delete event %s: %v", hex.Enc(ev.ID), err)
continue
}
deletionCount++
}
continue
}
}
continue
}
// If no valid deletions were found, return an error
if !validDeletionFound {
return fmt.Errorf("blocked: cannot delete events that belong to other users")
log.W.F("HandleDelete: no valid deletions found for event %0x", env.E.ID)
// Don't block delete events from being stored - just log the issue
// The delete event itself should still be accepted even if no targets are found
log.I.F("HandleDelete: delete event %0x stored but no target events found to delete", env.E.ID)
return nil
}
log.I.F("HandleDelete: successfully processed %d deletions for event %0x", deletionCount, env.E.ID)
return
}

View File

@@ -12,25 +12,158 @@ import (
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
"next.orly.dev/pkg/encoders/envelopes/okenvelope"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils"
)
func (l *Listener) HandleEvent(msg []byte) (err error) {
log.D.F("HandleEvent: START handling event: %s", msg)
// decode the envelope
env := eventenvelope.NewSubmission()
log.I.F("HandleEvent: received event message length: %d", len(msg))
if msg, err = env.Unmarshal(msg); chk.E(err) {
log.E.F("HandleEvent: failed to unmarshal event: %v", err)
return
}
log.I.F(
"HandleEvent: successfully unmarshaled event, kind: %d, pubkey: %s, id: %0x",
env.E.Kind, hex.Enc(env.E.Pubkey), env.E.ID,
)
defer func() {
if env != nil && env.E != nil {
env.E.Free()
}
}()
if len(msg) > 0 {
log.I.F("extra '%s'", msg)
}
// Check if sprocket is enabled and process event through it
if l.sprocketManager != nil && l.sprocketManager.IsEnabled() {
if l.sprocketManager.IsDisabled() {
// Sprocket is disabled due to failure - reject all events
log.W.F("sprocket is disabled, rejecting event %0x", env.E.ID)
if err = Ok.Error(
l, env,
"sprocket disabled - events rejected until sprocket is restored",
); chk.E(err) {
return
}
return
}
if !l.sprocketManager.IsRunning() {
// Sprocket is enabled but not running - reject all events
log.W.F(
"sprocket is enabled but not running, rejecting event %0x",
env.E.ID,
)
if err = Ok.Error(
l, env,
"sprocket not running - events rejected until sprocket starts",
); chk.E(err) {
return
}
return
}
// Process event through sprocket
response, sprocketErr := l.sprocketManager.ProcessEvent(env.E)
if chk.E(sprocketErr) {
log.E.F("sprocket processing failed: %v", sprocketErr)
if err = Ok.Error(
l, env, "sprocket processing failed",
); chk.E(err) {
return
}
return
}
// Handle sprocket response
switch response.Action {
case "accept":
// Continue with normal processing
log.D.F("sprocket accepted event %0x", env.E.ID)
case "reject":
// Return OK false with message
if err = okenvelope.NewFrom(
env.Id(), false,
reason.Error.F(response.Msg),
).Write(l); chk.E(err) {
return
}
return
case "shadowReject":
// Return OK true but abort processing
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
}
log.D.F("sprocket shadow rejected event %0x", env.E.ID)
return
default:
log.W.F("unknown sprocket action: %s", response.Action)
// Default to accept for unknown actions
}
}
// Check if policy is enabled and process event through it
if l.policyManager != nil && l.policyManager.Manager != nil && l.policyManager.Manager.IsEnabled() {
// Check policy for write access
allowed, policyErr := l.policyManager.CheckPolicy("write", env.E, l.authedPubkey.Load(), l.remote)
if chk.E(policyErr) {
log.E.F("policy check failed: %v", policyErr)
if err = Ok.Error(
l, env, "policy check failed",
); chk.E(err) {
return
}
return
}
if !allowed {
log.D.F("policy rejected event %0x", env.E.ID)
if err = Ok.Blocked(
l, env, "event blocked by policy",
); chk.E(err) {
return
}
return
}
log.D.F("policy allowed event %0x", env.E.ID)
// Check ACL policy for managed ACL mode, but skip for peer relay sync events
if acl.Registry.Active.Load() == "managed" && !l.isPeerRelayPubkey(l.authedPubkey.Load()) {
allowed, aclErr := acl.Registry.CheckPolicy(env.E)
if chk.E(aclErr) {
log.E.F("ACL policy check failed: %v", aclErr)
if err = Ok.Error(
l, env, "ACL policy check failed",
); chk.E(err) {
return
}
return
}
if !allowed {
log.D.F("ACL policy rejected event %0x", env.E.ID)
if err = Ok.Blocked(
l, env, "event blocked by ACL policy",
); chk.E(err) {
return
}
return
}
log.D.F("ACL policy allowed event %0x", env.E.ID)
}
}
// check the event ID is correct
calculatedId := env.E.GetIDBytes()
if !utils.FastEqual(calculatedId, env.E.ID) {
@@ -43,6 +176,18 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
return
}
// validate timestamp - reject events too far in the future (more than 1 hour)
now := time.Now().Unix()
if env.E.CreatedAt > now+3600 {
if err = Ok.Invalid(
l, env,
"timestamp too far in the future",
); chk.E(err) {
return
}
return
}
// verify the signature
var ok bool
if ok, err = env.Verify(); chk.T(err) {
@@ -63,46 +208,162 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
return
}
// check permissions of user
accessLevel := acl.Registry.GetAccessLevel(l.authedPubkey.Load(), l.remote)
switch accessLevel {
case "none":
log.D.F(
"handle event: sending 'OK,false,auth-required...' to %s", l.remote,
)
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F("auth required for write access"),
).Write(l); chk.E(err) {
// return
}
log.D.F("handle event: sending challenge to %s", l.remote)
if err = authenvelope.NewChallengeWith(l.challenge.Load()).
Write(l); chk.E(err) {
return
// Handle NIP-43 special events before ACL checks
switch env.E.Kind {
case nip43.KindJoinRequest:
// Process join request and return early
if err = l.HandleNIP43JoinRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 join request: %v", err)
}
return
case "read":
log.D.F(
"handle event: sending 'OK,false,auth-required:...' to %s",
l.remote,
)
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F("auth required for write access"),
).Write(l); chk.E(err) {
return
}
log.D.F("handle event: sending challenge to %s", l.remote)
if err = authenvelope.NewChallengeWith(l.challenge.Load()).
Write(l); chk.E(err) {
return
case nip43.KindLeaveRequest:
// Process leave request and return early
if err = l.HandleNIP43LeaveRequest(env.E); chk.E(err) {
log.E.F("failed to process NIP-43 leave request: %v", err)
}
return
default:
// user has write access or better, continue
// log.D.F("user has %s access", accessLevel)
}
// check permissions of user
log.I.F(
"HandleEvent: checking ACL permissions for pubkey: %s",
hex.Enc(l.authedPubkey.Load()),
)
// If ACL mode is "none" and no pubkey is set, use the event's pubkey
// But if auth is required or AuthToWrite is enabled, always use the authenticated pubkey
var pubkeyForACL []byte
if len(l.authedPubkey.Load()) == 0 && acl.Registry.Active.Load() == "none" && !l.Config.AuthRequired && !l.Config.AuthToWrite {
pubkeyForACL = env.E.Pubkey
log.I.F(
"HandleEvent: ACL mode is 'none' and auth not required, using event pubkey for ACL check: %s",
hex.Enc(pubkeyForACL),
)
} else {
pubkeyForACL = l.authedPubkey.Load()
}
// If auth is required or AuthToWrite is enabled but user is not authenticated, deny access
if (l.Config.AuthRequired || l.Config.AuthToWrite) && len(l.authedPubkey.Load()) == 0 {
log.D.F("HandleEvent: authentication required for write operations but user not authenticated")
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F("authentication required for write operations"),
).Write(l); chk.E(err) {
return
}
return
}
accessLevel := acl.Registry.GetAccessLevel(pubkeyForACL, l.remote)
log.I.F("HandleEvent: ACL access level: %s", accessLevel)
// Skip ACL check for admin/owner delete events
skipACLCheck := false
if env.E.Kind == kind.EventDeletion.K {
// Check if the delete event signer is admin or owner
for _, admin := range l.Admins {
if utils.FastEqual(admin, env.E.Pubkey) {
skipACLCheck = true
log.I.F("HandleEvent: admin delete event - skipping ACL check")
break
}
}
if !skipACLCheck {
for _, owner := range l.Owners {
if utils.FastEqual(owner, env.E.Pubkey) {
skipACLCheck = true
log.I.F("HandleEvent: owner delete event - skipping ACL check")
break
}
}
}
}
if !skipACLCheck {
switch accessLevel {
case "none":
log.D.F(
"handle event: sending 'OK,false,auth-required...' to %s",
l.remote,
)
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F("auth required for write access"),
).Write(l); chk.E(err) {
// return
}
log.D.F("handle event: sending challenge to %s", l.remote)
if err = authenvelope.NewChallengeWith(l.challenge.Load()).
Write(l); chk.E(err) {
return
}
return
case "read":
log.D.F(
"handle event: sending 'OK,false,auth-required:...' to %s",
l.remote,
)
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F("auth required for write access"),
).Write(l); chk.E(err) {
return
}
log.D.F("handle event: sending challenge to %s", l.remote)
if err = authenvelope.NewChallengeWith(l.challenge.Load()).
Write(l); chk.E(err) {
return
}
return
case "blocked":
log.D.F(
"handle event: sending 'OK,false,blocked...' to %s",
l.remote,
)
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F("IP address blocked"),
).Write(l); chk.E(err) {
return
}
return
case "banned":
log.D.F(
"handle event: sending 'OK,false,banned...' to %s",
l.remote,
)
if err = okenvelope.NewFrom(
env.Id(), false,
reason.AuthRequired.F("pubkey banned"),
).Write(l); chk.E(err) {
return
}
return
default:
// user has write access or better, continue
log.I.F("HandleEvent: user has %s access, continuing", accessLevel)
}
} else {
log.I.F("HandleEvent: skipping ACL check for admin/owner delete event")
}
// check if event is ephemeral - if so, deliver and return early
if kind.IsEphemeral(env.E.Kind) {
log.D.F("handling ephemeral event %0x (kind %d)", env.E.ID, env.E.Kind)
// Send OK response for ephemeral events
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
}
// Deliver the event to subscribers immediately
clonedEvent := env.E.Clone()
go l.publishers.Deliver(clonedEvent)
log.D.F("delivered ephemeral event %0x", env.E.ID)
return
}
log.D.F("processing regular event %0x (kind %d)", env.E.ID, env.E.Kind)
// check for protected tag (NIP-70)
protectedTag := env.E.Tags.GetFirst([]byte("-"))
if protectedTag != nil && acl.Registry.Active.Load() != "none" {
@@ -118,8 +379,25 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
}
// if the event is a delete, process the delete
log.I.F(
"HandleEvent: checking if event is delete - kind: %d, EventDeletion.K: %d",
env.E.Kind, kind.EventDeletion.K,
)
if env.E.Kind == kind.EventDeletion.K {
if err = l.HandleDelete(env); err != nil {
log.I.F("processing delete event %0x", env.E.ID)
// Store the delete event itself FIRST to ensure it's available for queries
saveCtx, cancel := context.WithTimeout(
context.Background(), 30*time.Second,
)
defer cancel()
log.I.F(
"attempting to save delete event %0x from pubkey %0x", env.E.ID,
env.E.Pubkey,
)
log.I.F("delete event pubkey hex: %s", hex.Enc(env.E.Pubkey))
if _, err = l.SaveEvent(saveCtx, env.E); err != nil {
log.E.F("failed to save delete event %0x: %v", env.E.ID, err)
if strings.HasPrefix(err.Error(), "blocked:") {
errStr := err.Error()[len("blocked: "):len(err.Error())]
if err = Ok.Error(
@@ -129,10 +407,46 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
return
}
chk.E(err)
return
}
log.I.F("successfully saved delete event %0x", env.E.ID)
// Now process the deletion (remove target events)
if err = l.HandleDelete(env); err != nil {
log.E.F("HandleDelete failed for event %0x: %v", env.E.ID, err)
if strings.HasPrefix(err.Error(), "blocked:") {
errStr := err.Error()[len("blocked: "):len(err.Error())]
if err = Ok.Error(
l, env, errStr,
); chk.E(err) {
return
}
return
}
// For non-blocked errors, still send OK but log the error
log.W.F("Delete processing failed but continuing: %v", err)
} else {
log.I.F(
"HandleDelete completed successfully for event %0x", env.E.ID,
)
}
// Send OK response for delete events
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
}
// Deliver the delete event to subscribers
clonedEvent := env.E.Clone()
go l.publishers.Deliver(clonedEvent)
log.D.F("processed delete event %0x", env.E.ID)
return
} else {
// check if the event was deleted
if err = l.CheckForDeleted(env.E, l.Admins); err != nil {
// Combine admins and owners for deletion checking
adminOwners := append(l.Admins, l.Owners...)
if err = l.CheckForDeleted(env.E, adminOwners); err != nil {
if strings.HasPrefix(err.Error(), "blocked:") {
errStr := err.Error()[len("blocked: "):len(err.Error())]
if err = Ok.Error(
@@ -147,7 +461,7 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
saveCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// log.I.F("saving event %0x, %s", env.E.ID, env.E.Serialize())
if _, _, err = l.SaveEvent(saveCtx, env.E); err != nil {
if _, err = l.SaveEvent(saveCtx, env.E); err != nil {
if strings.HasPrefix(err.Error(), "blocked:") {
errStr := err.Error()[len("blocked: "):len(err.Error())]
if err = Ok.Error(
@@ -160,6 +474,30 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
chk.E(err)
return
}
// Handle relay group configuration events
if l.relayGroupMgr != nil {
if err := l.relayGroupMgr.ValidateRelayGroupEvent(env.E); err != nil {
log.W.F("invalid relay group config event %s: %v", hex.Enc(env.E.ID), err)
}
// Process the event and potentially update peer lists
if l.syncManager != nil {
l.relayGroupMgr.HandleRelayGroupEvent(env.E, l.syncManager)
}
}
// Handle cluster membership events (Kind 39108)
if env.E.Kind == 39108 && l.clusterManager != nil {
if err := l.clusterManager.HandleMembershipEvent(env.E); err != nil {
log.W.F("invalid cluster membership event %s: %v", hex.Enc(env.E.ID), err)
}
}
// Update serial for distributed synchronization
if l.syncManager != nil {
l.syncManager.UpdateSerial()
log.D.F("updated serial for event %s", hex.Enc(env.E.ID))
}
// Send a success response storing
if err = Ok.Ok(l, env, ""); chk.E(err) {
return
@@ -170,12 +508,21 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
go l.publishers.Deliver(clonedEvent)
log.D.F("saved event %0x", env.E.ID)
var isNewFromAdmin bool
// Check if event is from admin or owner
for _, admin := range l.Admins {
if utils.FastEqual(admin, env.E.Pubkey) {
isNewFromAdmin = true
break
}
}
if !isNewFromAdmin {
for _, owner := range l.Owners {
if utils.FastEqual(owner, env.E.Pubkey) {
isNewFromAdmin = true
break
}
}
}
if isNewFromAdmin {
log.I.F("new event from admin %0x", env.E.Pubkey)
// if a follow list was saved, reconfigure ACLs now that it is persisted
@@ -191,3 +538,21 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
}
return
}
// isPeerRelayPubkey checks if the given pubkey belongs to a peer relay
func (l *Listener) isPeerRelayPubkey(pubkey []byte) bool {
if l.syncManager == nil {
return false
}
peerPubkeyHex := hex.Enc(pubkey)
// Check if this pubkey matches any of our configured peer relays' NIP-11 pubkeys
for _, peerURL := range l.syncManager.GetPeers() {
if l.syncManager.IsAuthorizedPeer(peerURL, peerPubkeyHex) {
return true
}
}
return false
}

View File

@@ -2,50 +2,160 @@ package app
import (
"fmt"
"strings"
"time"
"unicode/utf8"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/envelopes"
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/envelopes/closeenvelope"
"next.orly.dev/pkg/encoders/envelopes/countenvelope"
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
"next.orly.dev/pkg/encoders/envelopes/noticeenvelope"
"next.orly.dev/pkg/encoders/envelopes/reqenvelope"
)
// validateJSONMessage checks if a message contains invalid control characters
// that would cause JSON parsing to fail. It also validates UTF-8 encoding.
func validateJSONMessage(msg []byte) (err error) {
// First, validate that the message is valid UTF-8
if !utf8.Valid(msg) {
return fmt.Errorf("invalid UTF-8 encoding")
}
// Check for invalid control characters in JSON strings
for i := 0; i < len(msg); i++ {
b := msg[i]
// Check for invalid control characters (< 32) except tab, newline, carriage return
if b < 32 && b != '\t' && b != '\n' && b != '\r' {
return fmt.Errorf(
"invalid control character 0x%02X at position %d", b, i,
)
}
}
return
}
func (l *Listener) HandleMessage(msg []byte, remote string) {
// log.D.F("%s received message:\n%s", remote, msg)
// Handle blacklisted IPs - discard messages but keep connection open until timeout
if l.isBlacklisted {
// Check if timeout has been reached
if time.Now().After(l.blacklistTimeout) {
log.W.F(
"blacklisted IP %s timeout reached, closing connection", remote,
)
// Close the connection by cancelling the context
// The websocket handler will detect this and close the connection
return
}
log.D.F(
"discarding message from blacklisted IP %s (timeout in %v)", remote,
time.Until(l.blacklistTimeout),
)
return
}
msgPreview := string(msg)
if len(msgPreview) > 150 {
msgPreview = msgPreview[:150] + "..."
}
log.D.F("%s processing message (len=%d): %s", remote, len(msg), msgPreview)
// Validate message for invalid characters before processing
if err := validateJSONMessage(msg); err != nil {
log.E.F(
"%s message validation FAILED (len=%d): %v", remote, len(msg), err,
)
if noticeErr := noticeenvelope.NewFrom(
fmt.Sprintf(
"invalid message format: contains invalid characters: %s", msg,
),
).Write(l); noticeErr != nil {
log.E.F(
"%s failed to send validation error notice: %v", remote,
noticeErr,
)
}
return
}
l.msgCount++
var err error
var t string
var rem []byte
if t, rem, err = envelopes.Identify(msg); !chk.E(err) {
switch t {
case eventenvelope.L:
// log.D.F("eventenvelope: %s %s", remote, rem)
err = l.HandleEvent(rem)
case reqenvelope.L:
// log.D.F("reqenvelope: %s %s", remote, rem)
err = l.HandleReq(rem)
case closeenvelope.L:
// log.D.F("closeenvelope: %s %s", remote, rem)
err = l.HandleClose(rem)
case authenvelope.L:
// log.D.F("authenvelope: %s %s", remote, rem)
err = l.HandleAuth(rem)
default:
err = fmt.Errorf("unknown envelope type %s\n%s", t, rem)
}
}
if err != nil {
// log.D.C(
// func() string {
// return fmt.Sprintf(
// "notice->%s %s", remote, err,
// )
// },
// )
if err = noticeenvelope.NewFrom(err.Error()).Write(l); err != nil {
return
// Attempt to identify the envelope type
if t, rem, err = envelopes.Identify(msg); err != nil {
log.E.F(
"%s envelope identification FAILED (len=%d): %v", remote, len(msg),
err,
)
// Don't log message preview as it may contain binary data
chk.E(err)
// Send error notice to client
if noticeErr := noticeenvelope.NewFrom("malformed message").Write(l); noticeErr != nil {
log.E.F(
"%s failed to send malformed message notice: %v", remote,
noticeErr,
)
}
return
}
log.T.F(
"%s identified envelope type: %s (payload_len=%d)", remote, t, len(rem),
)
// Process the identified envelope type
switch t {
case eventenvelope.L:
log.T.F("%s processing EVENT envelope", remote)
l.eventCount++
err = l.HandleEvent(rem)
case reqenvelope.L:
log.T.F("%s processing REQ envelope", remote)
l.reqCount++
err = l.HandleReq(rem)
case closeenvelope.L:
log.T.F("%s processing CLOSE envelope", remote)
err = l.HandleClose(rem)
case authenvelope.L:
log.T.F("%s processing AUTH envelope", remote)
err = l.HandleAuth(rem)
case countenvelope.L:
log.T.F("%s processing COUNT envelope", remote)
err = l.HandleCount(rem)
default:
err = fmt.Errorf("unknown envelope type %s", t)
log.E.F(
"%s unknown envelope type: %s (payload_len: %d)", remote, t,
len(rem),
)
}
// Handle any processing errors
if err != nil {
// Don't log context cancellation errors as they're expected during shutdown
if !strings.Contains(err.Error(), "context canceled") {
log.E.F(
"%s message processing FAILED (type=%s): %v", remote, t, err,
)
// Don't log message preview as it may contain binary data
// Send error notice to client (use generic message to avoid control chars in errors)
noticeMsg := fmt.Sprintf("%s processing failed", t)
if noticeErr := noticeenvelope.NewFrom(noticeMsg).Write(l); noticeErr != nil {
log.E.F(
"%s failed to send error notice after %s processing failure: %v",
remote, t, noticeErr,
)
return
}
log.T.F("%s sent error notice for %s processing failure", remote, t)
}
} else {
log.T.F("%s message processing SUCCESS (type=%s)", remote, t)
}
}

254
app/handle-nip43.go Normal file
View File

@@ -0,0 +1,254 @@
package app
import (
"context"
"fmt"
"strings"
"time"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/envelopes/okenvelope"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/protocol/nip43"
)
// HandleNIP43JoinRequest processes a kind 28934 join request
func (l *Listener) HandleNIP43JoinRequest(ev *event.E) error {
log.I.F("handling NIP-43 join request from %s", hex.Enc(ev.Pubkey))
// Validate the join request
inviteCode, valid, reason := nip43.ValidateJoinRequest(ev)
if !valid {
log.W.F("invalid join request: %s", reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason))
}
// Check if user is already a member
isMember, err := l.D.IsNIP43Member(ev.Pubkey)
if chk.E(err) {
log.E.F("error checking membership: %v", err)
return l.sendOKResponse(ev.ID, false, "error: internal server error")
}
if isMember {
log.I.F("user %s is already a member", hex.Enc(ev.Pubkey))
return l.sendOKResponse(ev.ID, true, "duplicate: you are already a member of this relay")
}
// Validate the invite code
validCode, reason := l.Server.InviteManager.ValidateAndConsume(inviteCode, ev.Pubkey)
if !validCode {
log.W.F("invalid or expired invite code: %s - %s", inviteCode, reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("restricted: %s", reason))
}
// Add the member
if err = l.D.AddNIP43Member(ev.Pubkey, inviteCode); chk.E(err) {
log.E.F("error adding member: %v", err)
return l.sendOKResponse(ev.ID, false, "error: failed to add member")
}
log.I.F("successfully added member %s via invite code", hex.Enc(ev.Pubkey))
// Publish kind 8000 "add member" event if configured
if l.Config.NIP43PublishEvents {
if err = l.publishAddUserEvent(ev.Pubkey); chk.E(err) {
log.W.F("failed to publish add user event: %v", err)
}
}
// Update membership list if configured
if l.Config.NIP43PublishMemberList {
if err = l.publishMembershipList(); chk.E(err) {
log.W.F("failed to publish membership list: %v", err)
}
}
relayURL := l.Config.RelayURL
if relayURL == "" {
relayURL = fmt.Sprintf("wss://%s:%d", l.Config.Listen, l.Config.Port)
}
return l.sendOKResponse(ev.ID, true, fmt.Sprintf("welcome to %s!", relayURL))
}
// HandleNIP43LeaveRequest processes a kind 28936 leave request
func (l *Listener) HandleNIP43LeaveRequest(ev *event.E) error {
log.I.F("handling NIP-43 leave request from %s", hex.Enc(ev.Pubkey))
// Validate the leave request
valid, reason := nip43.ValidateLeaveRequest(ev)
if !valid {
log.W.F("invalid leave request: %s", reason)
return l.sendOKResponse(ev.ID, false, fmt.Sprintf("error: %s", reason))
}
// Check if user is a member
isMember, err := l.D.IsNIP43Member(ev.Pubkey)
if chk.E(err) {
log.E.F("error checking membership: %v", err)
return l.sendOKResponse(ev.ID, false, "error: internal server error")
}
if !isMember {
log.I.F("user %s is not a member", hex.Enc(ev.Pubkey))
return l.sendOKResponse(ev.ID, true, "you are not a member of this relay")
}
// Remove the member
if err = l.D.RemoveNIP43Member(ev.Pubkey); chk.E(err) {
log.E.F("error removing member: %v", err)
return l.sendOKResponse(ev.ID, false, "error: failed to remove member")
}
log.I.F("successfully removed member %s", hex.Enc(ev.Pubkey))
// Publish kind 8001 "remove member" event if configured
if l.Config.NIP43PublishEvents {
if err = l.publishRemoveUserEvent(ev.Pubkey); chk.E(err) {
log.W.F("failed to publish remove user event: %v", err)
}
}
// Update membership list if configured
if l.Config.NIP43PublishMemberList {
if err = l.publishMembershipList(); chk.E(err) {
log.W.F("failed to publish membership list: %v", err)
}
}
return l.sendOKResponse(ev.ID, true, "you have been removed from this relay")
}
// HandleNIP43InviteRequest processes a kind 28935 invite request (REQ subscription)
func (s *Server) HandleNIP43InviteRequest(pubkey []byte) (*event.E, error) {
log.I.F("generating NIP-43 invite for pubkey %s", hex.Enc(pubkey))
// Check if requester has permission to request invites
// This could be based on ACL, admins, etc.
accessLevel := acl.Registry.GetAccessLevel(pubkey, "")
if accessLevel != "admin" && accessLevel != "owner" {
log.W.F("unauthorized invite request from %s (level: %s)", hex.Enc(pubkey), accessLevel)
return nil, fmt.Errorf("unauthorized: only admins can request invites")
}
// Generate a new invite code
code, err := s.InviteManager.GenerateCode()
if chk.E(err) {
return nil, err
}
// Get relay identity
relaySecret, err := s.db.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return nil, err
}
// Build the invite event
inviteEvent, err := nip43.BuildInviteEvent(relaySecret, code)
if chk.E(err) {
return nil, err
}
log.I.F("generated invite code for %s", hex.Enc(pubkey))
return inviteEvent, nil
}
// publishAddUserEvent publishes a kind 8000 add user event
func (l *Listener) publishAddUserEvent(userPubkey []byte) error {
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildAddUserEvent(relaySecret, userPubkey)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 8000 add user event for %s", hex.Enc(userPubkey))
return nil
}
// publishRemoveUserEvent publishes a kind 8001 remove user event
func (l *Listener) publishRemoveUserEvent(userPubkey []byte) error {
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildRemoveUserEvent(relaySecret, userPubkey)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 8001 remove user event for %s", hex.Enc(userPubkey))
return nil
}
// publishMembershipList publishes a kind 13534 membership list event
func (l *Listener) publishMembershipList() error {
// Get all members
members, err := l.D.GetAllNIP43Members()
if chk.E(err) {
return err
}
relaySecret, err := l.D.GetOrCreateRelayIdentitySecret()
if chk.E(err) {
return err
}
ev, err := nip43.BuildMemberListEvent(relaySecret, members)
if chk.E(err) {
return err
}
// Save to database
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err = l.SaveEvent(ctx, ev); chk.E(err) {
return err
}
// Publish to subscribers
l.publishers.Deliver(ev)
log.I.F("published kind 13534 membership list event with %d members", len(members))
return nil
}
// sendOKResponse sends an OK envelope response
func (l *Listener) sendOKResponse(eventID []byte, accepted bool, message string) error {
// Ensure message doesn't have "restricted: " prefix if already present
if accepted && strings.HasPrefix(message, "restricted: ") {
message = strings.TrimPrefix(message, "restricted: ")
}
env := okenvelope.NewFrom(eventID, accepted, []byte(message))
return env.Write(l)
}

570
app/handle-nip43_test.go Normal file
View File

@@ -0,0 +1,570 @@
package app
import (
"context"
"os"
"testing"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
)
// setupTestListener creates a test listener with NIP-43 enabled
func setupTestListener(t *testing.T) (*Listener, *database.D, func()) {
tempDir, err := os.MkdirTemp("", "nip43_handler_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cfg := &config.C{
NIP43Enabled: true,
NIP43PublishEvents: true,
NIP43PublishMemberList: true,
NIP43InviteExpiry: 24 * time.Hour,
RelayURL: "wss://test.relay",
Listen: "localhost",
Port: 3334,
}
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
cleanup := func() {
db.Close()
os.RemoveAll(tempDir)
}
return listener, db, cleanup
}
// TestHandleNIP43JoinRequest_ValidRequest tests a successful join request
func TestHandleNIP43JoinRequest_ValidRequest(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create join request event
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", code))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
// Sign event
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Verify user was added to database
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("user was not added as member")
}
// Verify membership details
membership, err := db.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != code {
t.Errorf("wrong invite code stored: got %s, want %s", membership.InviteCode, code)
}
}
// TestHandleNIP43JoinRequest_InvalidCode tests join request with invalid code
func TestHandleNIP43JoinRequest_InvalidCode(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Create join request with invalid code
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", "invalid-code-123"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request - should succeed but not add member
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was incorrectly added as member with invalid code")
}
}
// TestHandleNIP43JoinRequest_DuplicateMember tests join request from existing member
func TestHandleNIP43JoinRequest_DuplicateMember(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Add user directly to database
err = db.AddNIP43Member(userPubkey, "original-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Generate new invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create join request
ev := event.New()
ev.Kind = nip43.KindJoinRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.Tags.Append(tag.NewFromAny("claim", code))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle join request - should handle gracefully
err = listener.HandleNIP43JoinRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify original membership is unchanged
membership, err := db.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != "original-code" {
t.Errorf("invite code was changed: got %s, want original-code", membership.InviteCode)
}
}
// TestHandleNIP43LeaveRequest_ValidRequest tests a successful leave request
func TestHandleNIP43LeaveRequest_ValidRequest(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Add user as member
err = db.AddNIP43Member(userPubkey, "test-code")
if err != nil {
t.Fatalf("failed to add member: %v", err)
}
// Create leave request
ev := event.New()
ev.Kind = nip43.KindLeaveRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle leave request
err = listener.HandleNIP43LeaveRequest(ev)
if err != nil {
t.Fatalf("failed to handle leave request: %v", err)
}
// Verify user was removed
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was not removed")
}
}
// TestHandleNIP43LeaveRequest_NonMember tests leave request from non-member
func TestHandleNIP43LeaveRequest_NonMember(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user (not a member)
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Create leave request
ev := event.New()
ev.Kind = nip43.KindLeaveRequest
copy(ev.Pubkey, userPubkey)
ev.Tags = tag.NewS()
ev.Tags.Append(tag.NewFromAny("-"))
ev.CreatedAt = time.Now().Unix()
ev.Content = []byte("")
if err = ev.Sign(userSigner); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Handle leave request - should handle gracefully
err = listener.HandleNIP43LeaveRequest(ev)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
}
// TestHandleNIP43InviteRequest_ValidRequest tests invite request from admin
func TestHandleNIP43InviteRequest_ValidRequest(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate admin user
adminSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate admin secret: %v", err)
}
adminSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = adminSigner.InitSec(adminSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
adminPubkey := adminSigner.Pub()
// Add admin to server (simulating admin config)
listener.Server.Admins = [][]byte{adminPubkey}
// Handle invite request
inviteEvent, err := listener.Server.HandleNIP43InviteRequest(adminPubkey)
if err != nil {
t.Fatalf("failed to handle invite request: %v", err)
}
// Verify invite event
if inviteEvent == nil {
t.Fatal("invite event is nil")
}
if inviteEvent.Kind != nip43.KindInviteReq {
t.Errorf("wrong event kind: got %d, want %d", inviteEvent.Kind, nip43.KindInviteReq)
}
// Verify claim tag
claimTag := inviteEvent.Tags.GetFirst([]byte("claim"))
if claimTag == nil {
t.Fatal("missing claim tag")
}
if claimTag.Len() < 2 {
t.Fatal("claim tag has no value")
}
}
// TestHandleNIP43InviteRequest_Unauthorized tests invite request from non-admin
func TestHandleNIP43InviteRequest_Unauthorized(t *testing.T) {
listener, _, cleanup := setupTestListener(t)
defer cleanup()
// Generate regular user (not admin)
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Handle invite request - should fail
_, err = listener.Server.HandleNIP43InviteRequest(userPubkey)
if err == nil {
t.Fatal("expected error for unauthorized user")
}
}
// TestJoinAndLeaveFlow tests the complete join and leave flow
func TestJoinAndLeaveFlow(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
// Generate test user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Fatalf("failed to initialize signer: %v", err)
}
userPubkey := userSigner.Pub()
// Step 1: Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Step 2: User sends join request
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags = tag.NewS()
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(userSigner); err != nil {
t.Fatalf("failed to sign join event: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Verify user is member
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership after join: %v", err)
}
if !isMember {
t.Fatal("user is not a member after join")
}
// Step 3: User sends leave request
leaveEv := event.New()
leaveEv.Kind = nip43.KindLeaveRequest
copy(leaveEv.Pubkey, userPubkey)
leaveEv.Tags = tag.NewS()
leaveEv.Tags.Append(tag.NewFromAny("-"))
leaveEv.CreatedAt = time.Now().Unix()
leaveEv.Content = []byte("")
if err = leaveEv.Sign(userSigner); err != nil {
t.Fatalf("failed to sign leave event: %v", err)
}
err = listener.HandleNIP43LeaveRequest(leaveEv)
if err != nil {
t.Fatalf("failed to handle leave request: %v", err)
}
// Verify user is no longer member
isMember, err = db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership after leave: %v", err)
}
if isMember {
t.Fatal("user is still a member after leave")
}
}
// TestMultipleUsersJoining tests multiple users joining concurrently
func TestMultipleUsersJoining(t *testing.T) {
listener, db, cleanup := setupTestListener(t)
defer cleanup()
userCount := 10
done := make(chan bool, userCount)
for i := 0; i < userCount; i++ {
go func(index int) {
// Generate user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Errorf("failed to generate user secret %d: %v", index, err)
done <- false
return
}
userSigner, err := p8k.New()
if err != nil {
t.Errorf("failed to create signer %d: %v", index, err)
done <- false
return
}
if err = userSigner.InitSec(userSecret); err != nil {
t.Errorf("failed to initialize signer %d: %v", index, err)
done <- false
return
}
userPubkey := userSigner.Pub()
// Generate invite code
code, err := listener.Server.InviteManager.GenerateCode()
if err != nil {
t.Errorf("failed to generate invite code %d: %v", index, err)
done <- false
return
}
// Create join request
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags = tag.NewS()
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(userSigner); err != nil {
t.Errorf("failed to sign event %d: %v", index, err)
done <- false
return
}
// Handle join request
if err = listener.HandleNIP43JoinRequest(joinEv); err != nil {
t.Errorf("failed to handle join request %d: %v", index, err)
done <- false
return
}
done <- true
}(i)
}
// Wait for all goroutines
successCount := 0
for i := 0; i < userCount; i++ {
if <-done {
successCount++
}
}
if successCount != userCount {
t.Errorf("not all users joined successfully: %d/%d", successCount, userCount)
}
// Verify member count
members, err := db.GetAllNIP43Members()
if err != nil {
t.Fatalf("failed to get all members: %v", err)
}
if len(members) != successCount {
t.Errorf("wrong member count: got %d, want %d", len(members), successCount)
}
}

557
app/handle-nip86.go Normal file
View File

@@ -0,0 +1,557 @@
package app
import (
"encoding/json"
"io"
"net/http"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/protocol/httpauth"
)
// NIP86Request represents a NIP-86 JSON-RPC request
type NIP86Request struct {
Method string `json:"method"`
Params []interface{} `json:"params"`
}
// NIP86Response represents a NIP-86 JSON-RPC response
type NIP86Response struct {
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// handleNIP86Management handles NIP-86 management API requests
func (s *Server) handleNIP86Management(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check Content-Type
contentType := r.Header.Get("Content-Type")
if contentType != "application/nostr+json+rpc" {
http.Error(w, "Content-Type must be application/nostr+json+rpc", http.StatusBadRequest)
return
}
// Validate NIP-98 authentication
valid, pubkey, err := httpauth.CheckAuth(r)
if chk.E(err) || !valid {
errorMsg := "NIP-98 authentication validation failed"
if err != nil {
errorMsg = err.Error()
}
http.Error(w, errorMsg, http.StatusUnauthorized)
return
}
// Check permissions - require owner level only
accessLevel := acl.Registry.GetAccessLevel(pubkey, r.RemoteAddr)
if accessLevel != "owner" {
http.Error(w, "Owner permission required", http.StatusForbidden)
return
}
// Check if managed ACL is active
if acl.Registry.Type() != "managed" {
http.Error(w, "Managed ACL mode is not active", http.StatusBadRequest)
return
}
// Get the managed ACL instance
var managedACL *database.ManagedACL
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok {
managedACL = managed.GetManagedACL()
break
}
}
}
if managedACL == nil {
http.Error(w, "Managed ACL not available", http.StatusInternalServerError)
return
}
// Read and parse the request
body, err := io.ReadAll(r.Body)
if chk.E(err) {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
var request NIP86Request
if err := json.Unmarshal(body, &request); chk.E(err) {
http.Error(w, "Invalid JSON request", http.StatusBadRequest)
return
}
// Set response headers
w.Header().Set("Content-Type", "application/json")
// Handle the request based on method
response := s.handleNIP86Method(request, managedACL)
// Send response
jsonData, err := json.Marshal(response)
if chk.E(err) {
http.Error(w, "Error generating response", http.StatusInternalServerError)
return
}
w.Write(jsonData)
}
// handleNIP86Method handles individual NIP-86 methods
func (s *Server) handleNIP86Method(request NIP86Request, managedACL *database.ManagedACL) NIP86Response {
switch request.Method {
case "supportedmethods":
return s.handleSupportedMethods()
case "banpubkey":
return s.handleBanPubkey(request.Params, managedACL)
case "listbannedpubkeys":
return s.handleListBannedPubkeys(managedACL)
case "allowpubkey":
return s.handleAllowPubkey(request.Params, managedACL)
case "listallowedpubkeys":
return s.handleListAllowedPubkeys(managedACL)
case "listeventsneedingmoderation":
return s.handleListEventsNeedingModeration(managedACL)
case "allowevent":
return s.handleAllowEvent(request.Params, managedACL)
case "banevent":
return s.handleBanEvent(request.Params, managedACL)
case "listbannedevents":
return s.handleListBannedEvents(managedACL)
case "changerelayname":
return s.handleChangeRelayName(request.Params, managedACL)
case "changerelaydescription":
return s.handleChangeRelayDescription(request.Params, managedACL)
case "changerelayicon":
return s.handleChangeRelayIcon(request.Params, managedACL)
case "allowkind":
return s.handleAllowKind(request.Params, managedACL)
case "disallowkind":
return s.handleDisallowKind(request.Params, managedACL)
case "listallowedkinds":
return s.handleListAllowedKinds(managedACL)
case "blockip":
return s.handleBlockIP(request.Params, managedACL)
case "unblockip":
return s.handleUnblockIP(request.Params, managedACL)
case "listblockedips":
return s.handleListBlockedIPs(managedACL)
default:
return NIP86Response{Error: "Unknown method: " + request.Method}
}
}
// handleSupportedMethods returns the list of supported methods
func (s *Server) handleSupportedMethods() NIP86Response {
methods := []string{
"supportedmethods",
"banpubkey",
"listbannedpubkeys",
"allowpubkey",
"listallowedpubkeys",
"listeventsneedingmoderation",
"allowevent",
"banevent",
"listbannedevents",
"changerelayname",
"changerelaydescription",
"changerelayicon",
"allowkind",
"disallowkind",
"listallowedkinds",
"blockip",
"unblockip",
"listblockedips",
}
return NIP86Response{Result: methods}
}
// handleBanPubkey bans a public key
func (s *Server) handleBanPubkey(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
// Validate pubkey format
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format"}
}
reason := ""
if len(params) > 1 {
if r, ok := params[1].(string); ok {
reason = r
}
}
if err := managedACL.SaveBannedPubkey(pubkey, reason); chk.E(err) {
return NIP86Response{Error: "Failed to ban pubkey: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListBannedPubkeys returns the list of banned pubkeys
func (s *Server) handleListBannedPubkeys(managedACL *database.ManagedACL) NIP86Response {
banned, err := managedACL.ListBannedPubkeys()
if chk.E(err) {
return NIP86Response{Error: "Failed to list banned pubkeys: " + err.Error()}
}
// Convert to the expected format
result := make([]map[string]interface{}, len(banned))
for i, b := range banned {
result[i] = map[string]interface{}{
"pubkey": b.Pubkey,
"reason": b.Reason,
}
}
return NIP86Response{Result: result}
}
// handleAllowPubkey allows a public key
func (s *Server) handleAllowPubkey(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
// Validate pubkey format
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format"}
}
reason := ""
if len(params) > 1 {
if r, ok := params[1].(string); ok {
reason = r
}
}
if err := managedACL.SaveAllowedPubkey(pubkey, reason); chk.E(err) {
return NIP86Response{Error: "Failed to allow pubkey: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListAllowedPubkeys returns the list of allowed pubkeys
func (s *Server) handleListAllowedPubkeys(managedACL *database.ManagedACL) NIP86Response {
allowed, err := managedACL.ListAllowedPubkeys()
if chk.E(err) {
return NIP86Response{Error: "Failed to list allowed pubkeys: " + err.Error()}
}
// Convert to the expected format
result := make([]map[string]interface{}, len(allowed))
for i, a := range allowed {
result[i] = map[string]interface{}{
"pubkey": a.Pubkey,
"reason": a.Reason,
}
}
return NIP86Response{Result: result}
}
// handleListEventsNeedingModeration returns events needing moderation
func (s *Server) handleListEventsNeedingModeration(managedACL *database.ManagedACL) NIP86Response {
events, err := managedACL.ListEventsNeedingModeration()
if chk.E(err) {
return NIP86Response{Error: "Failed to list events needing moderation: " + err.Error()}
}
// Convert to the expected format
result := make([]map[string]interface{}, len(events))
for i, e := range events {
result[i] = map[string]interface{}{
"id": e.ID,
"reason": e.Reason,
}
}
return NIP86Response{Result: result}
}
// handleAllowEvent allows an event
func (s *Server) handleAllowEvent(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: event_id"}
}
eventID, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid event_id parameter"}
}
// Validate event ID format
if len(eventID) != 64 {
return NIP86Response{Error: "Invalid event_id format"}
}
reason := ""
if len(params) > 1 {
if r, ok := params[1].(string); ok {
reason = r
}
}
if err := managedACL.SaveAllowedEvent(eventID, reason); chk.E(err) {
return NIP86Response{Error: "Failed to allow event: " + err.Error()}
}
// Remove from moderation queue if it was there
managedACL.RemoveEventNeedingModeration(eventID)
return NIP86Response{Result: true}
}
// handleBanEvent bans an event
func (s *Server) handleBanEvent(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: event_id"}
}
eventID, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid event_id parameter"}
}
// Validate event ID format
if len(eventID) != 64 {
return NIP86Response{Error: "Invalid event_id format"}
}
reason := ""
if len(params) > 1 {
if r, ok := params[1].(string); ok {
reason = r
}
}
if err := managedACL.SaveBannedEvent(eventID, reason); chk.E(err) {
return NIP86Response{Error: "Failed to ban event: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListBannedEvents returns the list of banned events
func (s *Server) handleListBannedEvents(managedACL *database.ManagedACL) NIP86Response {
banned, err := managedACL.ListBannedEvents()
if chk.E(err) {
return NIP86Response{Error: "Failed to list banned events: " + err.Error()}
}
// Convert to the expected format
result := make([]map[string]interface{}, len(banned))
for i, b := range banned {
result[i] = map[string]interface{}{
"id": b.ID,
"reason": b.Reason,
}
}
return NIP86Response{Result: result}
}
// handleChangeRelayName changes the relay name
func (s *Server) handleChangeRelayName(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: name"}
}
name, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid name parameter"}
}
config, err := managedACL.GetRelayConfig()
if chk.E(err) {
return NIP86Response{Error: "Failed to get relay config: " + err.Error()}
}
config.RelayName = name
if err := managedACL.SaveRelayConfig(config); chk.E(err) {
return NIP86Response{Error: "Failed to save relay config: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleChangeRelayDescription changes the relay description
func (s *Server) handleChangeRelayDescription(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: description"}
}
description, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid description parameter"}
}
config, err := managedACL.GetRelayConfig()
if chk.E(err) {
return NIP86Response{Error: "Failed to get relay config: " + err.Error()}
}
config.RelayDescription = description
if err := managedACL.SaveRelayConfig(config); chk.E(err) {
return NIP86Response{Error: "Failed to save relay config: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleChangeRelayIcon changes the relay icon
func (s *Server) handleChangeRelayIcon(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: icon_url"}
}
iconURL, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid icon_url parameter"}
}
config, err := managedACL.GetRelayConfig()
if chk.E(err) {
return NIP86Response{Error: "Failed to get relay config: " + err.Error()}
}
config.RelayIcon = iconURL
if err := managedACL.SaveRelayConfig(config); chk.E(err) {
return NIP86Response{Error: "Failed to save relay config: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleAllowKind allows an event kind
func (s *Server) handleAllowKind(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: kind"}
}
kindFloat, ok := params[0].(float64)
if !ok {
return NIP86Response{Error: "Invalid kind parameter"}
}
kind := int(kindFloat)
if err := managedACL.SaveAllowedKind(kind); chk.E(err) {
return NIP86Response{Error: "Failed to allow kind: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleDisallowKind disallows an event kind
func (s *Server) handleDisallowKind(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: kind"}
}
kindFloat, ok := params[0].(float64)
if !ok {
return NIP86Response{Error: "Invalid kind parameter"}
}
kind := int(kindFloat)
if err := managedACL.RemoveAllowedKind(kind); chk.E(err) {
return NIP86Response{Error: "Failed to disallow kind: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListAllowedKinds returns the list of allowed kinds
func (s *Server) handleListAllowedKinds(managedACL *database.ManagedACL) NIP86Response {
kinds, err := managedACL.ListAllowedKinds()
if chk.E(err) {
return NIP86Response{Error: "Failed to list allowed kinds: " + err.Error()}
}
return NIP86Response{Result: kinds}
}
// handleBlockIP blocks an IP address
func (s *Server) handleBlockIP(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: ip"}
}
ip, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid ip parameter"}
}
reason := ""
if len(params) > 1 {
if r, ok := params[1].(string); ok {
reason = r
}
}
if err := managedACL.SaveBlockedIP(ip, reason); chk.E(err) {
return NIP86Response{Error: "Failed to block IP: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleUnblockIP unblocks an IP address
func (s *Server) handleUnblockIP(params []interface{}, managedACL *database.ManagedACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: ip"}
}
ip, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid ip parameter"}
}
if err := managedACL.RemoveBlockedIP(ip); chk.E(err) {
return NIP86Response{Error: "Failed to unblock IP: " + err.Error()}
}
return NIP86Response{Result: true}
}
// handleListBlockedIPs returns the list of blocked IPs
func (s *Server) handleListBlockedIPs(managedACL *database.ManagedACL) NIP86Response {
blocked, err := managedACL.ListBlockedIPs()
if chk.E(err) {
return NIP86Response{Error: "Failed to list blocked IPs: " + err.Error()}
}
// Convert to the expected format
result := make([]map[string]interface{}, len(blocked))
for i, b := range blocked {
result[i] = map[string]interface{}{
"ip": b.IP,
"reason": b.Reason,
}
}
return NIP86Response{Result: result}
}

View File

@@ -0,0 +1,121 @@
package app
import (
"bytes"
"context"
"encoding/json"
"net/http/httptest"
"testing"
"next.orly.dev/app/config"
"next.orly.dev/pkg/database"
)
func TestHandleNIP86Management_Basic(t *testing.T) {
// Setup test database
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Use a temporary directory for the test database
tmpDir := t.TempDir()
db, err := database.New(ctx, cancel, tmpDir, "test.db")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer db.Close()
// Setup non-managed ACL
cfg := &config.C{
AuthRequired: false,
Owners: []string{"owner1"},
Admins: []string{"admin1"},
ACLMode: "none",
}
// Setup server
server := &Server{
Config: cfg,
D: db,
Admins: [][]byte{[]byte("admin1")},
Owners: [][]byte{[]byte("owner1")},
}
t.Run("non-managed mode should reject management API", func(t *testing.T) {
// Create request body
body := map[string]interface{}{"method": "banpubkey", "params": []string{"user1", "test ban"}}
bodyBytes, err := json.Marshal(body)
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
// Create HTTP request without authentication to test the managed mode check
req := httptest.NewRequest("POST", "/api/nip86", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/nostr+json+rpc")
// Create response recorder
rr := httptest.NewRecorder()
// Call the handler
server.handleNIP86Management(rr, req)
// Check status code (should be 401 due to authentication failure, not 400)
if rr.Code != 401 {
t.Errorf("handleNIP86Management() status = %v, want 401", rr.Code)
}
// The test verifies that the handler runs and returns an error
if rr.Body.String() == "" {
t.Errorf("handleNIP86Management() body should not be empty")
}
})
t.Run("GET method should not be allowed", func(t *testing.T) {
// Create HTTP request
req := httptest.NewRequest("GET", "/api/nip86", nil)
// Create response recorder
rr := httptest.NewRecorder()
// Call the handler
server.handleNIP86Management(rr, req)
// Check status code
if rr.Code != 405 {
t.Errorf("handleNIP86Management() status = %v, want 405", rr.Code)
}
// Check error message (should contain "Method not allowed")
if rr.Body.String() == "" {
t.Errorf("handleNIP86Management() body should not be empty")
}
})
t.Run("unauthenticated request should be rejected", func(t *testing.T) {
// Create request body
body := map[string]interface{}{"method": "banpubkey", "params": []string{"user1", "test ban"}}
bodyBytes, err := json.Marshal(body)
if err != nil {
t.Fatalf("Failed to marshal request body: %v", err)
}
// Create HTTP request without authentication
req := httptest.NewRequest("POST", "/api/nip86", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/nostr+json+rpc")
// Create response recorder
rr := httptest.NewRecorder()
// Call the handler
server.handleNIP86Management(rr, req)
// Check status code
if rr.Code != 401 {
t.Errorf("handleNIP86Management() status = %v, want 401", rr.Code)
}
// Check error message (should be about missing authorization header)
if rr.Body.String() == "" {
t.Errorf("handleNIP86Management() body should not be empty")
}
})
}

View File

@@ -4,9 +4,13 @@ import (
"encoding/json"
"net/http"
"sort"
"strings"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/protocol/relayinfo"
"next.orly.dev/pkg/version"
)
@@ -29,51 +33,110 @@ func (s *Server) HandleRelayInfo(w http.ResponseWriter, r *http.Request) {
r.Header.Set("Content-Type", "application/json")
log.D.Ln("handling relay information document")
var info *relayinfo.T
supportedNIPs := relayinfo.GetList(
nips := []relayinfo.NIP{
relayinfo.BasicProtocol,
// relayinfo.Authentication,
// relayinfo.EncryptedDirectMessage,
relayinfo.Authentication,
relayinfo.EncryptedDirectMessage,
relayinfo.EventDeletion,
relayinfo.RelayInformationDocument,
// relayinfo.GenericTagQueries,
relayinfo.GenericTagQueries,
// relayinfo.NostrMarketplace,
relayinfo.CountingResults,
relayinfo.EventTreatment,
// relayinfo.CommandResults,
relayinfo.CommandResults,
relayinfo.ParameterizedReplaceableEvents,
// relayinfo.ExpirationTimestamp,
relayinfo.ExpirationTimestamp,
relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata,
)
relayinfo.SearchCapability,
}
// Add NIP-43 if enabled
if s.Config.NIP43Enabled {
nips = append(nips, relayinfo.RelayAccessMetadata)
}
supportedNIPs := relayinfo.GetList(nips...)
if s.Config.ACLMode != "none" {
supportedNIPs = relayinfo.GetList(
nipsACL := []relayinfo.NIP{
relayinfo.BasicProtocol,
relayinfo.Authentication,
// relayinfo.EncryptedDirectMessage,
relayinfo.EncryptedDirectMessage,
relayinfo.EventDeletion,
relayinfo.RelayInformationDocument,
// relayinfo.GenericTagQueries,
relayinfo.GenericTagQueries,
// relayinfo.NostrMarketplace,
relayinfo.CountingResults,
relayinfo.EventTreatment,
// relayinfo.CommandResults,
// relayinfo.ParameterizedReplaceableEvents,
// relayinfo.ExpirationTimestamp,
relayinfo.CommandResults,
relayinfo.ParameterizedReplaceableEvents,
relayinfo.ExpirationTimestamp,
relayinfo.ProtectedEvents,
relayinfo.RelayListMetadata,
)
relayinfo.SearchCapability,
}
// Add NIP-43 if enabled
if s.Config.NIP43Enabled {
nipsACL = append(nipsACL, relayinfo.RelayAccessMetadata)
}
supportedNIPs = relayinfo.GetList(nipsACL...)
}
sort.Sort(supportedNIPs)
log.T.Ln("supported NIPs", supportedNIPs)
log.I.Ln("supported NIPs", supportedNIPs)
// Get relay identity pubkey as hex
var relayPubkey string
if skb, err := s.D.GetRelayIdentitySecret(); err == nil && len(skb) == 32 {
var sign *p8k.Signer
var sigErr error
if sign, sigErr = p8k.New(); sigErr == nil {
if err := sign.InitSec(skb); err == nil {
relayPubkey = hex.Enc(sign.Pub())
}
}
}
// Default relay info
name := s.Config.AppName
description := version.Description + " dashboard: " + s.DashboardURL(r)
icon := "https://i.nostr.build/6wGXAn7Zaw9mHxFg.png"
// Override with managed ACL config if in managed mode
if s.Config.ACLMode == "managed" {
// Get managed ACL instance
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok {
managedACL := managed.GetManagedACL()
if managedACL != nil {
if config, err := managedACL.GetRelayConfig(); err == nil {
if config.RelayName != "" {
name = config.RelayName
}
if config.RelayDescription != "" {
description = config.RelayDescription
}
if config.RelayIcon != "" {
icon = config.RelayIcon
}
}
}
}
break
}
}
}
info = &relayinfo.T{
Name: s.Config.AppName,
Description: version.Description,
Name: name,
Description: description,
PubKey: relayPubkey,
Nips: supportedNIPs,
Software: version.URL,
Version: version.V,
Version: strings.TrimPrefix(version.V, "v"),
Limitation: relayinfo.Limits{
AuthRequired: s.Config.ACLMode != "none",
RestrictedWrites: s.Config.ACLMode != "none",
AuthRequired: s.Config.AuthRequired || s.Config.ACLMode != "none",
RestrictedWrites: s.Config.ACLMode != "managed" && s.Config.ACLMode != "none",
PaymentRequired: s.Config.MonthlyPriceSats > 0,
},
Icon: "https://cdn.satellite.earth/ac9778868fbf23b63c47c769a74e163377e6ea94d3f0f31711931663d035c4f6.png",
Icon: icon,
}
if err := json.NewEncoder(w).Encode(info); chk.E(err) {
}

View File

@@ -2,43 +2,57 @@ package app
import (
"context"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/envelopes/closedenvelope"
"next.orly.dev/pkg/encoders/envelopes/eoseenvelope"
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
"next.orly.dev/pkg/encoders/envelopes/okenvelope"
"next.orly.dev/pkg/encoders/envelopes/reqenvelope"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/hex"
hexenc "next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/reason"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/utils"
"next.orly.dev/pkg/utils/normalize"
"next.orly.dev/pkg/utils/pointers"
)
func (l *Listener) HandleReq(msg []byte) (err error) {
// log.T.F("HandleReq: START processing from %s\n%s\n", l.remote, msg)
log.D.F("handling REQ: %s", msg)
log.T.F("HandleReq: START processing from %s", l.remote)
// var rem []byte
env := reqenvelope.New()
if _, err = env.Unmarshal(msg); chk.E(err) {
// Provide more specific error context for JSON parsing failures
if strings.Contains(err.Error(), "invalid character") {
log.E.F("REQ JSON parsing failed from %s: %v", l.remote, err)
log.T.F("REQ malformed message from %s: %q", l.remote, string(msg))
return normalize.Error.Errorf("malformed REQ message: %s", err.Error())
}
return normalize.Error.Errorf(err.Error())
}
// if len(rem) > 0 {
// log.I.F("REQ extra bytes: '%s'", rem)
// }
// send a challenge to the client to auth if an ACL is active
if acl.Registry.Active.Load() != "none" {
log.T.C(
func() string {
return fmt.Sprintf(
"REQ sub=%s filters=%d", env.Subscription, len(*env.Filters),
)
},
)
// send a challenge to the client to auth if an ACL is active, auth is required, or AuthToWrite is enabled
if len(l.authedPubkey.Load()) == 0 && (acl.Registry.Active.Load() != "none" || l.Config.AuthRequired || l.Config.AuthToWrite) {
if err = authenvelope.NewChallengeWith(l.challenge.Load()).
Write(l); chk.E(err) {
return
@@ -46,117 +60,294 @@ func (l *Listener) HandleReq(msg []byte) (err error) {
}
// check permissions of user
accessLevel := acl.Registry.GetAccessLevel(l.authedPubkey.Load(), l.remote)
switch accessLevel {
case "none":
if err = okenvelope.NewFrom(
env.Subscription, false,
reason.AuthRequired.F("user not authed or has no read access"),
// If auth is required but user is not authenticated, deny access
if l.Config.AuthRequired && len(l.authedPubkey.Load()) == 0 {
if err = closedenvelope.NewFrom(
env.Subscription,
reason.AuthRequired.F("authentication required"),
).Write(l); chk.E(err) {
return
}
return
default:
// user has read access or better, continue
// log.D.F("user has %s access", accessLevel)
}
var events event.S
for _, f := range *env.Filters {
// idsLen := 0
// kindsLen := 0
// authorsLen := 0
// tagsLen := 0
// if f != nil {
// if f.Ids != nil {
// idsLen = f.Ids.Len()
// }
// if f.Kinds != nil {
// kindsLen = f.Kinds.Len()
// }
// if f.Authors != nil {
// authorsLen = f.Authors.Len()
// }
// if f.Tags != nil {
// tagsLen = f.Tags.Len()
// }
// }
// log.T.F(
// "REQ %s: filter summary ids=%d kinds=%d authors=%d tags=%d",
// env.Subscription, idsLen, kindsLen, authorsLen, tagsLen,
// )
if f != nil && f.Authors != nil && f.Authors.Len() > 0 {
var authors []string
for _, a := range f.Authors.T {
authors = append(authors, hex.Enc(a))
// If AuthToWrite is enabled, allow REQ without auth (but still check ACL)
// Skip the auth requirement check for REQ when AuthToWrite is true
if l.Config.AuthToWrite && len(l.authedPubkey.Load()) == 0 {
// Allow unauthenticated REQ when AuthToWrite is enabled
// but still respect ACL access levels if ACL is active
if acl.Registry.Active.Load() != "none" {
switch accessLevel {
case "none", "blocked", "banned":
if err = closedenvelope.NewFrom(
env.Subscription,
reason.AuthRequired.F("user not authed or has no read access"),
).Write(l); chk.E(err) {
return
}
return
}
}
// Allow the request to proceed without authentication
}
// Only check ACL access level if not already handled by AuthToWrite
if !l.Config.AuthToWrite || len(l.authedPubkey.Load()) > 0 {
switch accessLevel {
case "none":
// For REQ denial, send a CLOSED with auth-required reason (NIP-01)
if err = closedenvelope.NewFrom(
env.Subscription,
reason.AuthRequired.F("user not authed or has no read access"),
).Write(l); chk.E(err) {
return
}
return
default:
// user has read access or better, continue
}
}
// Handle NIP-43 invite request (kind 28935) - ephemeral event
// Check if any filter requests kind 28935
for _, f := range *env.Filters {
if f != nil && f.Kinds != nil {
if f.Kinds.Contains(nip43.KindInviteReq) {
// Generate and send invite event
inviteEvent, err := l.Server.HandleNIP43InviteRequest(l.authedPubkey.Load())
if err != nil {
log.W.F("failed to generate NIP-43 invite: %v", err)
// Send EOSE and return
if err = eoseenvelope.NewFrom(env.Subscription).Write(l); chk.E(err) {
return err
}
return nil
}
// Send the invite event
evEnv, _ := eventenvelope.NewResultWith(env.Subscription, inviteEvent)
if err = evEnv.Write(l); chk.E(err) {
return err
}
// Send EOSE
if err = eoseenvelope.NewFrom(env.Subscription).Write(l); chk.E(err) {
return err
}
log.I.F("sent NIP-43 invite event to %s", l.remote)
return nil
}
}
}
var events event.S
// Create a single context for all filter queries, isolated from the connection context
// to prevent query timeouts from affecting the long-lived websocket connection
queryCtx, queryCancel := context.WithTimeout(
context.Background(), 30*time.Second,
)
defer queryCancel()
// Collect all events from all filters
var allEvents event.S
for _, f := range *env.Filters {
if f != nil {
// Summarize filter details for diagnostics (avoid internal fields)
var kindsLen int
if f.Kinds != nil {
kindsLen = f.Kinds.Len()
}
var authorsLen int
if f.Authors != nil {
authorsLen = f.Authors.Len()
}
var idsLen int
if f.Ids != nil {
idsLen = f.Ids.Len()
}
var dtag string
if f.Tags != nil {
if d := f.Tags.GetFirst([]byte("d")); d != nil {
dtag = string(d.Value())
}
}
var lim any
if f.Limit != nil {
lim = *f.Limit
}
var since any
if f.Since != nil {
since = f.Since.Int()
}
var until any
if f.Until != nil {
until = f.Until.Int()
}
log.T.C(
func() string {
return fmt.Sprintf(
"REQ %s filter: kinds.len=%d authors.len=%d ids.len=%d d=%q limit=%v since=%v until=%v",
env.Subscription, kindsLen, authorsLen, idsLen, dtag,
lim, since, until,
)
},
)
// Process large author lists by breaking them into chunks
if f.Authors != nil && f.Authors.Len() > 1000 {
log.W.F("REQ %s: breaking down large author list (%d authors) into chunks", env.Subscription, f.Authors.Len())
// Calculate chunk size to stay under message size limits
// Each pubkey is 64 hex chars, plus JSON overhead, so ~100 bytes per author
// Target ~50MB per chunk to stay well under 100MB limit
chunkSize := ClientMessageSizeLimit / 200 // ~500KB per chunk
if f.Kinds != nil && f.Kinds.Len() > 0 {
// Reduce chunk size if there are multiple kinds to prevent too many index ranges
chunkSize = chunkSize / f.Kinds.Len()
if chunkSize < 100 {
chunkSize = 100 // Minimum chunk size
}
}
// Process authors in chunks
for i := 0; i < f.Authors.Len(); i += chunkSize {
end := i + chunkSize
if end > f.Authors.Len() {
end = f.Authors.Len()
}
// Create a chunk filter
chunkAuthors := tag.NewFromBytesSlice(f.Authors.T[i:end]...)
chunkFilter := &filter.F{
Kinds: f.Kinds,
Authors: chunkAuthors,
Ids: f.Ids,
Tags: f.Tags,
Since: f.Since,
Until: f.Until,
Limit: f.Limit,
Search: f.Search,
}
log.T.F("REQ %s: processing chunk %d-%d of %d authors", env.Subscription, i+1, end, f.Authors.Len())
// Process this chunk
var chunkEvents event.S
if chunkEvents, err = l.QueryEvents(queryCtx, chunkFilter); chk.E(err) {
if errors.Is(err, badger.ErrDBClosed) {
return
}
log.E.F("QueryEvents failed for chunk filter: %v", err)
err = nil
continue
}
// Add chunk results to overall results
allEvents = append(allEvents, chunkEvents...)
// Check if we've hit the limit
if f.Limit != nil && len(allEvents) >= int(*f.Limit) {
log.T.F("REQ %s: reached limit of %d events, stopping chunk processing", env.Subscription, *f.Limit)
break
}
}
// Skip the normal processing since we handled it in chunks
continue
}
// log.T.F("REQ %s: authors=%v", env.Subscription, authors)
}
// if f != nil && f.Kinds != nil && f.Kinds.Len() > 0 {
// log.T.F("REQ %s: kinds=%v", env.Subscription, f.Kinds.ToUint16())
// }
// if f != nil && f.Ids != nil && f.Ids.Len() > 0 {
// var ids []string
// for _, id := range f.Ids.T {
// ids = append(ids, hex.Enc(id))
// }
// // var lim any
// // if pointers.Present(f.Limit) {
// // lim = *f.Limit
// // } else {
// // lim = nil
// // }
// // log.T.F(
// // "REQ %s: ids filter count=%d ids=%v limit=%v", env.Subscription,
// // f.Ids.Len(), ids, lim,
// // )
// }
if f != nil && pointers.Present(f.Limit) {
if *f.Limit == 0 {
continue
}
}
// Use a separate context for QueryEvents to prevent cancellation issues
queryCtx, cancel := context.WithTimeout(
context.Background(), 30*time.Second,
)
defer cancel()
// log.T.F(
// "HandleReq: About to QueryEvents for %s, main context done: %v",
// l.remote, l.ctx.Err() != nil,
// )
if events, err = l.QueryEvents(queryCtx, f); chk.E(err) {
var filterEvents event.S
if filterEvents, err = l.QueryEvents(queryCtx, f); chk.E(err) {
if errors.Is(err, badger.ErrDBClosed) {
return
}
// log.T.F("HandleReq: QueryEvents error for %s: %v", l.remote, err)
log.E.F("QueryEvents failed for filter: %v", err)
err = nil
continue
}
defer func() {
for _, ev := range events {
ev.Free()
}
}()
// log.T.F(
// "HandleReq: QueryEvents completed for %s, found %d events",
// l.remote, len(events),
// )
// Append events from this filter to the overall collection
allEvents = append(allEvents, filterEvents...)
}
events = allEvents
defer func() {
for _, ev := range events {
ev.Free()
}
}()
var tmp event.S
privCheck:
for _, ev := range events {
if kind.IsPrivileged(ev.Kind) &&
accessLevel != "admin" { // admins can see all events
// log.T.C(
// func() string {
// return fmt.Sprintf(
// "checking privileged event %0x", ev.ID,
// )
// },
// )
// Check for private tag first
privateTags := ev.Tags.GetAll([]byte("private"))
if len(privateTags) > 0 && accessLevel != "admin" {
pk := l.authedPubkey.Load()
if pk == nil {
continue // no auth, can't access private events
}
// Convert authenticated pubkey to npub for comparison
authedNpub, err := bech32encoding.BinToNpub(pk)
if err != nil {
continue // couldn't convert pubkey, skip
}
// Check if authenticated npub is in any private tag
authorized := false
for _, privateTag := range privateTags {
authorizedNpubs := strings.Split(
string(privateTag.Value()), ",",
)
for _, npub := range authorizedNpubs {
if strings.TrimSpace(npub) == string(authedNpub) {
authorized = true
break
}
}
if authorized {
break
}
}
if !authorized {
continue // not authorized to see this private event
}
// Event has private tag and user is authorized - continue to privileged check
}
// Always filter privileged events based on kind, regardless of ACLMode
// Privileged events should only be sent to users who are authenticated and
// are either the event author or listed in p tags
if kind.IsPrivileged(ev.Kind) && accessLevel != "admin" { // admins can see all events
log.T.C(
func() string {
return fmt.Sprintf(
"checking privileged event %0x", ev.ID,
)
},
)
pk := l.authedPubkey.Load()
if pk == nil {
// Not authenticated - cannot see privileged events
log.T.C(
func() string {
return fmt.Sprintf(
"privileged event %s denied - not authenticated",
ev.ID,
)
},
)
continue
}
// Check if user is authorized to see this privileged event
authorized := false
if utils.FastEqual(ev.Pubkey, pk) {
authorized = true
log.T.C(
func() string {
return fmt.Sprintf(
@@ -165,71 +356,241 @@ privCheck:
)
},
)
} else {
// Check p tags
pTags := ev.Tags.GetAll([]byte("p"))
for _, pTag := range pTags {
var pt []byte
if pt, err = hexenc.Dec(string(pTag.Value())); chk.E(err) {
continue
}
if utils.FastEqual(pt, pk) {
authorized = true
log.T.C(
func() string {
return fmt.Sprintf(
"privileged event %s is for logged in pubkey %0x",
ev.ID, pk,
)
},
)
break
}
}
}
if authorized {
tmp = append(tmp, ev)
continue
} else {
log.T.C(
func() string {
return fmt.Sprintf(
"privileged event %s does not contain the logged in pubkey %0x",
ev.ID, pk,
)
},
)
}
pTags := ev.Tags.GetAll([]byte("p"))
for _, pTag := range pTags {
var pt []byte
if pt, err = hex.Dec(string(pTag.Value())); chk.E(err) {
continue
}
if utils.FastEqual(pt, pk) {
// log.T.C(
// func() string {
// return fmt.Sprintf(
// "privileged event %s is for logged in pubkey %0x",
// ev.ID, pk,
// )
// },
// )
tmp = append(tmp, ev)
continue privCheck
}
}
// log.T.C(
// func() string {
// return fmt.Sprintf(
// "privileged event %s does not contain the logged in pubkey %0x",
// ev.ID, pk,
// )
// },
// )
} else {
// Check if policy defines this event as privileged (even if not in hardcoded list)
// Policy check will handle this later, but we can skip it here if not authenticated
// to avoid unnecessary processing
if l.policyManager != nil && l.policyManager.Manager != nil && l.policyManager.Manager.IsEnabled() {
rule, hasRule := l.policyManager.Rules[int(ev.Kind)]
if hasRule && rule.Privileged && accessLevel != "admin" {
pk := l.authedPubkey.Load()
if pk == nil {
// Not authenticated - cannot see policy-privileged events
log.T.C(
func() string {
return fmt.Sprintf(
"policy-privileged event %s denied - not authenticated",
ev.ID,
)
},
)
continue
}
// Policy check will verify authorization later, but we need to check
// if user is party to the event here
authorized := false
if utils.FastEqual(ev.Pubkey, pk) {
authorized = true
} else {
// Check p tags
pTags := ev.Tags.GetAll([]byte("p"))
for _, pTag := range pTags {
var pt []byte
if pt, err = hexenc.Dec(string(pTag.Value())); chk.E(err) {
continue
}
if utils.FastEqual(pt, pk) {
authorized = true
break
}
}
}
if !authorized {
log.T.C(
func() string {
return fmt.Sprintf(
"policy-privileged event %s does not contain the logged in pubkey %0x",
ev.ID, pk,
)
},
)
continue
}
}
}
tmp = append(tmp, ev)
}
}
events = tmp
// Apply policy filtering for read access if policy is enabled
if l.policyManager != nil && l.policyManager.Manager != nil && l.policyManager.Manager.IsEnabled() {
var policyFilteredEvents event.S
for _, ev := range events {
allowed, policyErr := l.policyManager.CheckPolicy("read", ev, l.authedPubkey.Load(), l.remote)
if chk.E(policyErr) {
log.E.F("policy check failed for read: %v", policyErr)
// Default to allow on policy error
policyFilteredEvents = append(policyFilteredEvents, ev)
continue
}
if allowed {
policyFilteredEvents = append(policyFilteredEvents, ev)
} else {
log.D.F("policy filtered out event %0x for read access", ev.ID)
}
}
events = policyFilteredEvents
}
// Deduplicate events (in case chunk processing returned duplicates)
// Use events (already filtered for privileged/policy) instead of allEvents
if len(events) > 0 {
seen := make(map[string]struct{})
var deduplicatedEvents event.S
originalCount := len(events)
for _, ev := range events {
eventID := hexenc.Enc(ev.ID)
if _, exists := seen[eventID]; !exists {
seen[eventID] = struct{}{}
deduplicatedEvents = append(deduplicatedEvents, ev)
}
}
events = deduplicatedEvents
if originalCount != len(events) {
log.T.F("REQ %s: deduplicated %d events to %d unique events", env.Subscription, originalCount, len(events))
}
}
// Apply managed ACL filtering for read access if managed ACL is active
if acl.Registry.Active.Load() == "managed" {
var aclFilteredEvents event.S
for _, ev := range events {
// Check if event is banned
eventID := hex.EncodeToString(ev.ID)
if banned, err := l.getManagedACL().IsEventBanned(eventID); err == nil && banned {
log.D.F("managed ACL filtered out banned event %s", hexenc.Enc(ev.ID))
continue
}
// Check if event author is banned
authorHex := hex.EncodeToString(ev.Pubkey)
if banned, err := l.getManagedACL().IsPubkeyBanned(authorHex); err == nil && banned {
log.D.F("managed ACL filtered out event %s from banned pubkey %s", hexenc.Enc(ev.ID), authorHex)
continue
}
// Check if event kind is allowed (only if allowed kinds are configured)
if allowed, err := l.getManagedACL().IsKindAllowed(int(ev.Kind)); err == nil && !allowed {
allowedKinds, err := l.getManagedACL().ListAllowedKinds()
if err == nil && len(allowedKinds) > 0 {
log.D.F("managed ACL filtered out event %s with disallowed kind %d", hexenc.Enc(ev.ID), ev.Kind)
continue
}
}
aclFilteredEvents = append(aclFilteredEvents, ev)
}
events = aclFilteredEvents
}
// Apply private tag filtering - only show events with "private" tags to authorized users
var privateFilteredEvents event.S
authedPubkey := l.authedPubkey.Load()
for _, ev := range events {
// Check if event has private tags
hasPrivateTag := false
var privatePubkey []byte
if ev.Tags != nil && ev.Tags.Len() > 0 {
for _, t := range *ev.Tags {
if t.Len() >= 2 {
keyBytes := t.Key()
if len(keyBytes) == 7 && string(keyBytes) == "private" {
hasPrivateTag = true
privatePubkey = t.Value()
break
}
}
}
}
// If no private tag, include the event
if !hasPrivateTag {
privateFilteredEvents = append(privateFilteredEvents, ev)
continue
}
// Event has private tag - check if user is authorized to see it
canSeePrivate := l.canSeePrivateEvent(authedPubkey, privatePubkey)
if canSeePrivate {
privateFilteredEvents = append(privateFilteredEvents, ev)
log.D.F("private tag: allowing event %s for authorized user", hexenc.Enc(ev.ID))
} else {
log.D.F("private tag: filtering out event %s from unauthorized user", hexenc.Enc(ev.ID))
}
}
events = privateFilteredEvents
seen := make(map[string]struct{})
for _, ev := range events {
// log.D.C(
// func() string {
// return fmt.Sprintf(
// "REQ %s: sending EVENT id=%s kind=%d", env.Subscription,
// hex.Enc(ev.ID), ev.Kind,
// )
// },
// )
// log.T.C(
// func() string {
// return fmt.Sprintf("event:\n%s\n", ev.Serialize())
// },
// )
log.T.C(
func() string {
return fmt.Sprintf(
"REQ %s: sending EVENT id=%s kind=%d", env.Subscription,
hexenc.Enc(ev.ID), ev.Kind,
)
},
)
log.T.C(
func() string {
return fmt.Sprintf("event:\n%s\n", ev.Serialize())
},
)
var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith(
env.Subscription, ev,
); chk.E(err) {
return
}
if err = res.Write(l); chk.E(err) {
if err = res.Write(l); err != nil {
// Don't log context canceled errors as they're expected during shutdown
if !strings.Contains(err.Error(), "context canceled") {
chk.E(err)
}
return
}
// track the IDs we've sent (use hex encoding for stable key)
seen[hex.Enc(ev.ID)] = struct{}{}
seen[hexenc.Enc(ev.ID)] = struct{}{}
}
// write the EOSE to signal to the client that all events found have been
// sent.
// log.T.F("sending EOSE to %s", l.remote)
log.T.F("sending EOSE to %s", l.remote)
if err = eoseenvelope.NewFrom(env.Subscription).
Write(l); chk.E(err) {
return
@@ -237,64 +598,137 @@ privCheck:
// if the query was for just Ids, we know there can't be any more results,
// so cancel the subscription.
cancel := true
// log.T.F(
// "REQ %s: computing cancel/subscription; events_sent=%d",
// env.Subscription, len(events),
// )
log.T.F(
"REQ %s: computing cancel/subscription; events_sent=%d",
env.Subscription, len(events),
)
var subbedFilters filter.S
for _, f := range *env.Filters {
// Check if this filter's limit was satisfied
limitSatisfied := false
if pointers.Present(f.Limit) {
if len(events) >= int(*f.Limit) {
limitSatisfied = true
}
}
if f.Ids.Len() < 1 {
cancel = false
subbedFilters = append(subbedFilters, f)
// Filter has no IDs - keep subscription open unless limit was satisfied
if !limitSatisfied {
cancel = false
subbedFilters = append(subbedFilters, f)
}
} else {
// remove the IDs that we already sent
// remove the IDs that we already sent, as it's one less
// comparison we have to make.
var notFounds [][]byte
for _, id := range f.Ids.T {
if _, ok := seen[hex.Enc(id)]; ok {
if _, ok := seen[hexenc.Enc(id)]; ok {
continue
}
notFounds = append(notFounds, id)
}
// log.T.F(
// "REQ %s: ids outstanding=%d of %d", env.Subscription,
// len(notFounds), f.Ids.Len(),
// )
log.T.F(
"REQ %s: ids outstanding=%d of %d", env.Subscription,
len(notFounds), f.Ids.Len(),
)
// if all were found, don't add to subbedFilters
if len(notFounds) == 0 {
continue
}
// Check if limit was satisfied
if limitSatisfied {
continue
}
// rewrite the filter Ids to remove the ones we already sent
f.Ids = tag.NewFromBytesSlice(notFounds...)
// add the filter to the list of filters we're subscribing to
cancel = false
subbedFilters = append(subbedFilters, f)
}
// also, if we received the limit number of events, subscription ded
if pointers.Present(f.Limit) {
if len(events) < int(*f.Limit) {
cancel = false
}
}
}
receiver := make(event.C, 32)
// if the subscription should be cancelled, do so
if !cancel {
// Create a dedicated context for this subscription that's independent of query context
// but is child of the listener context so it gets cancelled when connection closes
subCtx, subCancel := context.WithCancel(l.ctx)
// Track this subscription so we can cancel it on CLOSE or connection close
subID := string(env.Subscription)
l.subscriptionsMu.Lock()
l.subscriptions[subID] = subCancel
l.subscriptionsMu.Unlock()
// Register subscription with publisher
// Set AuthRequired based on ACL mode - when ACL is "none", don't require auth for privileged events
authRequired := acl.Registry.Active.Load() != "none"
l.publishers.Receive(
&W{
Conn: l.conn,
remote: l.remote,
Id: string(env.Subscription),
Id: subID,
Receiver: receiver,
Filters: env.Filters,
Filters: &subbedFilters,
AuthedPubkey: l.authedPubkey.Load(),
AuthRequired: authRequired,
},
)
// Launch goroutine to consume from receiver channel and forward to client
// This is the critical missing piece - without this, the receiver channel fills up
// and the publisher times out trying to send, causing subscription to be removed
go func() {
defer func() {
// Clean up when subscription ends
l.subscriptionsMu.Lock()
delete(l.subscriptions, subID)
l.subscriptionsMu.Unlock()
log.D.F("subscription goroutine exiting for %s @ %s", subID, l.remote)
}()
for {
select {
case <-subCtx.Done():
// Subscription cancelled (CLOSE message or connection closing)
log.D.F("subscription %s cancelled for %s", subID, l.remote)
return
case ev, ok := <-receiver:
if !ok {
// Channel closed - subscription ended
log.D.F("subscription %s receiver channel closed for %s", subID, l.remote)
return
}
// Forward event to client via write channel
var res *eventenvelope.Result
var err error
if res, err = eventenvelope.NewResultWith(subID, ev); chk.E(err) {
log.E.F("failed to create event envelope for subscription %s: %v", subID, err)
continue
}
// Write to client - this goes through the write worker
if err = res.Write(l); err != nil {
if !strings.Contains(err.Error(), "context canceled") {
log.E.F("failed to write event to subscription %s @ %s: %v", subID, l.remote, err)
}
// Don't return here - write errors shouldn't kill the subscription
// The connection cleanup will handle removing the subscription
continue
}
log.D.F("delivered real-time event %s to subscription %s @ %s",
hexenc.Enc(ev.ID), subID, l.remote)
}
}
}()
log.D.F("subscription %s created and goroutine launched for %s", subID, l.remote)
} else {
if err = closedenvelope.NewFrom(
env.Subscription, nil,
).Write(l); chk.E(err) {
return
}
// suppress server-sent CLOSED; client will close subscription if desired
log.D.F("subscription request cancelled immediately (all IDs found or limit satisfied)")
}
// log.T.F("HandleReq: COMPLETED processing from %s", l.remote)
log.T.F("HandleReq: COMPLETED processing from %s", l.remote)
return
}

View File

@@ -7,11 +7,12 @@ import (
"strings"
"time"
"github.com/coder/websocket"
"github.com/gorilla/websocket"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/envelopes/authenvelope"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/utils/units"
)
@@ -19,27 +20,26 @@ const (
DefaultWriteWait = 10 * time.Second
DefaultPongWait = 60 * time.Second
DefaultPingWait = DefaultPongWait / 2
DefaultReadTimeout = 7 * time.Second // Read timeout to detect stalled connections
DefaultWriteTimeout = 3 * time.Second
DefaultMaxMessageSize = 1 * units.Mb
// CloseMessage denotes a close control message. The optional message
// payload contains a numeric code and text. Use the FormatCloseMessage
// function to format a close message payload.
CloseMessage = 8
// PingMessage denotes a ping control message. The optional message payload
// is UTF-8 encoded text.
PingMessage = 9
// PongMessage denotes a pong control message. The optional message payload
// is UTF-8 encoded text.
PongMessage = 10
DefaultMaxMessageSize = 512000 // Match khatru's MaxMessageSize
// ClientMessageSizeLimit is the maximum message size that clients can handle
// This is set to 100MB to allow large messages
ClientMessageSizeLimit = 100 * 1024 * 1024 // 100MB
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for proxy compatibility
},
}
func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
remote := GetRemoteFromReq(r)
log.T.F("handling websocket connection from %s", remote)
// Log comprehensive proxy information for debugging
LogProxyInfo(r, "WebSocket connection from "+remote)
if len(s.Config.IPWhitelist) > 0 {
for _, ip := range s.Config.IPWhitelist {
log.T.F("checking IP whitelist: %s", ip)
@@ -52,42 +52,138 @@ func (s *Server) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
return
}
whitelist:
// Create an independent context for this connection
// This context will be cancelled when the connection closes or server shuts down
ctx, cancel := context.WithCancel(s.Ctx)
defer cancel()
var err error
var conn *websocket.Conn
if conn, err = websocket.Accept(
w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}},
); chk.E(err) {
// Configure upgrader for this connection
upgrader.ReadBufferSize = int(DefaultMaxMessageSize)
upgrader.WriteBufferSize = int(DefaultMaxMessageSize)
if conn, err = upgrader.Upgrade(w, r, nil); chk.E(err) {
log.E.F("websocket accept failed from %s: %v", remote, err)
return
}
log.T.F("websocket accepted from %s path=%s", remote, r.URL.String())
// Set read limit immediately after connection is established
conn.SetReadLimit(DefaultMaxMessageSize)
defer conn.CloseNow()
log.D.F("set read limit to %d bytes (%d MB) for %s", DefaultMaxMessageSize, DefaultMaxMessageSize/units.Mb, remote)
// Set initial read deadline - pong handler will extend it when pongs are received
conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
// Add pong handler to extend read deadline when client responds to pings
conn.SetPongHandler(func(string) error {
log.T.F("received PONG from %s, extending read deadline", remote)
return conn.SetReadDeadline(time.Now().Add(DefaultPongWait))
})
defer conn.Close()
listener := &Listener{
ctx: ctx,
Server: s,
conn: conn,
remote: remote,
req: r,
ctx: ctx,
cancel: cancel,
Server: s,
conn: conn,
remote: remote,
req: r,
startTime: time.Now(),
writeChan: make(chan publish.WriteRequest, 100), // Buffered channel for writes
writeDone: make(chan struct{}),
messageQueue: make(chan messageRequest, 100), // Buffered channel for message processing
processingDone: make(chan struct{}),
subscriptions: make(map[string]context.CancelFunc),
}
// Start write worker goroutine
go listener.writeWorker()
// Start message processor goroutine
go listener.messageProcessor()
// Register write channel with publisher
if socketPub := listener.publishers.GetSocketPublisher(); socketPub != nil {
socketPub.SetWriteChan(conn, listener.writeChan)
}
// Check for blacklisted IPs
listener.isBlacklisted = s.isIPBlacklisted(remote)
if listener.isBlacklisted {
log.W.F("detected blacklisted IP %s, marking connection for timeout", remote)
listener.blacklistTimeout = time.Now().Add(time.Minute) // Timeout after 1 minute
}
chal := make([]byte, 32)
rand.Read(chal)
listener.challenge.Store([]byte(hex.Enc(chal)))
// If admins are configured, immediately prompt client to AUTH (NIP-42)
if len(s.Config.Admins) > 0 {
// log.D.F("sending initial AUTH challenge to %s", remote)
if s.Config.ACLMode != "none" {
log.D.F("sending AUTH challenge to %s", remote)
if err = authenvelope.NewChallengeWith(listener.challenge.Load()).
Write(listener); chk.E(err) {
log.E.F("failed to send AUTH challenge to %s: %v", remote, err)
return
}
log.D.F("AUTH challenge sent successfully to %s", remote)
}
ticker := time.NewTicker(DefaultPingWait)
go s.Pinger(ctx, conn, ticker, cancel)
// Don't pass cancel to Pinger - it should not be able to cancel the connection context
go s.Pinger(ctx, listener, ticker)
defer func() {
// log.D.F("closing websocket connection from %s", remote)
log.D.F("closing websocket connection from %s", remote)
// Cancel all active subscriptions first
listener.subscriptionsMu.Lock()
for subID, cancelFunc := range listener.subscriptions {
log.D.F("cancelling subscription %s for %s", subID, remote)
cancelFunc()
}
listener.subscriptions = nil
listener.subscriptionsMu.Unlock()
// Cancel context and stop pinger
cancel()
ticker.Stop()
listener.publishers.Receive(&W{Cancel: true})
// Cancel all subscriptions for this connection at publisher level
log.D.F("removing subscriptions from publisher for %s", remote)
listener.publishers.Receive(&W{
Cancel: true,
Conn: listener.conn,
remote: listener.remote,
})
// Log detailed connection statistics
dur := time.Since(listener.startTime)
log.D.F(
"ws connection closed %s: msgs=%d, REQs=%d, EVENTs=%d, dropped=%d, duration=%v",
remote, listener.msgCount, listener.reqCount, listener.eventCount,
listener.DroppedMessages(), dur,
)
// Log any remaining connection state
if listener.authedPubkey.Load() != nil {
log.D.F("ws connection %s was authenticated", remote)
} else {
log.D.F("ws connection %s was not authenticated", remote)
}
// Close message queue to signal processor to exit
close(listener.messageQueue)
// Wait for message processor to finish
<-listener.processingDone
// Wait for all spawned message handlers to complete
// This is critical to prevent "send on closed channel" panics
log.D.F("ws->%s waiting for message handlers to complete", remote)
listener.handlerWg.Wait()
log.D.F("ws->%s all message handlers completed", remote)
// Close write channel to signal worker to exit
close(listener.writeChan)
// Wait for write worker to finish
<-listener.writeDone
}()
for {
select {
@@ -95,89 +191,92 @@ whitelist:
return
default:
}
var typ websocket.MessageType
var msg []byte
// log.T.F("waiting for message from %s", remote)
// Create a read context with timeout to prevent indefinite blocking
readCtx, readCancel := context.WithTimeout(ctx, DefaultReadTimeout)
typ, msg, err = conn.Read(readCtx)
readCancel()
if err != nil {
if strings.Contains(
err.Error(), "use of closed network connection",
) {
return
}
// Handle timeout errors - occurs when client becomes unresponsive
if strings.Contains(err.Error(), "context deadline exceeded") {
log.T.F(
"connection from %s timed out after %v", remote,
DefaultReadTimeout,
)
return
}
// Handle EOF errors gracefully - these occur when client closes connection
// or sends incomplete/malformed WebSocket frames
if strings.Contains(err.Error(), "EOF") ||
strings.Contains(err.Error(), "failed to read frame header") {
log.T.F("connection from %s closed: %v", remote, err)
return
}
status := websocket.CloseStatus(err)
switch status {
case websocket.StatusNormalClosure,
websocket.StatusGoingAway,
websocket.StatusNoStatusRcvd,
websocket.StatusAbnormalClosure,
websocket.StatusProtocolError:
log.T.F(
"connection from %s closed with status: %v", remote, status,
)
default:
log.E.F("unexpected close error from %s: %v", remote, err)
}
// Check if blacklisted connection has timed out
if listener.isBlacklisted && time.Now().After(listener.blacklistTimeout) {
log.W.F("blacklisted IP %s timeout reached, closing connection", remote)
return
}
if typ == PingMessage {
// Create a write context with timeout for pong response
writeCtx, writeCancel := context.WithTimeout(
ctx, DefaultWriteTimeout,
)
if err = conn.Write(writeCtx, PongMessage, msg); chk.E(err) {
writeCancel()
var typ int
var msg []byte
log.T.F("waiting for message from %s", remote)
// Don't set read deadline here - it's set initially and extended by pong handler
// This prevents premature timeouts on idle connections with active subscriptions
if ctx.Err() != nil {
return
}
// Block waiting for message; rely on pings and context cancellation to detect dead peers
// The read deadline is managed by the pong handler which extends it when pongs are received
typ, msg, err = conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseNormalClosure, // 1000
websocket.CloseGoingAway, // 1001
websocket.CloseNoStatusReceived, // 1005
websocket.CloseAbnormalClosure, // 1006
4537, // some client seems to send many of these
) {
log.I.F("websocket connection closed from %s: %v", remote, err)
}
cancel() // Cancel context like khatru does
return
}
if typ == websocket.PingMessage {
log.D.F("received PING from %s, sending PONG", remote)
// Send pong directly (like khatru does)
if err = conn.WriteMessage(websocket.PongMessage, nil); err != nil {
log.E.F("failed to send PONG to %s: %v", remote, err)
return
}
writeCancel()
continue
}
// Log message size for debugging
if len(msg) > 1000 { // Only log for larger messages
log.D.F("received large message from %s: %d bytes", remote, len(msg))
}
// log.T.F("received message from %s: %s", remote, string(msg))
go listener.HandleMessage(msg, remote)
// Queue message for asynchronous processing
if !listener.QueueMessage(msg, remote) {
log.W.F("ws->%s message queue full, dropping message (capacity=%d)", remote, cap(listener.messageQueue))
}
}
}
func (s *Server) Pinger(
ctx context.Context, conn *websocket.Conn, ticker *time.Ticker,
cancel context.CancelFunc,
ctx context.Context, listener *Listener, ticker *time.Ticker,
) {
defer func() {
cancel()
log.D.F("pinger shutting down")
ticker.Stop()
// Recover from panic if channel is closed
if r := recover(); r != nil {
log.D.F("pinger recovered from panic (channel likely closed): %v", r)
}
}()
var err error
pingCount := 0
for {
select {
case <-ctx.Done():
log.T.F("pinger context cancelled after %d pings", pingCount)
return
case <-ticker.C:
// Create a write context with timeout for ping operation
pingCtx, pingCancel := context.WithTimeout(ctx, DefaultWriteTimeout)
if err = conn.Ping(pingCtx); chk.E(err) {
pingCancel()
pingCount++
// Send ping request through write channel - this allows pings to interrupt other writes
select {
case <-ctx.Done():
return
case listener.writeChan <- publish.WriteRequest{IsPing: true, MsgType: pingCount}:
// Ping request queued successfully
case <-time.After(DefaultWriteTimeout):
log.E.F("ping #%d channel timeout - connection may be overloaded", pingCount)
return
}
pingCancel()
case <-ctx.Done():
return
}
}
}

View File

@@ -3,6 +3,8 @@ package app
import (
"net/http"
"strings"
"lol.mleku.dev/log"
)
// GetRemoteFromReq retrieves the originating IP address of the client from
@@ -67,3 +69,28 @@ func GetRemoteFromReq(r *http.Request) (rr string) {
}
return
}
// LogProxyInfo logs comprehensive proxy information for debugging
func LogProxyInfo(r *http.Request, prefix string) {
proxyHeaders := map[string]string{
"X-Forwarded-For": r.Header.Get("X-Forwarded-For"),
"X-Real-IP": r.Header.Get("X-Real-IP"),
"X-Forwarded-Proto": r.Header.Get("X-Forwarded-Proto"),
"X-Forwarded-Host": r.Header.Get("X-Forwarded-Host"),
"X-Forwarded-Port": r.Header.Get("X-Forwarded-Port"),
"Forwarded": r.Header.Get("Forwarded"),
"Host": r.Header.Get("Host"),
"User-Agent": r.Header.Get("User-Agent"),
}
var info []string
for header, value := range proxyHeaders {
if value != "" {
info = append(info, header+":"+value)
}
}
if len(info) > 0 {
log.T.F("%s proxy info: %s", prefix, strings.Join(info, " "))
}
}

View File

@@ -3,20 +3,55 @@ package app
import (
"context"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"lol.mleku.dev/chk"
"next.orly.dev/pkg/utils/atomic"
"github.com/gorilla/websocket"
"lol.mleku.dev/errorf"
"lol.mleku.dev/log"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/utils"
atomicutils "next.orly.dev/pkg/utils/atomic"
)
type Listener struct {
*Server
conn *websocket.Conn
ctx context.Context
remote string
req *http.Request
challenge atomic.Bytes
authedPubkey atomic.Bytes
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc // Cancel function for this listener's context
remote string
req *http.Request
challenge atomicutils.Bytes
authedPubkey atomicutils.Bytes
startTime time.Time
isBlacklisted bool // Marker to identify blacklisted IPs
blacklistTimeout time.Time // When to timeout blacklisted connections
writeChan chan publish.WriteRequest // Channel for write requests (back to queued approach)
writeDone chan struct{} // Closed when write worker exits
// Message processing queue for async handling
messageQueue chan messageRequest // Buffered channel for message processing
processingDone chan struct{} // Closed when message processor exits
handlerWg sync.WaitGroup // Tracks spawned message handler goroutines
// Flow control counters (atomic for concurrent access)
droppedMessages atomic.Int64 // Messages dropped due to full queue
// Diagnostics: per-connection counters
msgCount int
reqCount int
eventCount int
// Subscription tracking for cleanup
subscriptions map[string]context.CancelFunc // Map of subscription ID to cancel function
subscriptionsMu sync.Mutex // Protects subscriptions map
}
type messageRequest struct {
data []byte
remote string
}
// Ctx returns the listener's context, but creates a new context for each operation
@@ -25,17 +60,211 @@ func (l *Listener) Ctx() context.Context {
return l.ctx
}
func (l *Listener) Write(p []byte) (n int, err error) {
// Use a separate context with timeout for writes to prevent race conditions
// where the main connection context gets cancelled while writing events
writeCtx, cancel := context.WithTimeout(
context.Background(), DefaultWriteTimeout,
)
defer cancel()
if err = l.conn.Write(writeCtx, websocket.MessageText, p); chk.E(err) {
return
}
n = len(p)
return
// DroppedMessages returns the total number of messages that were dropped
// because the message processing queue was full.
func (l *Listener) DroppedMessages() int {
return int(l.droppedMessages.Load())
}
// RemainingCapacity returns the number of slots available in the message processing queue.
func (l *Listener) RemainingCapacity() int {
return cap(l.messageQueue) - len(l.messageQueue)
}
// QueueMessage queues a message for asynchronous processing.
// Returns true if the message was queued, false if the queue was full.
func (l *Listener) QueueMessage(data []byte, remote string) bool {
req := messageRequest{data: data, remote: remote}
select {
case l.messageQueue <- req:
return true
default:
l.droppedMessages.Add(1)
return false
}
}
func (l *Listener) Write(p []byte) (n int, err error) {
// Defensive: recover from any panic when sending to closed channel
defer func() {
if r := recover(); r != nil {
log.D.F("ws->%s write panic recovered (channel likely closed): %v", l.remote, r)
err = errorf.E("write channel closed")
n = 0
}
}()
// Send write request to channel - non-blocking with timeout
select {
case <-l.ctx.Done():
return 0, l.ctx.Err()
case l.writeChan <- publish.WriteRequest{Data: p, MsgType: websocket.TextMessage, IsControl: false}:
return len(p), nil
case <-time.After(DefaultWriteTimeout):
log.E.F("ws->%s write channel timeout", l.remote)
return 0, errorf.E("write channel timeout")
}
}
// WriteControl sends a control message through the write channel
func (l *Listener) WriteControl(messageType int, data []byte, deadline time.Time) (err error) {
// Defensive: recover from any panic when sending to closed channel
defer func() {
if r := recover(); r != nil {
log.D.F("ws->%s writeControl panic recovered (channel likely closed): %v", l.remote, r)
err = errorf.E("write channel closed")
}
}()
select {
case <-l.ctx.Done():
return l.ctx.Err()
case l.writeChan <- publish.WriteRequest{Data: data, MsgType: messageType, IsControl: true, Deadline: deadline}:
return nil
case <-time.After(DefaultWriteTimeout):
log.E.F("ws->%s writeControl channel timeout", l.remote)
return errorf.E("writeControl channel timeout")
}
}
// writeWorker is the single goroutine that handles all writes to the websocket connection.
// This serializes all writes to prevent concurrent write panics and allows pings to interrupt writes.
func (l *Listener) writeWorker() {
defer func() {
// Only unregister write channel if connection is actually dead/closing
// Unregister if:
// 1. Context is cancelled (connection closing)
// 2. Channel was closed (connection closing)
// 3. Connection error occurred (already handled inline)
if l.ctx.Err() != nil {
// Connection is closing - safe to unregister
if socketPub := l.publishers.GetSocketPublisher(); socketPub != nil {
log.D.F("ws->%s write worker: unregistering write channel (connection closing)", l.remote)
socketPub.SetWriteChan(l.conn, nil)
}
} else {
// Exiting for other reasons (timeout, etc.) but connection may still be valid
log.D.F("ws->%s write worker exiting unexpectedly", l.remote)
}
close(l.writeDone)
}()
for {
select {
case <-l.ctx.Done():
log.D.F("ws->%s write worker context cancelled", l.remote)
return
case req, ok := <-l.writeChan:
if !ok {
log.D.F("ws->%s write channel closed", l.remote)
return
}
// Handle the write request
var err error
if req.IsPing {
// Special handling for ping messages
log.D.F("sending PING #%d", req.MsgType)
deadline := time.Now().Add(DefaultWriteTimeout)
err = l.conn.WriteControl(websocket.PingMessage, nil, deadline)
if err != nil {
if !strings.HasSuffix(err.Error(), "use of closed network connection") {
log.E.F("error writing ping: %v; closing websocket", err)
}
return
}
} else if req.IsControl {
// Control message
err = l.conn.WriteControl(req.MsgType, req.Data, req.Deadline)
if err != nil {
log.E.F("ws->%s control write failed: %v", l.remote, err)
return
}
} else {
// Regular message
l.conn.SetWriteDeadline(time.Now().Add(DefaultWriteTimeout))
err = l.conn.WriteMessage(req.MsgType, req.Data)
if err != nil {
log.E.F("ws->%s write failed: %v", l.remote, err)
return
}
}
}
}
}
// messageProcessor is the goroutine that processes messages asynchronously.
// This prevents the websocket read loop from blocking on message processing.
func (l *Listener) messageProcessor() {
defer func() {
close(l.processingDone)
}()
for {
select {
case <-l.ctx.Done():
log.D.F("ws->%s message processor context cancelled", l.remote)
return
case req, ok := <-l.messageQueue:
if !ok {
log.D.F("ws->%s message queue closed", l.remote)
return
}
// Process the message in a separate goroutine to avoid blocking
// This allows multiple messages to be processed concurrently (like khatru does)
// Track the goroutine so we can wait for it during cleanup
l.handlerWg.Add(1)
go func(data []byte, remote string) {
defer l.handlerWg.Done()
l.HandleMessage(data, remote)
}(req.data, req.remote)
}
}
}
// getManagedACL returns the managed ACL instance if available
func (l *Listener) getManagedACL() *database.ManagedACL {
// Get the managed ACL instance from the ACL registry
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == "managed" {
if managed, ok := aclInstance.(*acl.Managed); ok {
return managed.GetManagedACL()
}
}
}
return nil
}
// QueryEvents queries events using the database QueryEvents method
func (l *Listener) QueryEvents(ctx context.Context, f *filter.F) (event.S, error) {
return l.D.QueryEvents(ctx, f)
}
// QueryAllVersions queries events using the database QueryAllVersions method
func (l *Listener) QueryAllVersions(ctx context.Context, f *filter.F) (event.S, error) {
return l.D.QueryAllVersions(ctx, f)
}
// canSeePrivateEvent checks if the authenticated user can see an event with a private tag
func (l *Listener) canSeePrivateEvent(authedPubkey, privatePubkey []byte) (canSee bool) {
// If no authenticated user, deny access
if len(authedPubkey) == 0 {
return false
}
// If the authenticated user matches the private tag pubkey, allow access
if len(privatePubkey) > 0 && utils.FastEqual(authedPubkey, privatePubkey) {
return true
}
// Check if user is an admin or owner (they can see all private events)
accessLevel := acl.Registry.GetAccessLevel(authedPubkey, l.remote)
if accessLevel == "admin" || accessLevel == "owner" {
return true
}
// Default deny
return false
}

View File

@@ -4,25 +4,37 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"golang.org/x/crypto/acme/autocert"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/app/config"
database "next.orly.dev/pkg/database"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/bech32encoding"
"next.orly.dev/pkg/policy"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/spider"
dsync "next.orly.dev/pkg/sync"
)
func Run(
ctx context.Context, cfg *config.C, db *database.D,
) (quit chan struct{}) {
quit = make(chan struct{})
var once sync.Once
// shutdown handler
go func() {
select {
case <-ctx.Done():
log.I.F("shutting down")
close(quit)
}
<-ctx.Done()
log.I.F("shutting down")
once.Do(func() { close(quit) })
}()
// get the admins
var err error
@@ -37,6 +49,18 @@ func Run(
}
adminKeys = append(adminKeys, pk)
}
// get the owners
var ownerKeys [][]byte
for _, owner := range cfg.Owners {
if len(owner) == 0 {
continue
}
var pk []byte
if pk, err = bech32encoding.NpubOrHexToPublicKeyBinary(owner); chk.E(err) {
continue
}
ownerKeys = append(ownerKeys, pk)
}
// start listener
l := &Server{
Ctx: ctx,
@@ -44,14 +68,281 @@ func Run(
D: db,
publishers: publish.New(NewPublisher(ctx)),
Admins: adminKeys,
Owners: ownerKeys,
cfg: cfg,
db: db,
}
// Initialize NIP-43 invite manager if enabled
if cfg.NIP43Enabled {
l.InviteManager = nip43.NewInviteManager(cfg.NIP43InviteExpiry)
log.I.F("NIP-43 invite system enabled with %v expiry", cfg.NIP43InviteExpiry)
}
// Initialize sprocket manager
l.sprocketManager = NewSprocketManager(ctx, cfg.AppName, cfg.SprocketEnabled)
// Initialize policy manager
l.policyManager = policy.NewWithManager(ctx, cfg.AppName, cfg.PolicyEnabled)
// Initialize spider manager based on mode
if cfg.SpiderMode != "none" {
if l.spiderManager, err = spider.New(ctx, db, l.publishers, cfg.SpiderMode); chk.E(err) {
log.E.F("failed to create spider manager: %v", err)
} else {
// Set up callbacks for follows mode
if cfg.SpiderMode == "follows" {
l.spiderManager.SetCallbacks(
func() []string {
// Get admin relays from follows ACL if available
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == "follows" {
if follows, ok := aclInstance.(*acl.Follows); ok {
return follows.AdminRelays()
}
}
}
return nil
},
func() [][]byte {
// Get followed pubkeys from follows ACL if available
for _, aclInstance := range acl.Registry.ACL {
if aclInstance.Type() == "follows" {
if follows, ok := aclInstance.(*acl.Follows); ok {
return follows.GetFollowedPubkeys()
}
}
}
return nil
},
)
}
if err = l.spiderManager.Start(); chk.E(err) {
log.E.F("failed to start spider manager: %v", err)
} else {
log.I.F("spider manager started successfully in '%s' mode", cfg.SpiderMode)
}
}
}
// Initialize relay group manager
l.relayGroupMgr = dsync.NewRelayGroupManager(db, cfg.RelayGroupAdmins)
// Initialize sync manager if relay peers are configured
var peers []string
if len(cfg.RelayPeers) > 0 {
peers = cfg.RelayPeers
} else {
// Try to get peers from relay group configuration
if config, err := l.relayGroupMgr.FindAuthoritativeConfig(ctx); err == nil && config != nil {
peers = config.Relays
log.I.F("using relay group configuration with %d peers", len(peers))
}
}
if len(peers) > 0 {
// Get relay identity for node ID
sk, err := db.GetOrCreateRelayIdentitySecret()
if err != nil {
log.E.F("failed to get relay identity for sync: %v", err)
} else {
nodeID, err := keys.SecretBytesToPubKeyHex(sk)
if err != nil {
log.E.F("failed to derive pubkey for sync node ID: %v", err)
} else {
relayURL := cfg.RelayURL
if relayURL == "" {
relayURL = fmt.Sprintf("http://localhost:%d", cfg.Port)
}
l.syncManager = dsync.NewManager(ctx, db, nodeID, relayURL, peers, l.relayGroupMgr, l.policyManager)
log.I.F("distributed sync manager initialized with %d peers", len(peers))
}
}
}
// Initialize cluster manager for cluster replication
var clusterAdminNpubs []string
if len(cfg.ClusterAdmins) > 0 {
clusterAdminNpubs = cfg.ClusterAdmins
} else {
// Default to regular admins if no cluster admins specified
for _, admin := range cfg.Admins {
clusterAdminNpubs = append(clusterAdminNpubs, admin)
}
}
if len(clusterAdminNpubs) > 0 {
l.clusterManager = dsync.NewClusterManager(ctx, db, clusterAdminNpubs, cfg.ClusterPropagatePrivilegedEvents, l.publishers)
l.clusterManager.Start()
log.I.F("cluster replication manager initialized with %d admin npubs", len(clusterAdminNpubs))
}
// Initialize the user interface
l.UserInterface()
addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)
log.I.F("starting listener on http://%s", addr)
// Initialize Blossom blob storage server
if l.blossomServer, err = initializeBlossomServer(ctx, cfg, db); err != nil {
log.E.F("failed to initialize blossom server: %v", err)
// Continue without blossom server
} else if l.blossomServer != nil {
log.I.F("blossom blob storage server initialized")
}
// Ensure a relay identity secret key exists when subscriptions and NWC are enabled
if cfg.SubscriptionEnabled && cfg.NWCUri != "" {
if skb, e := db.GetOrCreateRelayIdentitySecret(); e != nil {
log.E.F("failed to ensure relay identity key: %v", e)
} else if pk, e2 := keys.SecretBytesToPubKeyHex(skb); e2 == nil {
log.I.F("relay identity loaded (pub=%s)", pk)
// ensure relay identity pubkey is considered an admin for ACL follows mode
found := false
for _, a := range cfg.Admins {
if a == pk {
found = true
break
}
}
if !found {
cfg.Admins = append(cfg.Admins, pk)
log.I.F("added relay identity to admins for follow-list whitelisting")
}
// also ensure relay identity pubkey is considered an owner for full control
found = false
for _, o := range cfg.Owners {
if o == pk {
found = true
break
}
}
if !found {
cfg.Owners = append(cfg.Owners, pk)
log.I.F("added relay identity to owners for full control")
}
}
}
if l.paymentProcessor, err = NewPaymentProcessor(ctx, cfg, db); err != nil {
// log.E.F("failed to create payment processor: %v", err)
// Continue without payment processor
} else {
if err = l.paymentProcessor.Start(); err != nil {
log.E.F("failed to start payment processor: %v", err)
} else {
log.I.F("payment processor started successfully")
}
}
// Check if TLS is enabled
var tlsEnabled bool
var tlsServer *http.Server
var httpServer *http.Server
if len(cfg.TLSDomains) > 0 {
// Validate TLS configuration
if err = ValidateTLSConfig(cfg.TLSDomains, cfg.Certs); chk.E(err) {
log.E.F("invalid TLS configuration: %v", err)
} else {
tlsEnabled = true
log.I.F("TLS enabled for domains: %v", cfg.TLSDomains)
// Create cache directory for autocert
cacheDir := filepath.Join(cfg.DataDir, "autocert")
if err = os.MkdirAll(cacheDir, 0700); chk.E(err) {
log.E.F("failed to create autocert cache directory: %v", err)
tlsEnabled = false
} else {
// Set up autocert manager
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(cacheDir),
HostPolicy: autocert.HostWhitelist(cfg.TLSDomains...),
}
// Create TLS server on port 443
tlsServer = &http.Server{
Addr: ":443",
Handler: l,
TLSConfig: TLSConfig(m, cfg.Certs...),
}
// Create HTTP server for ACME challenges and redirects on port 80
httpServer = &http.Server{
Addr: ":80",
Handler: m.HTTPHandler(nil),
}
// Start TLS server
go func() {
log.I.F("starting TLS listener on https://:443")
if err := tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
log.E.F("TLS server error: %v", err)
}
}()
// Start HTTP server for ACME challenges
go func() {
log.I.F("starting HTTP listener on http://:80 for ACME challenges")
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.E.F("HTTP server error: %v", err)
}
}()
}
}
}
// Start regular HTTP server if TLS is not enabled or as fallback
if !tlsEnabled {
addr := fmt.Sprintf("%s:%d", cfg.Listen, cfg.Port)
log.I.F("starting listener on http://%s", addr)
httpServer = &http.Server{
Addr: addr,
Handler: l,
}
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.E.F("HTTP server error: %v", err)
}
}()
}
// Graceful shutdown handler
go func() {
chk.E(http.ListenAndServe(addr, l))
<-ctx.Done()
log.I.F("shutting down servers gracefully")
// Stop spider manager if running
if l.spiderManager != nil {
l.spiderManager.Stop()
log.I.F("spider manager stopped")
}
// Create shutdown context with timeout
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelShutdown()
// Shutdown TLS server if running
if tlsServer != nil {
if err := tlsServer.Shutdown(shutdownCtx); err != nil {
log.E.F("TLS server shutdown error: %v", err)
} else {
log.I.F("TLS server shutdown completed")
}
}
// Shutdown HTTP server
if httpServer != nil {
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.E.F("HTTP server shutdown error: %v", err)
} else {
log.I.F("HTTP server shutdown completed")
}
}
once.Do(func() { close(quit) })
}()
quit = make(chan struct{})
return
}

558
app/nip43_e2e_test.go Normal file
View File

@@ -0,0 +1,558 @@
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"next.orly.dev/pkg/interfaces/signer/p8k"
"os"
"testing"
"time"
"next.orly.dev/app/config"
"next.orly.dev/pkg/crypto/keys"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/protocol/nip43"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/protocol/relayinfo"
)
// setupE2ETest creates a full test server for end-to-end testing
func setupE2ETest(t *testing.T) (*Server, *httptest.Server, func()) {
tempDir, err := os.MkdirTemp("", "nip43_e2e_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("failed to open database: %v", err)
}
cfg := &config.C{
AppName: "TestRelay",
NIP43Enabled: true,
NIP43PublishEvents: true,
NIP43PublishMemberList: true,
NIP43InviteExpiry: 24 * time.Hour,
RelayURL: "wss://test.relay",
Listen: "localhost",
Port: 3334,
ACLMode: "none",
AuthRequired: false,
}
// Generate admin keys
adminSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate admin secret: %v", err)
}
adminSigner, err := p8k.New()
if err != nil {
t.Fatalf("failed to create admin signer: %v", err)
}
if err = adminSigner.InitSec(adminSecret); err != nil {
t.Fatalf("failed to initialize admin signer: %v", err)
}
adminPubkey := adminSigner.Pub()
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
Admins: [][]byte{adminPubkey},
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
server.mux = http.NewServeMux()
// Set up HTTP handlers
server.mux.HandleFunc(
"/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "application/nostr+json" {
server.HandleRelayInfo(w, r)
return
}
http.NotFound(w, r)
},
)
httpServer := httptest.NewServer(server.mux)
cleanup := func() {
httpServer.Close()
db.Close()
os.RemoveAll(tempDir)
}
return server, httpServer, cleanup
}
// TestE2E_RelayInfoIncludesNIP43 tests that NIP-43 is advertised in relay info
func TestE2E_RelayInfoIncludesNIP43(t *testing.T) {
server, httpServer, cleanup := setupE2ETest(t)
defer cleanup()
// Make request to relay info endpoint
req, err := http.NewRequest("GET", httpServer.URL, nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
req.Header.Set("Accept", "application/nostr+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("failed to make request: %v", err)
}
defer resp.Body.Close()
// Parse relay info
var info relayinfo.T
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
t.Fatalf("failed to decode relay info: %v", err)
}
// Verify NIP-43 is in supported NIPs
hasNIP43 := false
for _, nip := range info.Nips {
if nip == 43 {
hasNIP43 = true
break
}
}
if !hasNIP43 {
t.Error("NIP-43 not advertised in supported_nips")
}
// Verify server name
if info.Name != server.Config.AppName {
t.Errorf(
"wrong relay name: got %s, want %s", info.Name,
server.Config.AppName,
)
}
}
// TestE2E_CompleteJoinFlow tests the complete user join flow
func TestE2E_CompleteJoinFlow(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
// Step 1: Admin requests invite code
adminPubkey := server.Admins[0]
inviteEvent, err := server.HandleNIP43InviteRequest(adminPubkey)
if err != nil {
t.Fatalf("failed to generate invite: %v", err)
}
// Extract invite code
claimTag := inviteEvent.Tags.GetFirst([]byte("claim"))
if claimTag == nil || claimTag.Len() < 2 {
t.Fatal("invite event missing claim tag")
}
inviteCode := string(claimTag.T[1])
// Step 2: User creates join request
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", inviteCode))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign join event: %v", err)
}
// Step 3: Process join request
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("failed to handle join request: %v", err)
}
// Step 4: Verify membership
isMember, err := server.D.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if !isMember {
t.Error("user was not added as member")
}
membership, err := server.D.GetNIP43Membership(userPubkey)
if err != nil {
t.Fatalf("failed to get membership: %v", err)
}
if membership.InviteCode != inviteCode {
t.Errorf(
"wrong invite code: got %s, want %s", membership.InviteCode,
inviteCode,
)
}
}
// TestE2E_InviteCodeReuse tests that invite codes can only be used once
func TestE2E_InviteCodeReuse(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// First user uses the code
user1Secret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user1 secret: %v", err)
}
user1Pubkey, err := keys.SecretBytesToPubKeyBytes(user1Secret)
if err != nil {
t.Fatalf("failed to get user1 pubkey: %v", err)
}
signer1, err := keys.SecretBytesToSigner(user1Secret)
if err != nil {
t.Fatalf("failed to create signer1: %v", err)
}
joinEv1 := event.New()
joinEv1.Kind = nip43.KindJoinRequest
copy(joinEv1.Pubkey, user1Pubkey)
joinEv1.Tags.Append(tag.NewFromAny("-"))
joinEv1.Tags.Append(tag.NewFromAny("claim", code))
joinEv1.CreatedAt = time.Now().Unix()
joinEv1.Content = []byte("")
if err = joinEv1.Sign(signer1); err != nil {
t.Fatalf("failed to sign join event 1: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv1)
if err != nil {
t.Fatalf("failed to handle join request 1: %v", err)
}
// Verify first user is member
isMember, err := server.D.IsNIP43Member(user1Pubkey)
if err != nil {
t.Fatalf("failed to check user1 membership: %v", err)
}
if !isMember {
t.Error("user1 was not added")
}
// Second user tries to use same code
user2Secret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user2 secret: %v", err)
}
user2Pubkey, err := keys.SecretBytesToPubKeyBytes(user2Secret)
if err != nil {
t.Fatalf("failed to get user2 pubkey: %v", err)
}
signer2, err := keys.SecretBytesToSigner(user2Secret)
if err != nil {
t.Fatalf("failed to create signer2: %v", err)
}
joinEv2 := event.New()
joinEv2.Kind = nip43.KindJoinRequest
copy(joinEv2.Pubkey, user2Pubkey)
joinEv2.Tags.Append(tag.NewFromAny("-"))
joinEv2.Tags.Append(tag.NewFromAny("claim", code))
joinEv2.CreatedAt = time.Now().Unix()
joinEv2.Content = []byte("")
if err = joinEv2.Sign(signer2); err != nil {
t.Fatalf("failed to sign join event 2: %v", err)
}
// Should handle without error but not add user
err = listener.HandleNIP43JoinRequest(joinEv2)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify second user is NOT member
isMember, err = server.D.IsNIP43Member(user2Pubkey)
if err != nil {
t.Fatalf("failed to check user2 membership: %v", err)
}
if isMember {
t.Error("user2 was incorrectly added with reused code")
}
}
// TestE2E_MembershipListGeneration tests membership list event generation
func TestE2E_MembershipListGeneration(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// Add multiple members
memberCount := 5
members := make([][]byte, memberCount)
for i := 0; i < memberCount; i++ {
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret %d: %v", i, err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey %d: %v", i, err)
}
members[i] = userPubkey
// Add directly to database for speed
err = server.D.AddNIP43Member(userPubkey, "code")
if err != nil {
t.Fatalf("failed to add member %d: %v", i, err)
}
}
// Generate membership list
err := listener.publishMembershipList()
if err != nil {
t.Fatalf("failed to publish membership list: %v", err)
}
// Note: In a real test, you would verify the event was published
// through the publishers system. For now, we just verify no error.
}
// TestE2E_ExpiredInviteCode tests that expired codes are rejected
func TestE2E_ExpiredInviteCode(t *testing.T) {
tempDir, err := os.MkdirTemp("", "nip43_expired_test_*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
db, err := database.New(ctx, cancel, tempDir, "info")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer db.Close()
cfg := &config.C{
NIP43Enabled: true,
NIP43InviteExpiry: 1 * time.Millisecond, // Very short expiry
}
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Wait for expiry
time.Sleep(10 * time.Millisecond)
// Try to use expired code
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := db.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was added with expired code")
}
}
// TestE2E_InvalidTimestampRejected tests that events with invalid timestamps are rejected
func TestE2E_InvalidTimestampRejected(t *testing.T) {
server, _, cleanup := setupE2ETest(t)
defer cleanup()
listener := &Listener{
Server: server,
ctx: server.Ctx,
}
// Generate invite code
code, err := server.InviteManager.GenerateCode()
if err != nil {
t.Fatalf("failed to generate invite code: %v", err)
}
// Create user
userSecret, err := keys.GenerateSecretKey()
if err != nil {
t.Fatalf("failed to generate user secret: %v", err)
}
userPubkey, err := keys.SecretBytesToPubKeyBytes(userSecret)
if err != nil {
t.Fatalf("failed to get user pubkey: %v", err)
}
signer, err := keys.SecretBytesToSigner(userSecret)
if err != nil {
t.Fatalf("failed to create signer: %v", err)
}
// Create join request with timestamp far in the past
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix() - 700 // More than 10 minutes ago
joinEv.Content = []byte("")
if err = joinEv.Sign(signer); err != nil {
t.Fatalf("failed to sign event: %v", err)
}
// Should handle without error but not add user
err = listener.HandleNIP43JoinRequest(joinEv)
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
// Verify user was NOT added
isMember, err := server.D.IsNIP43Member(userPubkey)
if err != nil {
t.Fatalf("failed to check membership: %v", err)
}
if isMember {
t.Error("user was added with invalid timestamp")
}
}
// BenchmarkJoinRequestProcessing benchmarks join request processing
func BenchmarkJoinRequestProcessing(b *testing.B) {
tempDir, err := os.MkdirTemp("", "nip43_bench_*")
if err != nil {
b.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
db, err := database.New(ctx, cancel, tempDir, "error")
if err != nil {
b.Fatalf("failed to open database: %v", err)
}
defer db.Close()
cfg := &config.C{
NIP43Enabled: true,
NIP43InviteExpiry: 24 * time.Hour,
}
server := &Server{
Ctx: ctx,
Config: cfg,
D: db,
publishers: publish.New(NewPublisher(ctx)),
InviteManager: nip43.NewInviteManager(cfg.NIP43InviteExpiry),
cfg: cfg,
db: db,
}
listener := &Listener{
Server: server,
ctx: ctx,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Generate unique user and code for each iteration
userSecret, _ := keys.GenerateSecretKey()
userPubkey, _ := keys.SecretBytesToPubKeyBytes(userSecret)
signer, _ := keys.SecretBytesToSigner(userSecret)
code, _ := server.InviteManager.GenerateCode()
joinEv := event.New()
joinEv.Kind = nip43.KindJoinRequest
copy(joinEv.Pubkey, userPubkey)
joinEv.Tags.Append(tag.NewFromAny("-"))
joinEv.Tags.Append(tag.NewFromAny("claim", code))
joinEv.CreatedAt = time.Now().Unix()
joinEv.Content = []byte("")
joinEv.Sign(signer)
listener.HandleNIP43JoinRequest(joinEv)
}
}

1092
app/payment_processor.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,498 @@
package app
import (
"bytes"
"testing"
"time"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/encoders/tag"
)
// Test helper to create a test event
func createTestEvent(id, pubkey, content string, eventKind uint16, tags ...*tag.T) (ev *event.E) {
ev = &event.E{
ID: []byte(id),
Kind: eventKind,
Pubkey: []byte(pubkey),
Content: []byte(content),
Tags: &tag.S{},
CreatedAt: time.Now().Unix(),
}
for _, t := range tags {
*ev.Tags = append(*ev.Tags, t)
}
return ev
}
// Test helper to create a p tag
func createPTag(pubkey string) (t *tag.T) {
t = tag.New()
t.T = append(t.T, []byte("p"), []byte(pubkey))
return t
}
// Test helper to simulate privileged event filtering logic
func testPrivilegedEventFiltering(events event.S, authedPubkey []byte, aclMode string, accessLevel string) (filtered event.S) {
var tmp event.S
for _, ev := range events {
if aclMode != "none" &&
kind.IsPrivileged(ev.Kind) && accessLevel != "admin" {
if authedPubkey == nil {
// Not authenticated - cannot see privileged events
continue
}
// Check if user is authorized to see this privileged event
authorized := false
if bytes.Equal(ev.Pubkey, []byte(hex.Enc(authedPubkey))) {
authorized = true
} else {
// Check p tags
pTags := ev.Tags.GetAll([]byte("p"))
for _, pTag := range pTags {
var pt []byte
var err error
if pt, err = hex.Dec(string(pTag.Value())); err != nil {
continue
}
if bytes.Equal(pt, authedPubkey) {
authorized = true
break
}
}
}
if authorized {
tmp = append(tmp, ev)
}
} else {
tmp = append(tmp, ev)
}
}
return tmp
}
func TestPrivilegedEventFiltering(t *testing.T) {
// Test pubkeys
authorPubkey := []byte("author-pubkey-12345")
recipientPubkey := []byte("recipient-pubkey-67")
unauthorizedPubkey := []byte("unauthorized-pubkey")
// Test events
tests := []struct {
name string
event *event.E
authedPubkey []byte
accessLevel string
shouldAllow bool
description string
}{
{
name: "privileged event - author can see own event",
event: createTestEvent(
"event-id-1",
hex.Enc(authorPubkey),
"private message",
kind.EncryptedDirectMessage.K,
),
authedPubkey: authorPubkey,
accessLevel: "read",
shouldAllow: true,
description: "Author should be able to see their own privileged event",
},
{
name: "privileged event - recipient in p tag can see event",
event: createTestEvent(
"event-id-2",
hex.Enc(authorPubkey),
"private message to recipient",
kind.EncryptedDirectMessage.K,
createPTag(hex.Enc(recipientPubkey)),
),
authedPubkey: recipientPubkey,
accessLevel: "read",
shouldAllow: true,
description: "Recipient in p tag should be able to see privileged event",
},
{
name: "privileged event - unauthorized user cannot see event",
event: createTestEvent(
"event-id-3",
hex.Enc(authorPubkey),
"private message",
kind.EncryptedDirectMessage.K,
createPTag(hex.Enc(recipientPubkey)),
),
authedPubkey: unauthorizedPubkey,
accessLevel: "read",
shouldAllow: false,
description: "Unauthorized user should not be able to see privileged event",
},
{
name: "privileged event - unauthenticated user cannot see event",
event: createTestEvent(
"event-id-4",
hex.Enc(authorPubkey),
"private message",
kind.EncryptedDirectMessage.K,
),
authedPubkey: nil,
accessLevel: "none",
shouldAllow: false,
description: "Unauthenticated user should not be able to see privileged event",
},
{
name: "privileged event - admin can see all events",
event: createTestEvent(
"event-id-5",
hex.Enc(authorPubkey),
"private message",
kind.EncryptedDirectMessage.K,
),
authedPubkey: unauthorizedPubkey,
accessLevel: "admin",
shouldAllow: true,
description: "Admin should be able to see all privileged events",
},
{
name: "non-privileged event - anyone can see",
event: createTestEvent(
"event-id-6",
hex.Enc(authorPubkey),
"public message",
kind.TextNote.K,
),
authedPubkey: unauthorizedPubkey,
accessLevel: "read",
shouldAllow: true,
description: "Non-privileged events should be visible to anyone with read access",
},
{
name: "privileged event - multiple p tags, user in second tag",
event: createTestEvent(
"event-id-7",
hex.Enc(authorPubkey),
"message to multiple recipients",
kind.EncryptedDirectMessage.K,
createPTag(hex.Enc(unauthorizedPubkey)),
createPTag(hex.Enc(recipientPubkey)),
),
authedPubkey: recipientPubkey,
accessLevel: "read",
shouldAllow: true,
description: "User should be found even if they're in the second p tag",
},
{
name: "privileged event - gift wrap kind",
event: createTestEvent(
"event-id-8",
hex.Enc(authorPubkey),
"gift wrapped message",
kind.GiftWrap.K,
createPTag(hex.Enc(recipientPubkey)),
),
authedPubkey: recipientPubkey,
accessLevel: "read",
shouldAllow: true,
description: "Gift wrap events should also be filtered as privileged",
},
{
name: "privileged event - application specific data",
event: createTestEvent(
"event-id-9",
hex.Enc(authorPubkey),
"app config data",
kind.ApplicationSpecificData.K,
),
authedPubkey: authorPubkey,
accessLevel: "read",
shouldAllow: true,
description: "Application specific data should be privileged",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create event slice
events := event.S{tt.event}
// Test the filtering logic
filtered := testPrivilegedEventFiltering(events, tt.authedPubkey, "managed", tt.accessLevel)
// Check result
if tt.shouldAllow {
if len(filtered) != 1 {
t.Errorf("%s: Expected event to be allowed, but it was filtered out. %s", tt.name, tt.description)
}
} else {
if len(filtered) != 0 {
t.Errorf("%s: Expected event to be filtered out, but it was allowed. %s", tt.name, tt.description)
}
}
})
}
}
func TestAllPrivilegedKinds(t *testing.T) {
// Test that all defined privileged kinds are properly filtered
authorPubkey := []byte("author-pubkey-12345")
unauthorizedPubkey := []byte("unauthorized-pubkey")
privilegedKinds := []uint16{
kind.EncryptedDirectMessage.K,
kind.GiftWrap.K,
kind.GiftWrapWithKind4.K,
kind.JWTBinding.K,
kind.ApplicationSpecificData.K,
kind.Seal.K,
kind.DirectMessage.K,
}
for _, k := range privilegedKinds {
t.Run("kind_"+hex.Enc([]byte{byte(k >> 8), byte(k)}), func(t *testing.T) {
// Verify the kind is actually marked as privileged
if !kind.IsPrivileged(k) {
t.Fatalf("Kind %d should be privileged but IsPrivileged returned false", k)
}
// Create test event of this kind
ev := createTestEvent(
"test-event-id",
hex.Enc(authorPubkey),
"test content",
k,
)
// Test filtering with unauthorized user
events := event.S{ev}
filtered := testPrivilegedEventFiltering(events, unauthorizedPubkey, "managed", "read")
// Unauthorized user should not see the event
if len(filtered) != 0 {
t.Errorf("Privileged kind %d should be filtered out for unauthorized user", k)
}
})
}
}
func TestPrivilegedEventEdgeCases(t *testing.T) {
authorPubkey := []byte("author-pubkey-12345")
recipientPubkey := []byte("recipient-pubkey-67")
tests := []struct {
name string
event *event.E
authedUser []byte
shouldAllow bool
description string
}{
{
name: "malformed p tag - should not crash",
event: func() *event.E {
ev := createTestEvent(
"event-id-1",
hex.Enc(authorPubkey),
"message with malformed p tag",
kind.EncryptedDirectMessage.K,
)
// Add malformed p tag (invalid hex)
malformedTag := tag.New()
malformedTag.T = append(malformedTag.T, []byte("p"), []byte("invalid-hex-string"))
*ev.Tags = append(*ev.Tags, malformedTag)
return ev
}(),
authedUser: recipientPubkey,
shouldAllow: false,
description: "Malformed p tags should not cause crashes and should not grant access",
},
{
name: "empty p tag - should not crash",
event: func() *event.E {
ev := createTestEvent(
"event-id-2",
hex.Enc(authorPubkey),
"message with empty p tag",
kind.EncryptedDirectMessage.K,
)
// Add empty p tag
emptyTag := tag.New()
emptyTag.T = append(emptyTag.T, []byte("p"), []byte(""))
*ev.Tags = append(*ev.Tags, emptyTag)
return ev
}(),
authedUser: recipientPubkey,
shouldAllow: false,
description: "Empty p tags should not grant access",
},
{
name: "p tag with wrong length - should not match",
event: func() *event.E {
ev := createTestEvent(
"event-id-3",
hex.Enc(authorPubkey),
"message with wrong length p tag",
kind.EncryptedDirectMessage.K,
)
// Add p tag with wrong length (too short)
wrongLengthTag := tag.New()
wrongLengthTag.T = append(wrongLengthTag.T, []byte("p"), []byte("1234"))
*ev.Tags = append(*ev.Tags, wrongLengthTag)
return ev
}(),
authedUser: recipientPubkey,
shouldAllow: false,
description: "P tags with wrong length should not match",
},
{
name: "case sensitivity - hex should be case insensitive",
event: func() *event.E {
ev := createTestEvent(
"event-id-4",
hex.Enc(authorPubkey),
"message with mixed case p tag",
kind.EncryptedDirectMessage.K,
)
// Add p tag with mixed case hex
mixedCaseHex := hex.Enc(recipientPubkey)
// Convert some characters to uppercase
mixedCaseBytes := []byte(mixedCaseHex)
for i := 0; i < len(mixedCaseBytes); i += 2 {
if mixedCaseBytes[i] >= 'a' && mixedCaseBytes[i] <= 'f' {
mixedCaseBytes[i] = mixedCaseBytes[i] - 'a' + 'A'
}
}
mixedCaseTag := tag.New()
mixedCaseTag.T = append(mixedCaseTag.T, []byte("p"), mixedCaseBytes)
*ev.Tags = append(*ev.Tags, mixedCaseTag)
return ev
}(),
authedUser: recipientPubkey,
shouldAllow: true,
description: "Hex encoding should be case insensitive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test filtering
events := event.S{tt.event}
filtered := testPrivilegedEventFiltering(events, tt.authedUser, "managed", "read")
// Check result
if tt.shouldAllow {
if len(filtered) != 1 {
t.Errorf("%s: Expected event to be allowed, but it was filtered out. %s", tt.name, tt.description)
}
} else {
if len(filtered) != 0 {
t.Errorf("%s: Expected event to be filtered out, but it was allowed. %s", tt.name, tt.description)
}
}
})
}
}
func TestPrivilegedEventPolicyIntegration(t *testing.T) {
// Test that the policy system also correctly handles privileged events
// This tests the policy.go implementation
authorPubkey := []byte("author-pubkey-12345")
recipientPubkey := []byte("recipient-pubkey-67")
unauthorizedPubkey := []byte("unauthorized-pubkey")
tests := []struct {
name string
event *event.E
loggedInPubkey []byte
privileged bool
shouldAllow bool
description string
}{
{
name: "policy privileged - author can access own event",
event: createTestEvent(
"event-id-1",
hex.Enc(authorPubkey),
"private message",
kind.EncryptedDirectMessage.K,
),
loggedInPubkey: authorPubkey,
privileged: true,
shouldAllow: true,
description: "Policy should allow author to access their own privileged event",
},
{
name: "policy privileged - recipient in p tag can access",
event: createTestEvent(
"event-id-2",
hex.Enc(authorPubkey),
"private message to recipient",
kind.EncryptedDirectMessage.K,
createPTag(hex.Enc(recipientPubkey)),
),
loggedInPubkey: recipientPubkey,
privileged: true,
shouldAllow: true,
description: "Policy should allow recipient in p tag to access privileged event",
},
{
name: "policy privileged - unauthorized user denied",
event: createTestEvent(
"event-id-3",
hex.Enc(authorPubkey),
"private message",
kind.EncryptedDirectMessage.K,
createPTag(hex.Enc(recipientPubkey)),
),
loggedInPubkey: unauthorizedPubkey,
privileged: true,
shouldAllow: false,
description: "Policy should deny unauthorized user access to privileged event",
},
{
name: "policy privileged - unauthenticated user denied",
event: createTestEvent(
"event-id-4",
hex.Enc(authorPubkey),
"private message",
kind.EncryptedDirectMessage.K,
),
loggedInPubkey: nil,
privileged: true,
shouldAllow: false,
description: "Policy should deny unauthenticated user access to privileged event",
},
{
name: "policy non-privileged - anyone can access",
event: createTestEvent(
"event-id-5",
hex.Enc(authorPubkey),
"public message",
kind.TextNote.K,
),
loggedInPubkey: unauthorizedPubkey,
privileged: false,
shouldAllow: true,
description: "Policy should allow access to non-privileged events",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Import the policy package to test the checkRulePolicy function
// We'll simulate the policy check by creating a rule with Privileged flag
// Note: This test would require importing the policy package and creating
// a proper policy instance. For now, we'll focus on the main filtering logic
// which we've already tested above.
// The policy implementation in pkg/policy/policy.go lines 424-443 looks correct
// and matches our expectations based on the existing tests in policy_test.go
t.Logf("Policy integration test: %s - %s", tt.name, tt.description)
})
}
}

View File

@@ -4,25 +4,31 @@ import (
"context"
"fmt"
"sync"
"time"
"github.com/coder/websocket"
"lol.mleku.dev/chk"
"github.com/gorilla/websocket"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/filter"
"next.orly.dev/pkg/encoders/hex"
"next.orly.dev/pkg/encoders/kind"
"next.orly.dev/pkg/interfaces/publisher"
"next.orly.dev/pkg/interfaces/typer"
"next.orly.dev/pkg/protocol/publish"
"next.orly.dev/pkg/utils"
)
const Type = "socketapi"
// WriteChanMap maps websocket connections to their write channels
type WriteChanMap map[*websocket.Conn]chan publish.WriteRequest
type Subscription struct {
remote string
AuthedPubkey []byte
Receiver event.C // Channel for delivering events to this subscription
AuthRequired bool // Whether ACL requires authentication for privileged events
*filter.S
}
@@ -53,6 +59,11 @@ type W struct {
// AuthedPubkey is the authenticated pubkey associated with the listener (if any).
AuthedPubkey []byte
// AuthRequired indicates whether the ACL in operation requires auth. If
// this is set to true, the publisher will not publish privileged or other
// restricted events to non-authed listeners, otherwise, it will.
AuthRequired bool
}
func (w *W) Type() (typeName string) { return Type }
@@ -66,14 +77,17 @@ type P struct {
Mx sync.RWMutex
// Map is the map of subscribers and subscriptions from the websocket api.
Map
// WriteChans maps websocket connections to their write channels
WriteChans WriteChanMap
}
var _ publisher.I = &P{}
func NewPublisher(c context.Context) (publisher *P) {
return &P{
c: c,
Map: make(Map),
c: c,
Map: make(Map),
WriteChans: make(WriteChanMap, 100),
}
}
@@ -101,17 +115,8 @@ func (p *P) Receive(msg typer.T) {
if m.Cancel {
if m.Id == "" {
p.removeSubscriber(m.Conn)
// log.D.F("removed listener %s", m.remote)
} else {
p.removeSubscriberId(m.Conn, m.Id)
// log.D.C(
// func() string {
// return fmt.Sprintf(
// "removed subscription %s for %s", m.Id,
// m.remote,
// )
// },
// )
}
return
}
@@ -121,29 +126,14 @@ func (p *P) Receive(msg typer.T) {
subs = make(map[string]Subscription)
subs[m.Id] = Subscription{
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey,
Receiver: m.Receiver, AuthRequired: m.AuthRequired,
}
p.Map[m.Conn] = subs
// log.D.C(
// func() string {
// return fmt.Sprintf(
// "created new subscription for %s, %s",
// m.remote,
// m.Filters.Marshal(nil),
// )
// },
// )
} else {
subs[m.Id] = Subscription{
S: m.Filters, remote: m.remote, AuthedPubkey: m.AuthedPubkey,
Receiver: m.Receiver, AuthRequired: m.AuthRequired,
}
// log.D.C(
// func() string {
// return fmt.Sprintf(
// "added subscription %s for %s", m.Id,
// m.remote,
// )
// },
// )
}
}
}
@@ -160,7 +150,6 @@ func (p *P) Receive(msg typer.T) {
// applies authentication checks if required by the server and skips delivery
// for unauthenticated users when events are privileged.
func (p *P) Deliver(ev *event.E) {
var err error
// Snapshot the deliveries under read lock to avoid holding locks during I/O
p.Mx.RLock()
type delivery struct {
@@ -192,7 +181,17 @@ func (p *P) Deliver(ev *event.E) {
for _, d := range deliveries {
// If the event is privileged, enforce that the subscriber's authed pubkey matches
// either the event pubkey or appears in any 'p' tag of the event.
if kind.IsPrivileged(ev.Kind) && len(d.sub.AuthedPubkey) > 0 {
// Only check authentication if AuthRequired is true (ACL is active)
if kind.IsPrivileged(ev.Kind) && d.sub.AuthRequired {
if len(d.sub.AuthedPubkey) == 0 {
// Not authenticated - cannot see privileged events
log.D.F(
"subscription delivery DENIED for privileged event %s to %s (not authenticated)",
hex.Enc(ev.ID), d.sub.remote,
)
continue
}
pk := d.sub.AuthedPubkey
allowed := false
// Direct author match
@@ -212,37 +211,83 @@ func (p *P) Deliver(ev *event.E) {
}
}
if !allowed {
log.D.F(
"subscription delivery DENIED for privileged event %s to %s (auth mismatch)",
hex.Enc(ev.ID), d.sub.remote,
)
// Skip delivery for this subscriber
continue
}
}
var res *eventenvelope.Result
if res, err = eventenvelope.NewResultWith(d.id, ev); chk.E(err) {
continue
}
// Use a separate context with timeout for writes to prevent race conditions
// where the publisher context gets cancelled while writing events
writeCtx, cancel := context.WithTimeout(
context.Background(), DefaultWriteTimeout,
)
defer cancel()
if err = d.w.Write(
writeCtx, websocket.MessageText, res.Marshal(nil),
); err != nil {
// On error, remove the subscriber connection safely
p.removeSubscriber(d.w)
_ = d.w.CloseNow()
// Check for private tags - only deliver to authorized users
if ev.Tags != nil && ev.Tags.Len() > 0 {
hasPrivateTag := false
var privatePubkey []byte
for _, t := range *ev.Tags {
if t.Len() >= 2 {
keyBytes := t.Key()
if len(keyBytes) == 7 && string(keyBytes) == "private" {
hasPrivateTag = true
privatePubkey = t.Value()
break
}
}
}
if hasPrivateTag {
canSeePrivate := p.canSeePrivateEvent(
d.sub.AuthedPubkey, privatePubkey, d.sub.remote,
)
if !canSeePrivate {
log.D.F(
"subscription delivery DENIED for private event %s to %s (unauthorized)",
hex.Enc(ev.ID), d.sub.remote,
)
continue
}
log.D.F(
"subscription delivery ALLOWED for private event %s to %s (authorized)",
hex.Enc(ev.ID), d.sub.remote,
)
}
}
// Send event to the subscription's receiver channel
// The consumer goroutine (in handle-req.go) will read from this channel
// and forward it to the client via the write channel
log.D.F(
"attempting delivery of event %s (kind=%d) to subscription %s @ %s",
hex.Enc(ev.ID), ev.Kind, d.id, d.sub.remote,
)
// Check if receiver channel exists
if d.sub.Receiver == nil {
log.E.F(
"subscription %s has nil receiver channel for %s", d.id,
d.sub.remote,
)
continue
}
log.D.C(
func() string {
return fmt.Sprintf(
"dispatched event %0x to subscription %s, %s",
ev.ID, d.id, d.sub.remote,
)
},
)
// Send to receiver channel - non-blocking with timeout
select {
case <-p.c.Done():
continue
case d.sub.Receiver <- ev:
log.D.F(
"subscription delivery QUEUED: event=%s to=%s sub=%s",
hex.Enc(ev.ID), d.sub.remote, d.id,
)
case <-time.After(DefaultWriteTimeout):
log.E.F(
"subscription delivery TIMEOUT: event=%s to=%s sub=%s",
hex.Enc(ev.ID), d.sub.remote, d.id,
)
// Receiver channel is full - subscription consumer is stuck or slow
// The subscription should be removed by the cleanup logic
}
}
}
@@ -258,14 +303,66 @@ func (p *P) removeSubscriberId(ws *websocket.Conn, id string) {
// Check the actual map after deletion, not the original reference
if len(p.Map[ws]) == 0 {
delete(p.Map, ws)
// Don't remove write channel here - it's tied to the connection, not subscriptions
// The write channel will be removed when the connection closes (in handle-websocket.go defer)
// This allows new subscriptions to be created on the same connection
}
}
}
// SetWriteChan stores the write channel for a websocket connection
// If writeChan is nil, the entry is removed from the map
func (p *P) SetWriteChan(
conn *websocket.Conn, writeChan chan publish.WriteRequest,
) {
p.Mx.Lock()
defer p.Mx.Unlock()
if writeChan == nil {
delete(p.WriteChans, conn)
} else {
p.WriteChans[conn] = writeChan
}
}
// GetWriteChan returns the write channel for a websocket connection
func (p *P) GetWriteChan(conn *websocket.Conn) (
chan publish.WriteRequest, bool,
) {
p.Mx.RLock()
defer p.Mx.RUnlock()
ch, ok := p.WriteChans[conn]
return ch, ok
}
// removeSubscriber removes a websocket from the P collection.
func (p *P) removeSubscriber(ws *websocket.Conn) {
p.Mx.Lock()
defer p.Mx.Unlock()
clear(p.Map[ws])
delete(p.Map, ws)
delete(p.WriteChans, ws)
}
// canSeePrivateEvent checks if the authenticated user can see an event with a private tag
func (p *P) canSeePrivateEvent(
authedPubkey, privatePubkey []byte, remote string,
) (canSee bool) {
// If no authenticated user, deny access
if len(authedPubkey) == 0 {
return false
}
// If the authenticated user matches the private tag pubkey, allow access
if len(privatePubkey) > 0 && utils.FastEqual(authedPubkey, privatePubkey) {
return true
}
// Check if user is an admin or owner (they can see all private events)
accessLevel := acl.Registry.GetAccessLevel(authedPubkey, remote)
if accessLevel == "admin" || accessLevel == "owner" {
return true
}
// Default deny
return false
}

File diff suppressed because it is too large Load Diff

629
app/sprocket.go Normal file
View File

@@ -0,0 +1,629 @@
package app
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/adrg/xdg"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
"next.orly.dev/pkg/encoders/event"
)
// SprocketResponse represents a response from the sprocket script
type SprocketResponse struct {
ID string `json:"id"`
Action string `json:"action"` // accept, reject, or shadowReject
Msg string `json:"msg"` // NIP-20 response message (only used for reject)
}
// SprocketManager handles sprocket script execution and management
type SprocketManager struct {
ctx context.Context
cancel context.CancelFunc
configDir string
scriptPath string
currentCmd *exec.Cmd
currentCancel context.CancelFunc
mutex sync.RWMutex
isRunning bool
enabled bool
disabled bool // true when sprocket is disabled due to failure
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
responseChan chan SprocketResponse
}
// NewSprocketManager creates a new sprocket manager
func NewSprocketManager(ctx context.Context, appName string, enabled bool) *SprocketManager {
configDir := filepath.Join(xdg.ConfigHome, appName)
scriptPath := filepath.Join(configDir, "sprocket.sh")
ctx, cancel := context.WithCancel(ctx)
sm := &SprocketManager{
ctx: ctx,
cancel: cancel,
configDir: configDir,
scriptPath: scriptPath,
enabled: enabled,
disabled: false,
responseChan: make(chan SprocketResponse, 100), // Buffered channel for responses
}
// Start the sprocket script if it exists and is enabled
if enabled {
go sm.startSprocketIfExists()
// Start periodic check for sprocket script availability
go sm.periodicCheck()
}
return sm
}
// disableSprocket disables sprocket due to failure
func (sm *SprocketManager) disableSprocket() {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if !sm.disabled {
sm.disabled = true
log.W.F("sprocket disabled due to failure - all events will be rejected (script location: %s)", sm.scriptPath)
}
}
// enableSprocket re-enables sprocket and attempts to start it
func (sm *SprocketManager) enableSprocket() {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if sm.disabled {
sm.disabled = false
log.I.F("sprocket re-enabled, attempting to start")
// Attempt to start sprocket in background
go func() {
if _, err := os.Stat(sm.scriptPath); err == nil {
if err := sm.StartSprocket(); err != nil {
log.E.F("failed to restart sprocket: %v", err)
sm.disableSprocket()
} else {
log.I.F("sprocket restarted successfully")
}
} else {
log.W.F("sprocket script still not found, keeping disabled")
sm.disableSprocket()
}
}()
}
}
// periodicCheck periodically checks if sprocket script becomes available
func (sm *SprocketManager) periodicCheck() {
ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
defer ticker.Stop()
for {
select {
case <-sm.ctx.Done():
return
case <-ticker.C:
sm.mutex.RLock()
disabled := sm.disabled
running := sm.isRunning
sm.mutex.RUnlock()
// Only check if sprocket is disabled or not running
if disabled || !running {
if _, err := os.Stat(sm.scriptPath); err == nil {
// Script is available, try to enable/restart
if disabled {
sm.enableSprocket()
} else if !running {
// Script exists but sprocket isn't running, try to start
go func() {
if err := sm.StartSprocket(); err != nil {
log.E.F("failed to restart sprocket: %v", err)
sm.disableSprocket()
} else {
log.I.F("sprocket restarted successfully")
}
}()
}
}
}
}
}
}
// startSprocketIfExists starts the sprocket script if the file exists
func (sm *SprocketManager) startSprocketIfExists() {
if _, err := os.Stat(sm.scriptPath); err == nil {
if err := sm.StartSprocket(); err != nil {
log.E.F("failed to start sprocket: %v", err)
sm.disableSprocket()
}
} else {
log.W.F("sprocket script not found at %s, disabling sprocket", sm.scriptPath)
sm.disableSprocket()
}
}
// StartSprocket starts the sprocket script
func (sm *SprocketManager) StartSprocket() error {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if sm.isRunning {
return fmt.Errorf("sprocket is already running")
}
if _, err := os.Stat(sm.scriptPath); os.IsNotExist(err) {
return fmt.Errorf("sprocket script does not exist")
}
// Create a new context for this command
cmdCtx, cmdCancel := context.WithCancel(sm.ctx)
// Make the script executable
if err := os.Chmod(sm.scriptPath, 0755); chk.E(err) {
cmdCancel()
return fmt.Errorf("failed to make script executable: %v", err)
}
// Start the script
cmd := exec.CommandContext(cmdCtx, sm.scriptPath)
cmd.Dir = sm.configDir
// Set up stdio pipes for communication
stdin, err := cmd.StdinPipe()
if chk.E(err) {
cmdCancel()
return fmt.Errorf("failed to create stdin pipe: %v", err)
}
stdout, err := cmd.StdoutPipe()
if chk.E(err) {
cmdCancel()
stdin.Close()
return fmt.Errorf("failed to create stdout pipe: %v", err)
}
stderr, err := cmd.StderrPipe()
if chk.E(err) {
cmdCancel()
stdin.Close()
stdout.Close()
return fmt.Errorf("failed to create stderr pipe: %v", err)
}
// Start the command
if err := cmd.Start(); chk.E(err) {
cmdCancel()
stdin.Close()
stdout.Close()
stderr.Close()
return fmt.Errorf("failed to start sprocket: %v", err)
}
sm.currentCmd = cmd
sm.currentCancel = cmdCancel
sm.stdin = stdin
sm.stdout = stdout
sm.stderr = stderr
sm.isRunning = true
// Start response reader in background
go sm.readResponses()
// Log stderr output in background
go sm.logOutput(stdout, stderr)
// Monitor the process
go sm.monitorProcess()
log.I.F("sprocket started (pid=%d)", cmd.Process.Pid)
return nil
}
// StopSprocket stops the sprocket script gracefully, with SIGKILL fallback
func (sm *SprocketManager) StopSprocket() error {
sm.mutex.Lock()
defer sm.mutex.Unlock()
if !sm.isRunning || sm.currentCmd == nil {
return fmt.Errorf("sprocket is not running")
}
// Close stdin first to signal the script to exit
if sm.stdin != nil {
sm.stdin.Close()
}
// Cancel the context
if sm.currentCancel != nil {
sm.currentCancel()
}
// Wait for graceful shutdown with timeout
done := make(chan error, 1)
go func() {
done <- sm.currentCmd.Wait()
}()
select {
case <-done:
// Process exited gracefully
log.I.F("sprocket stopped gracefully")
case <-time.After(5 * time.Second):
// Force kill after 5 seconds
log.W.F("sprocket did not stop gracefully, sending SIGKILL")
if err := sm.currentCmd.Process.Kill(); chk.E(err) {
log.E.F("failed to kill sprocket process: %v", err)
}
<-done // Wait for the kill to complete
}
// Clean up pipes
if sm.stdin != nil {
sm.stdin.Close()
sm.stdin = nil
}
if sm.stdout != nil {
sm.stdout.Close()
sm.stdout = nil
}
if sm.stderr != nil {
sm.stderr.Close()
sm.stderr = nil
}
sm.isRunning = false
sm.currentCmd = nil
sm.currentCancel = nil
return nil
}
// RestartSprocket stops and starts the sprocket script
func (sm *SprocketManager) RestartSprocket() error {
if sm.isRunning {
if err := sm.StopSprocket(); chk.E(err) {
return fmt.Errorf("failed to stop sprocket: %v", err)
}
// Give it a moment to fully stop
time.Sleep(100 * time.Millisecond)
}
return sm.StartSprocket()
}
// UpdateSprocket updates the sprocket script and restarts it with zero downtime
func (sm *SprocketManager) UpdateSprocket(scriptContent string) error {
// Ensure config directory exists
if err := os.MkdirAll(sm.configDir, 0755); chk.E(err) {
return fmt.Errorf("failed to create config directory: %v", err)
}
// If script content is empty, delete the script and stop
if strings.TrimSpace(scriptContent) == "" {
if sm.isRunning {
if err := sm.StopSprocket(); chk.E(err) {
log.E.F("failed to stop sprocket before deletion: %v", err)
}
}
if _, err := os.Stat(sm.scriptPath); err == nil {
if err := os.Remove(sm.scriptPath); chk.E(err) {
return fmt.Errorf("failed to delete sprocket script: %v", err)
}
log.I.F("sprocket script deleted")
}
return nil
}
// Create backup of existing script if it exists
if _, err := os.Stat(sm.scriptPath); err == nil {
timestamp := time.Now().Format("20060102150405")
backupPath := sm.scriptPath + "." + timestamp
if err := os.Rename(sm.scriptPath, backupPath); chk.E(err) {
log.W.F("failed to create backup: %v", err)
} else {
log.I.F("created backup: %s", backupPath)
}
}
// Write new script to temporary file first
tempPath := sm.scriptPath + ".tmp"
if err := os.WriteFile(tempPath, []byte(scriptContent), 0755); chk.E(err) {
return fmt.Errorf("failed to write temporary sprocket script: %v", err)
}
// If sprocket is running, do zero-downtime update
if sm.isRunning {
// Atomically replace the script file
if err := os.Rename(tempPath, sm.scriptPath); chk.E(err) {
os.Remove(tempPath) // Clean up temp file
return fmt.Errorf("failed to replace sprocket script: %v", err)
}
log.I.F("sprocket script updated atomically")
// Restart the sprocket process
return sm.RestartSprocket()
} else {
// Not running, just replace the file
if err := os.Rename(tempPath, sm.scriptPath); chk.E(err) {
os.Remove(tempPath) // Clean up temp file
return fmt.Errorf("failed to replace sprocket script: %v", err)
}
log.I.F("sprocket script updated")
return nil
}
}
// GetSprocketStatus returns the current status of the sprocket
func (sm *SprocketManager) GetSprocketStatus() map[string]interface{} {
sm.mutex.RLock()
defer sm.mutex.RUnlock()
status := map[string]interface{}{
"is_running": sm.isRunning,
"script_exists": false,
"script_path": sm.scriptPath,
}
if _, err := os.Stat(sm.scriptPath); err == nil {
status["script_exists"] = true
// Get script content
if content, err := os.ReadFile(sm.scriptPath); err == nil {
status["script_content"] = string(content)
}
// Get file info
if info, err := os.Stat(sm.scriptPath); err == nil {
status["script_modified"] = info.ModTime()
}
}
if sm.isRunning && sm.currentCmd != nil && sm.currentCmd.Process != nil {
status["pid"] = sm.currentCmd.Process.Pid
}
return status
}
// GetSprocketVersions returns a list of all sprocket script versions
func (sm *SprocketManager) GetSprocketVersions() ([]map[string]interface{}, error) {
versions := []map[string]interface{}{}
// Check for current script
if _, err := os.Stat(sm.scriptPath); err == nil {
if info, err := os.Stat(sm.scriptPath); err == nil {
if content, err := os.ReadFile(sm.scriptPath); err == nil {
versions = append(versions, map[string]interface{}{
"name": "sprocket.sh",
"path": sm.scriptPath,
"modified": info.ModTime(),
"content": string(content),
"is_current": true,
})
}
}
}
// Check for backup versions
dir := filepath.Dir(sm.scriptPath)
files, err := os.ReadDir(dir)
if chk.E(err) {
return versions, nil
}
for _, file := range files {
if strings.HasPrefix(file.Name(), "sprocket.sh.") && !file.IsDir() {
path := filepath.Join(dir, file.Name())
if info, err := os.Stat(path); err == nil {
if content, err := os.ReadFile(path); err == nil {
versions = append(versions, map[string]interface{}{
"name": file.Name(),
"path": path,
"modified": info.ModTime(),
"content": string(content),
"is_current": false,
})
}
}
}
}
return versions, nil
}
// DeleteSprocketVersion deletes a specific sprocket version
func (sm *SprocketManager) DeleteSprocketVersion(filename string) error {
// Don't allow deleting the current script
if filename == "sprocket.sh" {
return fmt.Errorf("cannot delete current sprocket script")
}
path := filepath.Join(sm.configDir, filename)
if err := os.Remove(path); chk.E(err) {
return fmt.Errorf("failed to delete sprocket version: %v", err)
}
log.I.F("deleted sprocket version: %s", filename)
return nil
}
// logOutput logs the output from stdout and stderr
func (sm *SprocketManager) logOutput(stdout, stderr io.ReadCloser) {
defer stdout.Close()
defer stderr.Close()
// Trace-log stdout lines
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
log.T.F("sprocket stdout: %s", line)
}
}()
// Trace-log stderr lines
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
log.T.F("sprocket stderr: %s", line)
}
}()
}
// ProcessEvent sends an event to the sprocket script and waits for a response
func (sm *SprocketManager) ProcessEvent(evt *event.E) (*SprocketResponse, error) {
sm.mutex.RLock()
if !sm.isRunning || sm.stdin == nil {
sm.mutex.RUnlock()
return nil, fmt.Errorf("sprocket is not running")
}
stdin := sm.stdin
sm.mutex.RUnlock()
// Serialize the event to JSON
eventJSON, err := json.Marshal(evt)
if chk.E(err) {
return nil, fmt.Errorf("failed to serialize event: %v", err)
}
// Send the event JSON to the sprocket script
// The final ']' should be the only thing after the event's raw JSON
if _, err := stdin.Write(eventJSON); chk.E(err) {
return nil, fmt.Errorf("failed to write event to sprocket: %v", err)
}
// Wait for response with timeout
select {
case response := <-sm.responseChan:
return &response, nil
case <-time.After(5 * time.Second):
return nil, fmt.Errorf("sprocket response timeout")
case <-sm.ctx.Done():
return nil, fmt.Errorf("sprocket context cancelled")
}
}
// readResponses reads JSONL responses from the sprocket script
func (sm *SprocketManager) readResponses() {
if sm.stdout == nil {
return
}
scanner := bufio.NewScanner(sm.stdout)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var response SprocketResponse
if err := json.Unmarshal([]byte(line), &response); chk.E(err) {
log.E.F("failed to parse sprocket response: %v", err)
continue
}
// Send response to channel (non-blocking)
select {
case sm.responseChan <- response:
default:
log.W.F("sprocket response channel full, dropping response")
}
}
if err := scanner.Err(); chk.E(err) {
log.E.F("error reading sprocket responses: %v", err)
}
}
// IsEnabled returns whether sprocket is enabled
func (sm *SprocketManager) IsEnabled() bool {
return sm.enabled
}
// IsRunning returns whether sprocket is currently running
func (sm *SprocketManager) IsRunning() bool {
sm.mutex.RLock()
defer sm.mutex.RUnlock()
return sm.isRunning
}
// IsDisabled returns whether sprocket is disabled due to failure
func (sm *SprocketManager) IsDisabled() bool {
sm.mutex.RLock()
defer sm.mutex.RUnlock()
return sm.disabled
}
// monitorProcess monitors the sprocket process and cleans up when it exits
func (sm *SprocketManager) monitorProcess() {
if sm.currentCmd == nil {
return
}
err := sm.currentCmd.Wait()
sm.mutex.Lock()
defer sm.mutex.Unlock()
// Clean up pipes
if sm.stdin != nil {
sm.stdin.Close()
sm.stdin = nil
}
if sm.stdout != nil {
sm.stdout.Close()
sm.stdout = nil
}
if sm.stderr != nil {
sm.stderr.Close()
sm.stderr = nil
}
sm.isRunning = false
sm.currentCmd = nil
sm.currentCancel = nil
if err != nil {
log.E.F("sprocket process exited with error: %v", err)
// Auto-disable sprocket on failure
sm.disabled = true
log.W.F("sprocket disabled due to process failure - all events will be rejected (script location: %s)", sm.scriptPath)
} else {
log.I.F("sprocket process exited normally")
}
}
// Shutdown gracefully shuts down the sprocket manager
func (sm *SprocketManager) Shutdown() {
sm.cancel()
if sm.isRunning {
sm.StopSprocket()
}
}

View File

@@ -0,0 +1,449 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
"next.orly.dev/app/config"
"next.orly.dev/pkg/database"
"next.orly.dev/pkg/encoders/event"
"next.orly.dev/pkg/encoders/tag"
"next.orly.dev/pkg/interfaces/signer/p8k"
"next.orly.dev/pkg/protocol/publish"
)
// createSignedTestEvent creates a properly signed test event for use in tests
func createSignedTestEvent(t *testing.T, kind uint16, content string, tags ...*tag.T) *event.E {
t.Helper()
// Create a signer
signer, err := p8k.New()
if err != nil {
t.Fatalf("Failed to create signer: %v", err)
}
defer signer.Zero()
// Generate a keypair
if err := signer.Generate(); err != nil {
t.Fatalf("Failed to generate keypair: %v", err)
}
// Create event
ev := &event.E{
Kind: kind,
Content: []byte(content),
CreatedAt: time.Now().Unix(),
Tags: &tag.S{},
}
// Add any provided tags
for _, tg := range tags {
*ev.Tags = append(*ev.Tags, tg)
}
// Sign the event (this sets Pubkey, ID, and Sig)
if err := ev.Sign(signer); err != nil {
t.Fatalf("Failed to sign event: %v", err)
}
return ev
}
// TestLongRunningSubscriptionStability verifies that subscriptions remain active
// for extended periods and correctly receive real-time events without dropping.
func TestLongRunningSubscriptionStability(t *testing.T) {
// Create test server
server, cleanup := setupTestServer(t)
defer cleanup()
// Start HTTP test server
httpServer := httptest.NewServer(server)
defer httpServer.Close()
// Convert HTTP URL to WebSocket URL
wsURL := strings.Replace(httpServer.URL, "http://", "ws://", 1)
// Connect WebSocket client
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Failed to connect WebSocket: %v", err)
}
defer conn.Close()
// Subscribe to kind 1 events
subID := "test-long-running"
reqMsg := fmt.Sprintf(`["REQ","%s",{"kinds":[1]}]`, subID)
if err := conn.WriteMessage(websocket.TextMessage, []byte(reqMsg)); err != nil {
t.Fatalf("Failed to send REQ: %v", err)
}
// Read until EOSE
gotEOSE := false
for !gotEOSE {
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if strings.Contains(string(msg), `"EOSE"`) && strings.Contains(string(msg), subID) {
gotEOSE = true
t.Logf("Received EOSE for subscription %s", subID)
}
}
// Set up event counter
var receivedCount atomic.Int64
var mu sync.Mutex
receivedEvents := make(map[string]bool)
// Start goroutine to read events
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
readDone := make(chan struct{})
go func() {
defer close(readDone)
defer func() {
// Recover from any panic in read goroutine
if r := recover(); r != nil {
t.Logf("Read goroutine panic (recovered): %v", r)
}
}()
for {
// Check context first before attempting any read
select {
case <-ctx.Done():
return
default:
}
// Use a longer deadline and check context more frequently
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Immediately check if context is done - if so, just exit without continuing
if ctx.Err() != nil {
return
}
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
// Check if this is a timeout error - those are recoverable
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Double-check context before continuing
if ctx.Err() != nil {
return
}
continue
}
// Any other error means connection is broken, exit
t.Logf("Read error (non-timeout): %v", err)
return
}
// Parse message to check if it's an EVENT for our subscription
var envelope []interface{}
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) >= 3 && envelope[0] == "EVENT" && envelope[1] == subID {
// Extract event ID
eventMap, ok := envelope[2].(map[string]interface{})
if !ok {
continue
}
eventID, ok := eventMap["id"].(string)
if !ok {
continue
}
mu.Lock()
if !receivedEvents[eventID] {
receivedEvents[eventID] = true
receivedCount.Add(1)
t.Logf("Received event %s (total: %d)", eventID[:8], receivedCount.Load())
}
mu.Unlock()
}
}
}()
// Publish events at regular intervals over 30 seconds
const numEvents = 30
const publishInterval = 1 * time.Second
publishCtx, publishCancel := context.WithTimeout(context.Background(), 35*time.Second)
defer publishCancel()
for i := 0; i < numEvents; i++ {
select {
case <-publishCtx.Done():
t.Fatalf("Publish timeout exceeded")
default:
}
// Create and sign test event
ev := createSignedTestEvent(t, 1, fmt.Sprintf("Test event %d for long-running subscription", i))
// Save event to database
if _, err := server.D.SaveEvent(context.Background(), ev); err != nil {
t.Errorf("Failed to save event %d: %v", i, err)
continue
}
// Manually trigger publisher to deliver event to subscriptions
server.publishers.Deliver(ev)
t.Logf("Published event %d", i)
// Wait before next publish
if i < numEvents-1 {
time.Sleep(publishInterval)
}
}
// Wait a bit more for all events to be delivered
time.Sleep(3 * time.Second)
// Cancel context and wait for reader to finish
cancel()
<-readDone
// Check results
received := receivedCount.Load()
t.Logf("Test complete: published %d events, received %d events", numEvents, received)
// We should receive at least 90% of events (allowing for some timing edge cases)
minExpected := int64(float64(numEvents) * 0.9)
if received < minExpected {
t.Errorf("Subscription stability issue: expected at least %d events, got %d", minExpected, received)
}
// Close subscription
closeMsg := fmt.Sprintf(`["CLOSE","%s"]`, subID)
if err := conn.WriteMessage(websocket.TextMessage, []byte(closeMsg)); err != nil {
t.Errorf("Failed to send CLOSE: %v", err)
}
t.Logf("Long-running subscription test PASSED: %d/%d events delivered", received, numEvents)
}
// TestMultipleConcurrentSubscriptions verifies that multiple subscriptions
// can coexist on the same connection without interfering with each other.
func TestMultipleConcurrentSubscriptions(t *testing.T) {
// Create test server
server, cleanup := setupTestServer(t)
defer cleanup()
// Start HTTP test server
httpServer := httptest.NewServer(server)
defer httpServer.Close()
// Convert HTTP URL to WebSocket URL
wsURL := strings.Replace(httpServer.URL, "http://", "ws://", 1)
// Connect WebSocket client
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("Failed to connect WebSocket: %v", err)
}
defer conn.Close()
// Create 3 subscriptions for different kinds
subscriptions := []struct {
id string
kind int
}{
{"sub1", 1},
{"sub2", 3},
{"sub3", 7},
}
// Subscribe to all
for _, sub := range subscriptions {
reqMsg := fmt.Sprintf(`["REQ","%s",{"kinds":[%d]}]`, sub.id, sub.kind)
if err := conn.WriteMessage(websocket.TextMessage, []byte(reqMsg)); err != nil {
t.Fatalf("Failed to send REQ for %s: %v", sub.id, err)
}
}
// Read until we get EOSE for all subscriptions
eoseCount := 0
for eoseCount < len(subscriptions) {
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("Failed to read message: %v", err)
}
if strings.Contains(string(msg), `"EOSE"`) {
eoseCount++
t.Logf("Received EOSE %d/%d", eoseCount, len(subscriptions))
}
}
// Track received events per subscription
var mu sync.Mutex
receivedByKind := make(map[int]int)
// Start reader goroutine
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
readDone := make(chan struct{})
go func() {
defer close(readDone)
defer func() {
// Recover from any panic in read goroutine
if r := recover(); r != nil {
t.Logf("Read goroutine panic (recovered): %v", r)
}
}()
for {
// Check context first before attempting any read
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
// Immediately check if context is done - if so, just exit without continuing
if ctx.Err() != nil {
return
}
// Check for normal close
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return
}
// Check if this is a timeout error - those are recoverable
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
// Double-check context before continuing
if ctx.Err() != nil {
return
}
continue
}
// Any other error means connection is broken, exit
t.Logf("Read error (non-timeout): %v", err)
return
}
// Parse message
var envelope []interface{}
if err := json.Unmarshal(msg, &envelope); err != nil {
continue
}
if len(envelope) >= 3 && envelope[0] == "EVENT" {
eventMap, ok := envelope[2].(map[string]interface{})
if !ok {
continue
}
kindFloat, ok := eventMap["kind"].(float64)
if !ok {
continue
}
kind := int(kindFloat)
mu.Lock()
receivedByKind[kind]++
t.Logf("Received event for kind %d (count: %d)", kind, receivedByKind[kind])
mu.Unlock()
}
}
}()
// Publish events for each kind
for _, sub := range subscriptions {
for i := 0; i < 5; i++ {
// Create and sign test event
ev := createSignedTestEvent(t, uint16(sub.kind), fmt.Sprintf("Test for kind %d event %d", sub.kind, i))
if _, err := server.D.SaveEvent(context.Background(), ev); err != nil {
t.Errorf("Failed to save event: %v", err)
}
// Manually trigger publisher to deliver event to subscriptions
server.publishers.Deliver(ev)
time.Sleep(100 * time.Millisecond)
}
}
// Wait for events to be delivered
time.Sleep(2 * time.Second)
// Cancel and cleanup
cancel()
<-readDone
// Verify each subscription received its events
mu.Lock()
defer mu.Unlock()
for _, sub := range subscriptions {
count := receivedByKind[sub.kind]
if count < 4 { // Allow for some timing issues, expect at least 4/5
t.Errorf("Subscription %s (kind %d) only received %d/5 events", sub.id, sub.kind, count)
}
}
t.Logf("Multiple concurrent subscriptions test PASSED")
}
// setupTestServer creates a test relay server for subscription testing
func setupTestServer(t *testing.T) (*Server, func()) {
// Setup test database
ctx, cancel := context.WithCancel(context.Background())
// Use a temporary directory for the test database
tmpDir := t.TempDir()
db, err := database.New(ctx, cancel, tmpDir, "test.db")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
// Setup basic config
cfg := &config.C{
AuthRequired: false,
Owners: []string{},
Admins: []string{},
ACLMode: "none",
}
// Setup server
server := &Server{
Config: cfg,
D: db,
Ctx: ctx,
publishers: publish.New(NewPublisher(ctx)),
Admins: [][]byte{},
Owners: [][]byte{},
challenges: make(map[string][]byte),
}
// Cleanup function
cleanup := func() {
db.Close()
cancel()
}
return server, cleanup
}

132
app/tls.go Normal file
View File

@@ -0,0 +1,132 @@
package app
import (
"crypto/tls"
"crypto/x509"
"fmt"
"strings"
"sync"
"golang.org/x/crypto/acme/autocert"
"lol.mleku.dev/chk"
"lol.mleku.dev/log"
)
// TLSConfig returns a TLS configuration that works with LetsEncrypt automatic SSL cert issuer
// as well as any provided certificate files from providers.
//
// The certs are provided in the form of paths where .pem and .key files exist
func TLSConfig(m *autocert.Manager, certs ...string) (tc *tls.Config) {
certMap := make(map[string]*tls.Certificate)
var mx sync.Mutex
for _, certPath := range certs {
if certPath == "" {
continue
}
var err error
var c tls.Certificate
// Load certificate and key files
if c, err = tls.LoadX509KeyPair(
certPath+".pem", certPath+".key",
); chk.E(err) {
log.E.F("failed to load certificate from %s: %v", certPath, err)
continue
}
// Extract domain names from certificate
if len(c.Certificate) > 0 {
if x509Cert, err := x509.ParseCertificate(c.Certificate[0]); err == nil {
// Use the common name as the primary domain
if x509Cert.Subject.CommonName != "" {
certMap[x509Cert.Subject.CommonName] = &c
log.I.F("loaded certificate for domain: %s", x509Cert.Subject.CommonName)
}
// Also add any subject alternative names
for _, san := range x509Cert.DNSNames {
if san != "" {
certMap[san] = &c
log.I.F("loaded certificate for SAN domain: %s", san)
}
}
}
}
}
if m == nil {
// Create a basic TLS config without autocert
tc = &tls.Config{
GetCertificate: func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) {
mx.Lock()
defer mx.Unlock()
// Check for exact match first
if cert, exists := certMap[helo.ServerName]; exists {
return cert, nil
}
// Check for wildcard matches
for domain, cert := range certMap {
if strings.HasPrefix(domain, "*.") {
baseDomain := domain[2:] // Remove "*."
if strings.HasSuffix(helo.ServerName, baseDomain) {
return cert, nil
}
}
}
return nil, fmt.Errorf("no certificate found for %s", helo.ServerName)
},
}
} else {
tc = m.TLSConfig()
tc.GetCertificate = func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) {
mx.Lock()
// Check for exact match first
if cert, exists := certMap[helo.ServerName]; exists {
mx.Unlock()
return cert, nil
}
// Check for wildcard matches
for domain, cert := range certMap {
if strings.HasPrefix(domain, "*.") {
baseDomain := domain[2:] // Remove "*."
if strings.HasSuffix(helo.ServerName, baseDomain) {
mx.Unlock()
return cert, nil
}
}
}
mx.Unlock()
// Fall back to autocert for domains not in our certificate map
return m.GetCertificate(helo)
}
}
return tc
}
// ValidateTLSConfig checks if the TLS configuration is valid
func ValidateTLSConfig(domains []string, certs []string) (err error) {
if len(domains) == 0 {
return fmt.Errorf("no TLS domains specified")
}
// Validate domain names
for _, domain := range domains {
if domain == "" {
continue
}
if strings.Contains(domain, " ") || strings.Contains(domain, "\t") {
return fmt.Errorf("invalid domain name: %s", domain)
}
}
return nil
}

View File

@@ -16,4 +16,10 @@ func GetReactAppFS() http.FileSystem {
panic("Failed to load embedded web app: " + err.Error())
}
return http.FS(webDist)
}
}
// ServeEmbeddedWeb serves the embedded web application
func ServeEmbeddedWeb(w http.ResponseWriter, r *http.Request) {
// Serve the embedded web app
http.FileServer(GetReactAppFS()).ServeHTTP(w, r)
}

41
app/web/.gitignore vendored
View File

@@ -1,30 +1,11 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Bun
.bunfig.toml
bun.lockb
# Build directories
build
# Cache and logs
.cache
.temp
.log
*.log
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Editor directories and files
.idea
.vscode
*.swp
*.swo
node_modules/
dist/
.vite/
.tanstack/
.idea/
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
/.idea/

View File

@@ -1,89 +0,0 @@
# Orly Web Application
This is a React web application that uses Bun for building and bundling, and is automatically embedded into the Go binary when built.
## Prerequisites
- [Bun](https://bun.sh/) - JavaScript runtime and toolkit
- Go 1.16+ (for embedding functionality)
## Development
There are two ways to develop the web app:
1) Standalone (recommended for hot reload)
- Start the Go relay with the embedded web UI disabled so the React app can run on its own dev server with HMR.
- Configure the relay via environment variables:
```bash
# In another shell at repo root
export ORLY_WEB_DISABLE=true
# Optional: if you want same-origin URLs, you can set a proxy target and access the relay on the same port
# export ORLY_WEB_DEV_PROXY_URL=http://localhost:5173
# Start the relay as usual
go run .
```
- Then start the React dev server:
```bash
cd app/web
bun install
bun dev
```
When ORLY_WEB_DISABLE=true is set, the Go server still serves the API and websocket endpoints and sends permissive CORS headers, so the dev server can access them cross-origin. If ORLY_WEB_DEV_PROXY_URL is set, the Go server will reverse-proxy non-/api paths to the dev server so you can use the same origin.
2) Embedded (no hot reload)
- Build the web app and run the Go server with defaults:
```bash
cd app/web
bun install
bun run build
cd ../../
go run .
```
## Building
The React application needs to be built before compiling the Go binary to ensure that the embedded files are available:
```bash
# Build the React application
cd app/web
bun install
bun run build
# Build the Go binary from project root
cd ../../
go build
```
## How it works
1. The React application is built to the `app/web/dist` directory
2. The Go embed directive in `app/web.go` embeds these files into the binary
3. When the server runs, it serves the embedded React app at the root path
## Build Automation
You can create a shell script to automate the build process:
```bash
#!/bin/bash
# build.sh
echo "Building React app..."
cd app/web
bun install
bun run build
echo "Building Go binary..."
cd ../../
go build
echo "Build complete!"
```
Make it executable with `chmod +x build.sh` and run with `./build.sh`.

View File

@@ -2,35 +2,319 @@
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "orly-web",
"name": "svelte-app",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"applesauce-core": "^4.1.0",
"applesauce-signers": "^4.1.0",
"nostr-tools": "^2.17.0",
"sirv-cli": "^2.0.0",
},
"devDependencies": {
"bun-types": "latest",
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-terser": "^0.4.0",
"rollup": "^3.15.0",
"rollup-plugin-copy": "^3.5.0",
"rollup-plugin-css-only": "^4.3.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.2",
"svelte": "^3.55.0",
},
},
},
"packages": {
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
"@noble/secp256k1": ["@noble/secp256k1@1.7.2", "", {}, "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@24.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "glob": "^8.0.3", "is-reference": "1.2.1", "magic-string": "^0.27.0" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0" }, "optionalPeers": ["rollup"] }, "sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@15.3.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA=="],
"@rollup/plugin-terser": ["@rollup/plugin-terser@0.4.4", "", { "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
"@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/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/fs-extra": ["@types/fs-extra@8.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ=="],
"@types/glob": ["@types/glob@7.2.0", "", { "dependencies": { "@types/minimatch": "*", "@types/node": "*" } }, "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA=="],
"@types/minimatch": ["@types/minimatch@6.0.0", "", { "dependencies": { "minimatch": "*" } }, "sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA=="],
"@types/node": ["@types/node@24.7.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"applesauce-core": ["applesauce-core@4.1.0", "", { "dependencies": { "@noble/hashes": "^1.7.1", "@scure/base": "^1.2.4", "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "light-bolt11-decoder": "^3.2.0", "nanoid": "^5.0.9", "nostr-tools": "~2.17", "rxjs": "^7.8.1" } }, "sha512-vFOHfqWW4DJfvPkMYLYNiy2ozO2IF+ZNwetGqaLuPjgE1Iwu4trZmG3GJUH+lO1Oq1N4e/OQ/EcotJoEBEiW7Q=="],
"applesauce-signers": ["applesauce-signers@4.1.0", "", { "dependencies": { "@noble/hashes": "^1.7.1", "@noble/secp256k1": "^1.7.1", "@scure/base": "^1.2.4", "applesauce-core": "^4.1.0", "debug": "^4.4.0", "nanoid": "^5.0.9", "nostr-tools": "~2.17", "rxjs": "^7.8.2" } }, "sha512-S+nTkAt1CAGhalwI7warLTINsxxjBpS3NqbViz6LVy1ZrzEqaNirlalX+rbCjxjRrvIGhYV+rszkxDFhCYbPkg=="],
"array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"console-clear": ["console-clear@1.1.1", "", {}, "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-port": ["get-port@3.2.0", "", {}, "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg=="],
"glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"globby": ["globby@10.0.1", "", { "dependencies": { "@types/glob": "^7.1.1", "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.0.3", "glob": "^7.1.3", "ignore": "^5.1.1", "merge2": "^1.2.3", "slash": "^3.0.0" } }, "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hash-sum": ["hash-sum@2.0.0", "", {}, "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-object": ["is-plain-object@3.0.1", "", {}, "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g=="],
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"light-bolt11-decoder": ["light-bolt11-decoder@3.2.0", "", { "dependencies": { "@scure/base": "1.1.1" } }, "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ=="],
"livereload": ["livereload@0.9.3", "", { "dependencies": { "chokidar": "^3.5.0", "livereload-js": "^3.3.1", "opts": ">= 1.2.0", "ws": "^7.4.3" }, "bin": { "livereload": "bin/livereload.js" } }, "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw=="],
"livereload-js": ["livereload-js@3.4.1", "", {}, "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g=="],
"local-access": ["local-access@1.1.0", "", {}, "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw=="],
"magic-string": ["magic-string@0.27.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.13" } }, "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"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=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"opts": ["opts@2.0.2", "", {}, "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@3.29.5", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w=="],
"rollup-plugin-copy": ["rollup-plugin-copy@3.5.0", "", { "dependencies": { "@types/fs-extra": "^8.0.1", "colorette": "^1.1.0", "fs-extra": "^8.1.0", "globby": "10.0.1", "is-plain-object": "^3.0.0" } }, "sha512-wI8D5dvYovRMx/YYKtUNt3Yxaw4ORC9xo6Gt9t22kveWz1enG9QrhVlagzwrxSC455xD1dHMKhIJkbsQ7d48BA=="],
"rollup-plugin-css-only": ["rollup-plugin-css-only@4.5.5", "", { "dependencies": { "@rollup/pluginutils": "5" }, "peerDependencies": { "rollup": "<5" } }, "sha512-O2m2Sj8qsAtjUVqZyGTDXJypaOFFNV4knz8OlS6wJBws6XEICIiLsXmI56SbQEmWDqYU5TgRgWmslGj4THofJQ=="],
"rollup-plugin-livereload": ["rollup-plugin-livereload@2.0.5", "", { "dependencies": { "livereload": "^0.9.1" } }, "sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA=="],
"rollup-plugin-svelte": ["rollup-plugin-svelte@7.2.3", "", { "dependencies": { "@rollup/pluginutils": "^4.1.0", "resolve.exports": "^2.0.0" }, "peerDependencies": { "rollup": ">=2.0.0", "svelte": ">=3.5.0" } }, "sha512-LlniP+h00DfM+E4eav/Kk8uGjgPUjGIBfrAS/IxQvsuFdqSM0Y2sXf31AdxuIGSW9GsmocDqOfaxR5QNno/Tgw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"semiver": ["semiver@1.1.0", "", {}, "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg=="],
"serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="],
"sirv": ["sirv@2.0.4", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="],
"sirv-cli": ["sirv-cli@2.0.2", "", { "dependencies": { "console-clear": "^1.1.0", "get-port": "^3.2.0", "kleur": "^4.1.4", "local-access": "^1.0.1", "sade": "^1.6.0", "semiver": "^1.0.0", "sirv": "^2.0.0", "tinydate": "^1.0.0" }, "bin": { "sirv": "bin.js" } }, "sha512-OtSJDwxsF1NWHc7ps3Sa0s+dPtP15iQNJzfKVz+MxkEo3z72mCD+yu30ct79rPr0CaV1HXSOBp+MIY5uIhHZ1A=="],
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"smob": ["smob@1.5.0", "", {}, "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@3.59.2", "", {}, "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA=="],
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
"tinydate": ["tinydate@1.3.0", "", {}, "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"@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/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"@scure/bip32/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
"@scure/bip39/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"@scure/bip39/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"globby/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"light-bolt11-decoder/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"nostr-tools/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"nostr-tools/@scure/base": ["@scure/base@1.1.1", "", {}, "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"rollup-plugin-svelte/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="],
"@scure/bip32/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.1", "", {}, "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA=="],
"globby/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"rollup-plugin-svelte/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"globby/glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
}
}

View File

@@ -1 +0,0 @@
*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}html,body,#root{height:100%}html{-webkit-text-size-adjust:100%;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Noto Sans,Ubuntu,Cantarell,Helvetica Neue,Helvetica,Arial,"\"Apple Color Emoji\"","\"Segoe UI Emoji\"";line-height:1.5}body{margin:0}button,input{font:inherit;color:inherit}img{display:block;max-width:100%;height:auto}.sticky{position:sticky}.relative{position:relative}.absolute{position:absolute}.top-0{top:0}.left-0{left:0}.inset-0{inset:0}.z-50{z-index:50}.z-10{z-index:10}.block{display:block}.flex{display:flex}.items-center{align-items: center}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.flex-grow{flex-grow:1}.shrink-0{flex-shrink:0}.overflow-hidden{overflow:hidden}.w-full{width:100%}.w-auto{width:auto}.w-16{width:4rem}.h-full{height:100%}.h-16{height:4rem}.aspect-square{aspect-ratio:1}.max-w-3xl{max-width:48rem}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.mr-0{margin-right:0}.mr-2{margin-right:.5rem}.mt-2{margin-top:.5rem}.mt-5{margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.border-0{border-width:0}.border-2{border-width:2px}.border-white{border-color:#fff}.border{border-width:1px}.border-gray-300{border-color:#d1d5db}.bg-white{background-color:#fff}.bg-gray-100{background-color:#f3f4f6}.bg-blue-600{background-color:#2563eb}.hover\:bg-blue-700:hover{background-color:#1d4ed8}.bg-red-600{background-color:#dc2626}.hover\:bg-red-700:hover{background-color:#b91c1c}.bg-cyan-100{background-color:#cffafe}.bg-green-100{background-color:#d1fae5}.bg-red-100{background-color:#fee2e2}.bg-cover{background-size:cover}.bg-center{background-position:50%}.bg-transparent{background-color:#0000}.text-left{text-align:left}.text-white{color:#fff}.text-gray-500{color:#6b7280}.hover\:text-gray-800:hover{color:#1f2937}.text-green-800{color:#065f46}.text-red-800{color:#991b1b}.text-cyan-800{color:#155e75}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.font-bold{font-weight:700}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;box-shadow:var(--tw-shadow)}.cursor-pointer{cursor:pointer}.box-border{box-sizing:border-box}.hover\:bg-transparent:hover{background-color:#0000}body{margin:0;padding:0;font-family:Arial,sans-serif}.container{background:#f9f9f9;border-radius:8px;margin-top:20px;padding:30px}.form-group{margin-bottom:20px}label{display:block;margin-bottom:5px;font-weight:700}input,textarea{border:1px solid #ddd;border-radius:4px;width:100%;padding:10px}button{color:#fff;cursor:pointer;background:#007cba;border:none;border-radius:4px;padding:12px 20px}button:hover{background:#005a87}.danger-button{background:#dc3545}.danger-button:hover{background:#c82333}.status{border-radius:4px;margin-top:20px;margin-bottom:20px;padding:10px}.success{color:#155724;background:#d4edda}.error{color:#721c24;background:#f8d7da}.info{color:#0c5460;background:#d1ecf1}.header-panel{position:sticky;z-index:1000;display:flex;overflow:hidden;background-color:#f8f9fa;background-position:50%;background-size:cover;align-items: center;width:100%;height:60px;top:0;left:0;box-shadow:0 2px 4px #0000001a}.header-content{display:flex;box-sizing:border-box;align-items: center;width:100%;height:100%;margin:0 auto;padding:0 0 0 12px}.header-left{display:flex;justify-content:flex-start;align-items: center;height:100%}.header-center{display:flex;position:relative;overflow:hidden;flex-grow:1;justify-content:flex-start;align-items: center}.header-right{display:flex;justify-content:flex-end;align-items: center;height:100%}.header-logo{aspect-ratio:1;object-fit:cover;border-radius:0;flex-shrink:0;width:auto;height:100%}.user-avatar{object-fit:cover;border:2px solid #fff;border-radius:50%;width:40px;height:40px;margin-right:10px;box-shadow:0 1px 3px #0003}.user-profile{display:flex;position:relative;z-index:1;align-items: center}.user-info{text-align:left;font-size:1.2em;font-weight:700}.user-name{display:block;font-size:1em;font-weight:700}.profile-banner{position:absolute;z-index:-1;opacity:.7;width:100%;height:100%;top:0;left:0}.logout-button{color:#6c757d;cursor:pointer;display:flex;background:0 0;border:none;flex-shrink:0;justify-content:center;align-items: center;width:48px;height:100%;margin-left:10px;margin-right:0;padding:0;font-size:20px}.logout-button:hover{color:#343a40;background:0 0}

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,17 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nostr Relay</title>
<link rel="stylesheet" crossorigin href="./index-zhtd763e.css"><script type="module" crossorigin src="./index-zqddcpy5.js"></script></head>
<body class="bg-white">
<div id="root"></div>
</body>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ORLY?</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/global.css" />
<link rel="stylesheet" href="/bundle.css" />
<script defer src="/bundle.js"></script>
</head>
<body></body>
</html>

View File

@@ -1,82 +0,0 @@
/*
Local Tailwind CSS (minimal subset for this UI)
Note: This file includes just the utilities used by the app to keep size small.
You can replace this with a full Tailwind build if desired.
*/
/* Preflight-like resets (very minimal) */
*,::before,::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}
html,body,#root{height:100%}
html{line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,Noto Sans,\"Apple Color Emoji\",\"Segoe UI Emoji\"}
body{margin:0}
button,input{font:inherit;color:inherit}
img{display:block;max-width:100%;height:auto}
/* Layout */
.sticky{position:sticky}.relative{position:relative}.absolute{position:absolute}
.top-0{top:0}.left-0{left:0}.inset-0{top:0;right:0;bottom:0;left:0}
.z-50{z-index:50}.z-10{z-index:10}
.block{display:block}.flex{display:flex}
.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}
.flex-grow{flex-grow:1}.shrink-0{flex-shrink:0}
.overflow-hidden{overflow:hidden}
/* Sizing */
.w-full{width:100%}.w-auto{width:auto}.w-16{width:4rem}
.h-full{height:100%}.h-16{height:4rem}
.aspect-square{aspect-ratio:1/1}
.max-w-3xl{max-width:48rem}
/* Spacing */
.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}
.px-2{padding-left:.5rem;padding-right:.5rem}
.mr-0{margin-right:0}.mr-2{margin-right:.5rem}
.mt-2{margin-top:.5rem}.mt-5{margin-top:1.25rem}
.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}
.mx-auto{margin-left:auto;margin-right:auto}
/* Borders & Radius */
.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}
.border-0{border-width:0}.border-2{border-width:2px}
.border-white{border-color:#fff}
.border{border-width:1px}.border-gray-300{border-color:#d1d5db}
/* Colors / Backgrounds */
.bg-white{background-color:#fff}
.bg-gray-100{background-color:#f3f4f6}
.bg-blue-600{background-color:#2563eb}.hover\:bg-blue-700:hover{background-color:#1d4ed8}
.bg-red-600{background-color:#dc2626}.hover\:bg-red-700:hover{background-color:#b91c1c}
.bg-cyan-100{background-color:#cffafe}
.bg-green-100{background-color:#d1fae5}
.bg-red-100{background-color:#fee2e2}
.bg-cover{background-size:cover}.bg-center{background-position:center}
.bg-transparent{background-color:transparent}
/* Text */
.text-left{text-align:left}
.text-white{color:#fff}
.text-gray-500{color:#6b7280}.hover\:text-gray-800:hover{color:#1f2937}
.text-green-800{color:#065f46}
.text-red-800{color:#991b1b}
.text-cyan-800{color:#155e75}
.text-base{font-size:1rem;line-height:1.5rem}
.text-lg{font-size:1.125rem;line-height:1.75rem}
.text-2xl{font-size:1.5rem;line-height:2rem}
.font-bold{font-weight:700}
/* Opacity */
.opacity-70{opacity:.7}
/* Effects */
.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px -1px rgba(0,0,0,0.1);box-shadow:var(--tw-shadow)}
/* Cursor */
.cursor-pointer{cursor:pointer}
/* Box model */
.box-border{box-sizing:border-box}
/* Utilities */
.hover\:bg-transparent:hover{background-color:transparent}
/* Height for avatar images in header already inherit from container */

BIN
app/web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

1602
app/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,28 @@
{
"name": "orly-web",
"version": "0.1.0",
"name": "svelte-app",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot --port 5173 public/dev.html",
"build": "rm -rf dist && bun build ./public/index.html --outdir ./dist --minify --splitting && cp -r public/tailwind.min.css dist/",
"preview": "bun x serve dist"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public --no-clear"
},
"devDependencies": {
"bun-types": "latest"
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-terser": "^0.4.0",
"rollup": "^3.15.0",
"rollup-plugin-copy": "^3.5.0",
"rollup-plugin-css-only": "^4.3.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.2",
"svelte": "^3.55.0"
},
"dependencies": {
"applesauce-core": "^4.1.0",
"applesauce-signers": "^4.1.0",
"nostr-tools": "^2.17.0",
"sirv-cli": "^2.0.0"
}
}
}

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nostr Relay (Dev)</title>
<link rel="stylesheet" href="tailwind.min.css" />
</head>
<body class="bg-white">
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

BIN
app/web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

69
app/web/public/global.css Normal file
View File

@@ -0,0 +1,69 @@
html,
body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0, 100, 200);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0, 80, 160);
}
label {
display: block;
}
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

View File

@@ -1,13 +1,17 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nostr Relay</title>
<link rel="stylesheet" href="tailwind.min.css" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>ORLY?</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/global.css" />
<link rel="stylesheet" href="/bundle.css" />
<script defer src="/bundle.js"></script>
</head>
<body class="bg-white">
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
<body></body>
</html>

BIN
app/web/public/orly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

View File

@@ -1,82 +0,0 @@
/*
Local Tailwind CSS (minimal subset for this UI)
Note: This file includes just the utilities used by the app to keep size small.
You can replace this with a full Tailwind build if desired.
*/
/* Preflight-like resets (very minimal) */
*,::before,::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}
html,body,#root{height:100%}
html{line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,Noto Sans,\"Apple Color Emoji\",\"Segoe UI Emoji\"}
body{margin:0}
button,input{font:inherit;color:inherit}
img{display:block;max-width:100%;height:auto}
/* Layout */
.sticky{position:sticky}.relative{position:relative}.absolute{position:absolute}
.top-0{top:0}.left-0{left:0}.inset-0{top:0;right:0;bottom:0;left:0}
.z-50{z-index:50}.z-10{z-index:10}
.block{display:block}.flex{display:flex}
.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}
.flex-grow{flex-grow:1}.shrink-0{flex-shrink:0}
.overflow-hidden{overflow:hidden}
/* Sizing */
.w-full{width:100%}.w-auto{width:auto}.w-16{width:4rem}
.h-full{height:100%}.h-16{height:4rem}
.aspect-square{aspect-ratio:1/1}
.max-w-3xl{max-width:48rem}
/* Spacing */
.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}
.px-2{padding-left:.5rem;padding-right:.5rem}
.mr-0{margin-right:0}.mr-2{margin-right:.5rem}
.mt-2{margin-top:.5rem}.mt-5{margin-top:1.25rem}
.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}
.mx-auto{margin-left:auto;margin-right:auto}
/* Borders & Radius */
.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}
.border-0{border-width:0}.border-2{border-width:2px}
.border-white{border-color:#fff}
.border{border-width:1px}.border-gray-300{border-color:#d1d5db}
/* Colors / Backgrounds */
.bg-white{background-color:#fff}
.bg-gray-100{background-color:#f3f4f6}
.bg-blue-600{background-color:#2563eb}.hover\:bg-blue-700:hover{background-color:#1d4ed8}
.bg-red-600{background-color:#dc2626}.hover\:bg-red-700:hover{background-color:#b91c1c}
.bg-cyan-100{background-color:#cffafe}
.bg-green-100{background-color:#d1fae5}
.bg-red-100{background-color:#fee2e2}
.bg-cover{background-size:cover}.bg-center{background-position:center}
.bg-transparent{background-color:transparent}
/* Text */
.text-left{text-align:left}
.text-white{color:#fff}
.text-gray-500{color:#6b7280}.hover\:text-gray-800:hover{color:#1f2937}
.text-green-800{color:#065f46}
.text-red-800{color:#991b1b}
.text-cyan-800{color:#155e75}
.text-base{font-size:1rem;line-height:1.5rem}
.text-lg{font-size:1.125rem;line-height:1.75rem}
.text-2xl{font-size:1.5rem;line-height:2rem}
.font-bold{font-weight:700}
/* Opacity */
.opacity-70{opacity:.7}
/* Effects */
.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,0.1),0 1px 2px -1px rgba(0,0,0,0.1);box-shadow:var(--tw-shadow)}
/* Cursor */
.cursor-pointer{cursor:pointer}
/* Box model */
.box-border{box-sizing:border-box}
/* Utilities */
.hover\:bg-transparent:hover{background-color:transparent}
/* Height for avatar images in header already inherit from container */

3
app/web/readme.adoc Normal file
View File

@@ -0,0 +1,3 @@
= nostrly.app
a simple, material design nostr kind 1 nostr note client

90
app/web/rollup.config.js Normal file
View File

@@ -0,0 +1,90 @@
import { spawn } from "child_process";
import svelte from "rollup-plugin-svelte";
import commonjs from "@rollup/plugin-commonjs";
import terser from "@rollup/plugin-terser";
import resolve from "@rollup/plugin-node-resolve";
import livereload from "rollup-plugin-livereload";
import css from "rollup-plugin-css-only";
import copy from "rollup-plugin-copy";
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = spawn("npm", ["run", "start", "--", "--dev"], {
stdio: ["ignore", "inherit", "inherit"],
shell: true,
});
process.on("SIGTERM", toExit);
process.on("exit", toExit);
},
};
}
export default {
input: "src/main.js",
output: {
sourcemap: true,
format: "iife",
name: "app",
file: "dist/bundle.js",
},
plugins: [
svelte({
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
},
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: "bundle.css" }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ["svelte"],
exportConditions: ["svelte"],
}),
commonjs(),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload("public"),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
// Copy static files from public to dist
copy({
targets: [
{ src: 'public/index.html', dest: 'dist' },
{ src: 'public/global.css', dest: 'dist' },
{ src: 'public/favicon.png', dest: 'dist' },
{ src: 'public/orly.png', dest: 'dist' },
{ src: 'public/orly-favicon.png', dest: 'dist' }
]
}),
],
watch: {
clearScreen: false,
},
};

View File

@@ -0,0 +1,147 @@
// @ts-check
/** This script modifies the project to support TS code in .svelte files like:
<script lang="ts">
export let name: string;
</script>
As well as validating the code for CI.
*/
/** To work on this script:
rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template
*/
import fs from "fs";
import path from "path";
import { argv } from "process";
import url from "url";
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
const projectRoot = argv[2] || path.join(__dirname, "..");
// Add deps to pkg.json
const packageJSON = JSON.parse(
fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"),
);
packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, {
"svelte-check": "^3.0.0",
"svelte-preprocess": "^5.0.0",
"@rollup/plugin-typescript": "^11.0.0",
typescript: "^4.9.0",
tslib: "^2.5.0",
"@tsconfig/svelte": "^3.0.0",
});
// Add script for checking
packageJSON.scripts = Object.assign(packageJSON.scripts, {
check: "svelte-check",
});
// Write the package JSON
fs.writeFileSync(
path.join(projectRoot, "package.json"),
JSON.stringify(packageJSON, null, " "),
);
// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too
const beforeMainJSPath = path.join(projectRoot, "src", "main.js");
const afterMainTSPath = path.join(projectRoot, "src", "main.ts");
fs.renameSync(beforeMainJSPath, afterMainTSPath);
// Switch the app.svelte file to use TS
const appSveltePath = path.join(projectRoot, "src", "App.svelte");
let appFile = fs.readFileSync(appSveltePath, "utf8");
appFile = appFile.replace("<script>", '<script lang="ts">');
appFile = appFile.replace("export let name;", "export let name: string;");
fs.writeFileSync(appSveltePath, appFile);
// Edit rollup config
const rollupConfigPath = path.join(projectRoot, "rollup.config.js");
let rollupConfig = fs.readFileSync(rollupConfigPath, "utf8");
// Edit imports
rollupConfig = rollupConfig.replace(
`'rollup-plugin-css-only';`,
`'rollup-plugin-css-only';
import sveltePreprocess from 'svelte-preprocess';
import typescript from '@rollup/plugin-typescript';`,
);
// Replace name of entry point
rollupConfig = rollupConfig.replace(`'src/main.js'`, `'src/main.ts'`);
// Add preprocessor
rollupConfig = rollupConfig.replace(
"compilerOptions:",
"preprocess: sveltePreprocess({ sourceMap: !production }),\n\t\t\tcompilerOptions:",
);
// Add TypeScript
rollupConfig = rollupConfig.replace(
"commonjs(),",
"commonjs(),\n\t\ttypescript({\n\t\t\tsourceMap: !production,\n\t\t\tinlineSources: !production\n\t\t}),",
);
fs.writeFileSync(rollupConfigPath, rollupConfig);
// Add svelte.config.js
const tsconfig = `{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*"]
}`;
const tsconfigPath = path.join(projectRoot, "tsconfig.json");
fs.writeFileSync(tsconfigPath, tsconfig);
// Add TSConfig
const svelteConfig = `import sveltePreprocess from 'svelte-preprocess';
export default {
preprocess: sveltePreprocess()
};
`;
const svelteConfigPath = path.join(projectRoot, "svelte.config.js");
fs.writeFileSync(svelteConfigPath, svelteConfig);
// Add global.d.ts
const dtsPath = path.join(projectRoot, "src", "global.d.ts");
fs.writeFileSync(dtsPath, `/// <reference types="svelte" />`);
// Delete this script, but not during testing
if (!argv[2]) {
// Remove the script
fs.unlinkSync(path.join(__filename));
// Check for Mac's DS_store file, and if it's the only one left remove it
const remainingFiles = fs.readdirSync(path.join(__dirname));
if (remainingFiles.length === 1 && remainingFiles[0] === ".DS_store") {
fs.unlinkSync(path.join(__dirname, ".DS_store"));
}
// Check if the scripts folder is empty
if (fs.readdirSync(path.join(__dirname)).length === 0) {
// Remove the scripts folder
fs.rmdirSync(path.join(__dirname));
}
}
// Adds the extension recommendation
fs.mkdirSync(path.join(projectRoot, ".vscode"), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, ".vscode", "extensions.json"),
`{
"recommendations": ["svelte.svelte-vscode"]
}
`,
);
console.log("Converted to TypeScript.");
if (fs.existsSync(path.join(projectRoot, "node_modules"))) {
console.log(
"\nYou will need to re-run your dependency manager to get started.",
);
}

View File

@@ -1,511 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
function App() {
const [user, setUser] = useState(null);
const [status, setStatus] = useState('Ready to authenticate');
const [statusType, setStatusType] = useState('info');
const [profileData, setProfileData] = useState(null);
const [checkingAuth, setCheckingAuth] = useState(true);
// Login view layout measurements
const titleRef = useRef(null);
const fileInputRef = useRef(null);
const [loginPadding, setLoginPadding] = useState(16); // default fallback padding in px
useEffect(() => {
function updatePadding() {
if (titleRef.current) {
const h = titleRef.current.offsetHeight || 0;
// Pad area around the text by half the title text height
setLoginPadding(Math.max(0, Math.round(h / 2)));
}
}
updatePadding();
window.addEventListener('resize', updatePadding);
return () => window.removeEventListener('resize', updatePadding);
}, []);
useEffect(() => {
// Check authentication status on page load
(async () => {
await checkStatus();
setCheckingAuth(false);
})();
}, []);
// Effect to fetch profile when user changes
useEffect(() => {
if (user?.pubkey) {
fetchUserProfile(user.pubkey);
}
}, [user?.pubkey]);
function relayURL() {
try {
return window.location.protocol.replace('http', 'ws') + '//' + window.location.host;
} catch (_) {
return 'ws://localhost:3333';
}
}
async function checkStatus() {
try {
const response = await fetch('/api/auth/status');
const data = await response.json();
if (data.authenticated && data.pubkey) {
// Fetch permission first, then set user and profile
try {
const permResponse = await fetch(`/api/permissions/${data.pubkey}`);
const permData = await permResponse.json();
if (permData && permData.permission) {
const fullUser = { pubkey: data.pubkey, permission: permData.permission };
setUser(fullUser);
updateStatus(`Already authenticated as: ${data.pubkey.slice(0, 16)}...`, 'success');
// Fire and forget profile fetch
fetchUserProfile(data.pubkey);
}
} catch (_) {
// ignore permission fetch errors
}
}
} catch (error) {
// Ignore errors for status check
}
}
function updateStatus(message, type = 'info') {
setStatus(message);
setStatusType(type);
}
function statusClassName() {
const base = 'mt-5 mb-5 p-3 rounded';
switch (statusType) {
case 'success':
return base + ' bg-green-100 text-green-800';
case 'error':
return base + ' bg-red-100 text-red-800';
case 'info':
default:
return base + ' bg-cyan-100 text-cyan-800';
}
}
async function getChallenge() {
try {
const response = await fetch('/api/auth/challenge');
const data = await response.json();
return data.challenge;
} catch (error) {
updateStatus('Failed to get authentication challenge: ' + error.message, 'error');
throw error;
}
}
async function loginWithExtension() {
if (!window.nostr) {
updateStatus('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.', 'error');
return;
}
try {
updateStatus('Connecting to extension...', 'info');
// Get public key from extension
const pubkey = await window.nostr.getPublicKey();
// Get challenge from server
const challenge = await getChallenge();
// Create authentication event
const authEvent = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL()],
['challenge', challenge]
],
content: ''
};
// Sign the event with extension
const signedEvent = await window.nostr.signEvent(authEvent);
// Send to server
await authenticate(signedEvent);
} catch (error) {
updateStatus('Extension login failed: ' + error.message, 'error');
}
}
async function fetchKind0FromRelay(pubkeyHex, timeoutMs = 4000) {
return new Promise((resolve) => {
let resolved = false;
let events = [];
let ws;
try {
ws = new WebSocket(relayURL());
} catch (e) {
resolve(null);
return;
}
const subId = 'profile-' + Math.random().toString(36).slice(2);
const timer = setTimeout(() => {
if (ws && ws.readyState === 1) {
try { ws.close(); } catch (_) {}
}
if (!resolved) {
resolved = true;
resolve(null);
}
}, timeoutMs);
ws.onopen = () => {
try {
const req = [
'REQ',
subId,
{ kinds: [0], authors: [pubkeyHex] }
];
ws.send(JSON.stringify(req));
} catch (_) {}
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
const type = data[0];
if (type === 'EVENT' && data[1] === subId) {
const event = data[2];
if (event && event.kind === 0 && event.content) {
events.push(event);
}
} else if (type === 'EOSE' && data[1] === subId) {
try {
ws.send(JSON.stringify(['CLOSE', subId]));
} catch (_) {}
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
if (events.length) {
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
try {
const meta = JSON.parse(latest.content);
resolve(meta || null);
} catch (_) {
resolve(null);
}
} else {
resolve(null);
}
}
}
} catch (_) {
// ignore malformed messages
}
};
ws.onerror = () => {
try { ws.close(); } catch (_) {}
clearTimeout(timer);
if (!resolved) {
resolved = true;
resolve(null);
}
};
ws.onclose = () => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
if (events.length) {
const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b));
try {
const meta = JSON.parse(latest.content);
resolve(meta || null);
} catch (_) {
resolve(null);
}
} else {
resolve(null);
}
}
};
});
}
// Function to fetch user profile metadata (kind 0)
async function fetchUserProfile(pubkeyHex) {
try {
// Create a simple placeholder with the pubkey
const placeholderProfile = {
name: `user:${pubkeyHex.slice(0, 8)}`,
about: 'No profile data available'
};
// Always set the placeholder profile first
setProfileData(placeholderProfile);
// First, try to get profile kind:0 from the relay itself
let relayMetadata = null;
try {
relayMetadata = await fetchKind0FromRelay(pubkeyHex);
} catch (_) {}
if (relayMetadata) {
const parsed = typeof relayMetadata === 'string' ? JSON.parse(relayMetadata) : relayMetadata;
setProfileData({
name: parsed.name || placeholderProfile.name,
display_name: parsed.display_name,
picture: parsed.picture,
banner: parsed.banner,
about: parsed.about || placeholderProfile.about
});
return parsed;
}
// Fallback: try extension metadata if available
if (window.nostr && window.nostr.getPublicKey) {
try {
if (window.nostr.getUserMetadata) {
const metadata = await window.nostr.getUserMetadata();
if (metadata) {
try {
const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
setProfileData({
name: parsedMetadata.name || placeholderProfile.name,
display_name: parsedMetadata.display_name,
picture: parsedMetadata.picture,
banner: parsedMetadata.banner,
about: parsedMetadata.about || placeholderProfile.about
});
return parsedMetadata;
} catch (parseError) {
console.log('Error parsing user metadata:', parseError);
}
}
}
} catch (nostrError) {
console.log('Could not get profile from extension:', nostrError);
}
}
return placeholderProfile;
} catch (error) {
console.error('Error handling profile data:', error);
return null;
}
}
async function authenticate(signedEvent) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedEvent)
});
const result = await response.json();
if (result.success) {
setUser(result.pubkey);
updateStatus('Successfully authenticated as: ' + result.pubkey.slice(0, 16) + '...', 'success');
// Check permissions after login
const permResponse = await fetch(`/api/permissions/${result.pubkey}`);
const permData = await permResponse.json();
if (permData && permData.permission) {
setUser({pubkey: result.pubkey, permission: permData.permission});
// Fetch user profile data
await fetchUserProfile(result.pubkey);
}
} else {
updateStatus('Authentication failed: ' + result.error, 'error');
}
} catch (error) {
updateStatus('Authentication request failed: ' + error.message, 'error');
}
}
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (_) {}
setUser(null);
updateStatus('Logged out', 'info');
}
function handleImportButton() {
try {
fileInputRef?.current?.click();
} catch (_) {}
}
async function handleImportChange(e) {
const file = e?.target?.files && e.target.files[0];
if (!file) return;
try {
updateStatus('Uploading import file...', 'info');
const fd = new FormData();
fd.append('file', file);
const res = await fetch('/api/import', { method: 'POST', body: fd });
if (res.ok) {
updateStatus('Import started. Processing will continue in the background.', 'success');
} else {
const txt = await res.text();
updateStatus('Import failed: ' + txt, 'error');
}
} catch (err) {
updateStatus('Import failed: ' + (err?.message || String(err)), 'error');
} finally {
// reset input so selecting the same file again works
if (e && e.target) e.target.value = '';
}
}
// Prevent UI flash: wait until we checked auth status
if (checkingAuth) {
return null;
}
return (
<>
{user?.permission ? (
<>
{/* Logged in view with user profile */}
<div className="sticky top-0 left-0 w-full bg-gray-100 z-50 h-16 flex items-center overflow-hidden">
<div className="flex items-center h-full w-full box-border">
<div className="relative overflow-hidden flex flex-grow items-center justify-start h-full">
{profileData?.banner && (
<div className="absolute inset-0 opacity-70 bg-cover bg-center" style={{ backgroundImage: `url(${profileData.banner})` }}></div>
)}
<div className="relative z-10 p-2 flex items-center h-full">
{profileData?.picture && <img src={profileData.picture} alt="User Avatar" className="h-full aspect-square w-auto rounded-full object-cover border-2 border-white mr-2 shadow box-border" />}
<div>
<div className="font-bold text-base block">
{profileData?.display_name || profileData?.name || user.pubkey.slice(0, 8)}
{profileData?.name && profileData?.display_name && ` (${profileData.name})`}
</div>
<div className="font-bold text-lg text-left">
{user.permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard"}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end shrink-0 h-full">
<button className="bg-transparent text-gray-500 border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent hover:text-gray-800" onClick={logout}></button>
</div>
</div>
</div>
{/* Hidden file input for import (admin) */}
<input
type="file"
ref={fileInputRef}
onChange={handleImportChange}
accept=".json,.jsonl,text/plain,application/x-ndjson,application/json"
style={{ display: 'none' }}
/>
<div className="p-2 w-full bg-white border-b border-gray-200">
<div className="text-lg font-bold flex items-center">Welcome</div>
<p>here you can configure all the things</p>
</div>
{/* Export only my events */}
<div className="p-2 bg-white rounded w-full">
<div className="w-full flex items-center justify-between">
<div className="pr-2 w-full">
<div className="text-base font-bold mb-1">Export My Events</div>
<p className="text-sm w-full text-gray-700">Download your own events as line-delimited JSON (JSONL/NDJSON). Only events you authored will be included.</p>
</div>
<button
className="bg-gray-100 text-gray-500 border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent hover:text-gray-800"
onClick={() => { window.location.href = '/api/export/mine'; }}
aria-label="Download my events as JSONL"
title="Download my events"
>
</button>
</div>
</div>
{user.permission === "admin" && (
<>
<div className="p-2 w-full rounded">
<div className="flex items-center justify-between">
<div className="pr-2 w-full">
<div className="text-base font-bold mb-1">Export All Events (admin)</div>
<p className="text-sm text-gray-700">Download all stored events as line-delimited JSON (JSONL/NDJSON). This may take a while on large databases.</p>
</div>
<button
className="bg-gray-100 text-gray-500 border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent hover:text-gray-800"
onClick={() => { window.location.href = '/api/export'; }}
aria-label="Download all events as JSONL"
title="Download all events"
>
</button>
</div>
</div>
<div className="p-2 w-full rounded">
<div className="flex items-center justify-between">
<div className="pr-2 w-full">
<div className="text-base font-bold mb-1">Import Events (admin)</div>
<p className="text-sm text-gray-700">Upload events in line-delimited JSON (JSONL/NDJSON) to import into the database.</p>
</div>
<button
className="bg-gray-100 text-gray-500 border-0 text-2xl cursor-pointer flex items-center justify-center h-full aspect-square shrink-0 hover:bg-transparent hover:text-gray-800"
onClick={handleImportButton}
aria-label="Import events from JSONL"
title="Import events"
>
</button>
</div>
</div>
</>
)}
</>
) : (
// Not logged in view - shows the login form
<div className="w-full h-full flex items-center justify-center">
<div
className="bg-gray-100"
style={{ width: '800px', maxWidth: '100%', boxSizing: 'border-box', padding: `${loginPadding}px` }}
>
<div className="flex items-center gap-3 mb-3">
<img
src="/orly.png"
alt="Orly logo"
className="object-contain"
style={{ width: '4rem', height: '4rem' }}
onError={(e) => {
// fallback to repo docs image if public asset missing
e.currentTarget.onerror = null;
e.currentTarget.src = "/docs/orly.png";
}}
/>
<h1 ref={titleRef} className="text-2xl font-bold p-2">ORLY🦉 Dashboard Login</h1>
</div>
<p className="mb-4">Authenticate to this Nostr relay using your browser extension.</p>
<div className={statusClassName()}>
{status}
</div>
<div className="mb-5">
<button className="bg-blue-600 text-white px-5 py-3 rounded hover:bg-blue-700" onClick={loginWithExtension}>Login with Browser Extension (NIP-07)</button>
</div>
</div>
</div>
)}
</>
);
}
export default App;

Some files were not shown because too many files have changed in this diff Show More