diff --git a/.ackrc b/.ackrc index 4bb23ec..ee75e2d 100644 --- a/.ackrc +++ b/.ackrc @@ -1,4 +1,5 @@ ---ignore-dir=docs +--ignore-dir=docs/reference +--ignore-dir=docs/.vitepress/cache --ignore-dir=dist --ignore-dir=build --ignore-dir=.svelte-kit diff --git a/.fdignore b/.fdignore index dd87e2d..87b867f 100644 --- a/.fdignore +++ b/.fdignore @@ -1,2 +1,4 @@ node_modules +docs/reference +docs/.vitepress/cache build diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 9843296..80662f4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -21,13 +21,17 @@ export default defineConfig({ ], }, { - text: "@welshman/lib", - link: "/lib/", + text: "@welshman/app", + link: "/app/", items: [ - {text: "Utilities", link: "/lib/tools"}, - {text: "LRU cache", link: "/lib/lru"}, - {text: "Worker", link: "/lib/worker"}, - {text: "Deferred", link: "/lib/deferred"}, + {text: "Session Management", link: "/app/session"}, + {text: "Relay Selection", link: "/app/relay-selection"}, + {text: "Making Requests", link: "/app/making-requests"}, + {text: "Publishing Events", link: "/app/publishing-events"}, + {text: "Tag utilities", link: "/app/tags"}, + {text: "Web of Trust", link: "/app/wot"}, + {text: "Storage", link: "/app/storage"}, + {text: "Context", link: "/app/context"}, ], }, { @@ -48,37 +52,6 @@ export default defineConfig({ {text: "Zaps", link: "/util/zaps"}, ], }, - { - text: "@welshman/content", - link: "/content/", - items: [ - {text: "Parser", link: "/content/parser"}, - {text: "Renderer", link: "/content/renderer"}, - ], - }, - { - text: "@welshman/feeds", - link: "/feeds/", - items: [ - {text: "Core", link: "/feeds/core"}, - {text: "Utilities", link: "/feeds/utils"}, - {text: "Compiler", link: "/feeds/compiler"}, - {text: "Controller", link: "/feeds/controller"}, - ], - }, - { - text: "@welshman/editor", - link: "/editor/", - items: [], - }, - { - text: "@welshman/store", - link: "/store/", - items: [ - {text: "Basic utilities", link: "/store/basic"}, - {text: "Event stores", link: "/store/events"}, - ], - }, { text: "@welshman/net", link: "/net/", @@ -95,14 +68,6 @@ export default defineConfig({ {text: "Socket", link: "/net/socket"}, ], }, - { - text: "@welshman/dvm", - link: "/dvm/", - items: [ - {text: "Handler", link: "/dvm/handler"}, - {text: "Request", link: "/dvm/request"}, - ], - }, { text: "@welshman/signer", link: "/signer/", @@ -116,22 +81,59 @@ export default defineConfig({ ], }, { - text: "@welshman/app", - link: "/app/", + text: "@welshman/relay", + link: "/relay/", items: [ - {text: "Context", link: "/app/context"}, - {text: "Storage", link: "/app/storage"}, - {text: "Router", link: "/app/router"}, - {text: "Session", link: "/app/session"}, - {text: "Collection", link: "/app/collection"}, - {text: "Commands", link: "/app/commands"}, - {text: "Subscription", link: "/app/subscription"}, - {text: "Publish (Thunks)", link: "/app/thunks"}, - {text: "Feed", link: "/app/feed"}, - {text: "Tag utilities", link: "/app/tags"}, - {text: "Topics", link: "/app/topics"}, - {text: "Web of Trust", link: "/app/wot"}, - {text: "Stores and Loaders", link: "/app/storesandloaders"}, + ], + }, + { + text: "@welshman/content", + link: "/content/", + items: [ + {text: "Parser", link: "/content/parser"}, + {text: "Renderer", link: "/content/renderer"}, + ], + }, + { + text: "@welshman/editor", + link: "/editor/", + items: [], + }, + { + text: "@welshman/feeds", + link: "/feeds/", + items: [ + {text: "Core", link: "/feeds/core"}, + {text: "Utilities", link: "/feeds/utils"}, + {text: "Compiler", link: "/feeds/compiler"}, + {text: "Controller", link: "/feeds/controller"}, + ], + }, + { + text: "@welshman/dvm", + link: "/dvm/", + items: [ + {text: "Handler", link: "/dvm/handler"}, + {text: "Request", link: "/dvm/request"}, + ], + }, + { + text: "@welshman/store", + link: "/store/", + items: [ + {text: "Basic utilities", link: "/store/basic"}, + {text: "Event stores", link: "/store/events"}, + ], + }, + { + text: "@welshman/lib", + link: "/lib/", + items: [ + {text: "Utilities", link: "/lib/tools"}, + {text: "LRU cache", link: "/lib/lru"}, + {text: "Task Queue", link: "/lib/task-queue"}, + {text: "Normalize URL", link: "/lib/normalize-url"}, + {text: "Deferred", link: "/lib/deferred"}, ], }, ], diff --git a/docs/app/collection.md b/docs/app/collection.md deleted file mode 100644 index 8aa3987..0000000 --- a/docs/app/collection.md +++ /dev/null @@ -1,103 +0,0 @@ -# Collection Stores - -The `collection` utility creates stores that handle caching, loading, and indexing of Nostr data. It provides a consistent pattern for managing entities that need to be fetched from the network and cached locally. - -```typescript -const { - indexStore, // Map of all items by key - deriveItem, // Get reactive item by key - loadItem // Trigger network load -} = collection({ - name: "storeName", // For persistence - store: writable([]), // Base store - getKey: item => item.id // How to index items - load: async (key) => { // Network loader - // Load logic here - } -}) -``` - -## Available Collections - -```typescript -// Profiles -profiles → profilesByPubkey → deriveProfile → loadProfile - -// Lists -follows → followsByPubkey → deriveFollows → loadFollows -mutes → mutesByPubkey → deriveMutes → loadMutes -pins → pinsByPubkey → derivePins → loadPins - -// Relays -relays → relaysByUrl → deriveRelay → loadRelay -relaySelections → relaySelectionsByPubkey → deriveRelaySelections → loadRelaySelections -inboxRelaySelections → inboxRelaySelectionsByPubkey → deriveInboxRelaySelections → loadInboxRelaySelections - -// Identity -handles → handlesByNip05 → deriveHandle → loadHandle -zappers → zappersByLnurl → deriveZapper → loadZapper -``` - -## Real World Examples - -### Loading and Displaying Profiles - -```typescript -import { - deriveProfile, - loadProfile, - displayProfile -} from '@welshman/app' - -// In a Svelte component -let profile - -// Subscribe to profile changes -$: profile = $deriveProfile(pubkey) - -// Load automatically triggers when needed -onMount(() => { - loadProfile(pubkey, { - // Optional request params - relays: ["wss://relay.example.com"] - }) -}) - -// Display with fallback -$: name = displayProfile(profile, "unknown") -``` - -### Managing Relay Selections - -```typescript -import { - deriveRelaySelections, - loadRelaySelections, - getReadRelayUrls, - getWriteRelayUrls -} from '@welshman/app' - -// Get user's relay preferences -const selections = deriveRelaySelections(pubkey).get() - -// Load from network if needed -await loadRelaySelections(pubkey) - -// Get read/write URLs -const readRelays = getReadRelayUrls(selections) -const writeRelays = getWriteRelayUrls(selections) - -// Use with router -const relays = ctx.app.router - .FromPubkey(pubkey) - .getUrls() -``` - -Each collection automatically: -- Caches to IndexedDB -- Deduplicates network requests -- Updates reactively -- Provides typed access -- Handles loading states - -The pattern is consistent across all stores, making it predictable to work with different types of nostr data. diff --git a/docs/app/commands.md b/docs/app/commands.md deleted file mode 100644 index cf21d35..0000000 --- a/docs/app/commands.md +++ /dev/null @@ -1,88 +0,0 @@ -# Commands - -High-level commands for common Nostr operations. -Each command handles signing, encryption, and relay selection automatically. - -## Available Commands - -```typescript -// List Management -follow(pubkey) -unfollow(pubkey) -mute(pubkey) -unmute(pubkey) -pin(tag) -unpin(tag) -``` - -Each command returns a [`Thunk`](app/thunk) which: -- Optimistically updates local state -- Signs and publishes the event -- Can be aborted within a delay window -- Reports publish progress - -## Real World Examples - -### Following/Unfollowing Users - -```typescript -import {follow, unfollow, userFollows} from '@welshman/app' - -// Follow with optimistic update -const followUser = async (pubkey: string) => { - - // Creates and publishes event with an updated follow list - const thunk = await follow(pubkey) - - // Track publish status per relay - thunk.status.subscribe(statuses => { - for (const [url, status] of Object.entries(statuses)) { - console.log(`${url}: ${status}`) - } - }) - - // Can abort within delay window - setTimeout(() => thunk.controller.abort(), 1000) -} - -// Unfollow works the same way -const unfollowUser = async (pubkey: string) => { - const thunk = await unfollow(pubkey) - - // Wait for completion - const results = await thunk.result -} -``` - -### Managing Pins - -```typescript -import {pin, unpin, userPins} from '@welshman/app' - -// Pin an event with context -const pinEvent = async (event: TrustedEvent) => { - const thunk = await pin([ - 'e', event.id, - ctx.app.router.Event(event).getUrl() - ]) - - // Handle specific relay errors - thunk.status.subscribe(statuses => { - for (const [url, {status, message}] of Object.entries(statuses)) { - if (status === 'failure') { - console.error(`Failed on ${url}: ${message}`) - } - } - }) -} -``` - -All commands: -- Handle encryption automatically -- Select appropriate relays -- Update local state immediately -- Allow soft-undo via abort -- Report per-relay status -- Return consistent Thunk interface - -Commands provide a high-level way to modify the Nostr state without dealing with the complexities of event creation, encryption, and relay selection. diff --git a/docs/app/context.md b/docs/app/context.md index a10a9b0..4926ad6 100644 --- a/docs/app/context.md +++ b/docs/app/context.md @@ -1,139 +1,13 @@ # Application Context -The `@welshman/app` package uses a global context system to configure core behaviors. -Understanding the app context is essential as it powers [session/authentication](/app/session), [relay routing](/app/relay) and [request handling](/app/request). +The `@welshman/app` package uses a global context system to configure a few core behaviors. -## Basic Setup +## Dufflepud + +[Dufflepud](https://github.com/coracle-social/dufflepud) is a utility server that can retrieve NIP 05 profiles, zappers, relay metadata, link previews, etc. It's not necessary for using welshman, but can improve things by bypassing CORS. ```typescript -import {ctx, setContext} from '@welshman/lib' -import {getDefaultNetContext, getDefaultAppContext} from '@welshman/app' +import {appContext} from '@welshman/app' -// Initialize app with default settings -setContext({ - net: getDefaultNetContext(), - app: getDefaultAppContext() -}) - -// Access context anywhere -console.log(ctx.app.router) -console.log(ctx.net.pool) -``` - -## Default App Context - -```typescript -export type AppContext = { - // Smart relay routing system - router: Router - - // Time to wait between batched requests (ms) - requestDelay: number // default: 50 - - // Time to wait for NIP-42 relay auth (ms) - authTimeout: number // default: 300 - - // Time to wait for request completion (ms) - requestTimeout: number // default: 3000 - - // URL of metadata service (optional) - dufflepudUrl?: string - - // Additional relays for indexed content - indexerRelays?: string[] -} - -// Example with custom settings -setContext({ - app: getDefaultAppContext({ - requestDelay: 100, - authTimeout: 500, - requestTimeout: 5000, - dufflepudUrl: "https://api.example.com", - indexerRelays: [ - "wss://relay.example.com", - "wss://indexed.example.com" - ] - }) -}) -``` - -## Network Context - -```typescript -export type NetContext = { - // Global connection pool - pool: Pool - - // How to handle NIP-42 auth - authMode: AuthMode // default: 'implicit' - - // Event validation and handling - onEvent: (url: string, event: TrustedEvent) => void - isDeleted: (url: string, event: TrustedEvent) => boolean - isValid: (url: string, event: TrustedEvent) => boolean - - // Event signing (used by all packages) - signEvent: (event: StampedEvent) => Promise - - // Subscription optimization - optimizeSubscriptions: (subs: Subscription[]) => RelaysAndFilters[] -} - -// Example with custom validation -setContext({ - net: getDefaultNetContext({ - // Custom event validation - isValid: (url, event) => { - if (url === LOCAL_RELAY_URL) return true - return hasValidSignature(event) - }, - - // Track deleted events - isDeleted: (url, event) => - repository.isDeleted(event), - - // Custom event handling - onEvent: (url, event) => { - // Save to local repository - repository.publish(event) - - // Track which relay it came from - tracker.track(event.id, url) - } - }) -}) -``` - - -## Using Context Values - -Once configured, context values are used throughout the app: - -```typescript -import {ctx} from '@welshman/lib' - -// Smart relay routing -const relays = ctx.app.router - .ForPubkey(pubkey) - .getUrls() - -// Publish with timeout -const pub = publish({ - event, - relays, - timeout: ctx.app.requestTimeout -}) - -// Subscribe with auth -const sub = subscribe({ - filters, - relays, - authTimeout: ctx.app.authTimeout -}) - -// Check connection pool -const connected = ctx.net.pool - .get(relay) - .socket.status === 'open' +appContext.dufflepudUrl = 'https://my-dufflepud-instance.com' ``` diff --git a/docs/app/feed.md b/docs/app/feed.md deleted file mode 100644 index e22e591..0000000 --- a/docs/app/feed.md +++ /dev/null @@ -1,96 +0,0 @@ -# Feed - -The feed system provides a powerful way to compose and load complex `Nostr` queries. It supports user scopes, web of trust filtering, DVM integration, and thread construction. - -## Controller - -The `controller.load()` function is the main interface for fetching events from a feed. It handles all the complexity of relay selection, subscription management, and event filtering. - -```typescript -import {createFeedController} from '@welshman/app' -import {scopeFeed, wotFeed} from '@welshman/feeds' - -const controller = createFeedController({ - // Define what to load - feed: scopeFeed("follows"), - - // Optional configurations - closeOnEose: true, // Close after getting all events - onEvent: event => {}, // Handle events as they arrive - onEose: url => {}, // Handle EOSE from each relay - onComplete: () => {}, // Called when all relays complete -}) - -// Load first 20 events -const events = await controller.load(20) - -// Load next 20 events -const moreEvents = await controller.load(20) -``` - -The controller maintains its state between loads, so subsequent calls will: -- Continue from last position -- Use appropriate time windows -- Skip already seen events -- Maintain relay connections - -## Paginated Feed - -```typescript -import {intersectionFeed, scopeFeed, wotFeed} from '@welshman/feeds' - -const HomeFeed = { - let events = [] - let loading = false - let controller - - onMount(() => { - // Create feed for home timeline - controller = createFeedController({ - feed: intersectionFeed( - // Content from follows - scopeFeed("follows"), - // Filtered by web of trust - wotFeed({min: 0.1}) - ), - - // Handle events as they arrive - onEvent: event => { - events = [...events, event] - }, - - // Track loading state - onComplete: () => { - loading = false - } - }) - - // Initial load - loadMore() - }) - - const loadMore = async () => { - if (loading) return - loading = true - - // Load next batch - await controller.load(20) - } -} -``` - -Key points about `controller.load()`: -- Takes a limit parameter for batch size -- Returns a promise of loaded events -- Can be called repeatedly for pagination -- Handles subscription lifecycle -- Manages relay connections -- Deduplicates events - -The controller is stateful and maintains: -- Current time window -- Seen events -- Active subscriptions -- Relay connections - -This makes it ideal for implementing infinite scroll feeds, thread loading, and other paginated content scenarios. diff --git a/docs/app/index.md b/docs/app/index.md index 68ac46b..e96ab5f 100644 --- a/docs/app/index.md +++ b/docs/app/index.md @@ -1,5 +1,7 @@ # @welshman/app +[![version](https://badgen.net/npm/v/@welshman/app)](https://npmjs.com/package/@welshman/app) + A comprehensive framework for building nostr clients, powering production applications like [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). It provides a complete toolkit for managing events, subscriptions, user data, and relay connections. ## What's Included @@ -7,11 +9,71 @@ A comprehensive framework for building nostr clients, powering production applic - **Repository System** - Event storage and query capabilities - **Router** - Intelligent relay selection for optimal networking - **Feed Controller** - Manages feed creation and updates -- **Authentication** - User identity and key management +- **Session Management** - User identity and key management - **Event Actions** - High-level operations like reacting, replying, etc. - **Profile Management** - User profile handling and metadata - **Relay Directories** - Discovery and management of relays +- **Web of Trust** - Utilities for building webs of trust +## Quick Example + +```typescript +import {getNip07} from '@welshman/signer' +import {load, request, RequestEvent, defaultSocketPolicies, makeSocketPolicyAuth, Socket} from '@welshman/net' +import {StampedEvent, TrustedEvent, makeEvent, NOTE} from '@welshman/util' +import {pubkey, signer, publishThunk} from '@welshman/app' + +// Log in via NIP 07 +addSession({method: 'nip07', pubkey: await getNip07().getPubkey()}) + +// Enable automatic authentication to relays +defaultSocketPolicies.push( + makeSocketPolicyAuth({ + sign: (event: StampedEvent) => signer.get()?.sign(event), + shouldAuth: (socket: Socket) => true, + }), +) + +// This will fetch the user's profile automatically, and return a store that updates +// automatically. Several different stores exist that are ready to go, including handles, +// zappers, relaySelections, relays, follows, mutes. +const profile = deriveProfile(pubkey.get()) + +// Publish is done using thunks, which optimistically publish to the local database, deferring +// signing and publishing for instant user feedback. Progress is reported as relays accept/reject the event +// Events are automatically signed using the current session +const thunk = publishThunk({ + relays: Router.get().FromUser().getUrls(), + event: makeEvent(NOTE, {content: "hi"}), + delay: 3000, +}) + +// Thunks can be aborted until after `delay`, allowing for soft-undo +thunk.controller.abort() + +// Some commands are included +const thunk = follow('97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322') + +// Load events as a promise +const events = await load({ + relays: Router.get().ForUser().getUrls(), + filters: [{kinds: [NOTE], +}]) + +// Or use `request` for more fine-grained subscription control +const req = request({ + relays: Router.get().ForUser().getUrls(), + filters: [{kinds: [NOTE], +}]) + +// Listen for events +req.on(RequestEvent.Event, (event: TrustedEvent) => { + console.log(event) +}) + +// Close the req +req.close() +``` ## Installation diff --git a/docs/app/making-requests.md b/docs/app/making-requests.md new file mode 100644 index 0000000..f121c9b --- /dev/null +++ b/docs/app/making-requests.md @@ -0,0 +1,143 @@ +# Making Requests + +Welshman extends Nostr's base subscription model with intelligent caching, repository integration, and configurable behaviors. + +## Key Concepts + +- **Local Repository**: Events are automatically cached and tracked +- **Cache Intelligence**: Smart decisions about when to use cached data +- **Relay Integration**: Works with the router for optimal relay selection +- **Configurable Behavior**: Control caching and timeouts + +## Request and Load + +The base functionality for subscription management is implemented in `@welshman/net`. Please refer to [the documentation](/net) for that module for details. + +## Collections and Loaders + +The `collection` utility creates stores that handle caching, loading, and indexing of Nostr data. It provides a consistent pattern for managing entities that need to be fetched from the network and cached locally. + +```typescript +const { + indexStore, // Map of all items by key + deriveItem, // Get reactive item by key + loadItem // Trigger network load +} = collection({ + name: "storeName", // For persistence + store: writable([]), // Base store + getKey: item => item.id // How to index items + load: async (key) => { // Network loader + // Load logic here + } +}) +``` + +### Deriving Events + +The best way to create collections is by deriving their contents from the app `repository` using `deriveEvents` from `@welshman/store`. For more control, use `deriveEventsMapped`. + +```typescript +import {deriveEventsMapped} from "@welshman/store" + +export const notes = deriveEvents(repository, {filters: [{kinds: [NOTE]}]}) +``` + +A collection could then be created by passing the `notes` store to `collection`. + +### Available Collections + +Several common collections are built-in and ready for use: + +```typescript +// Profiles +profiles → profilesByPubkey → deriveProfile → loadProfile + +// Lists +follows → followsByPubkey → deriveFollows → loadFollows +mutes → mutesByPubkey → deriveMutes → loadMutes +pins → pinsByPubkey → derivePins → loadPins + +// Relays +relays → relaysByUrl → deriveRelay → loadRelay +relaySelections → relaySelectionsByPubkey → deriveRelaySelections → loadRelaySelections +inboxRelaySelections → inboxRelaySelectionsByPubkey → deriveInboxRelaySelections → loadInboxRelaySelections + +// Identity +handles → handlesByNip05 → deriveHandle → loadHandle +zappers → zappersByLnurl → deriveZapper → loadZapper +``` + +### Example - Loading and Displaying Profiles + +```typescript +import {get} from 'svelte/store' +import {displayProfile} from '@welshman/util' +import {deriveProfile, deriveProfileDisplay} from '@welshman/app' + +// Subscribe to profile changes - this will automatically load the profile in the background +const profile = deriveProfile(pubkey) + +// Display with fallback +const name = displayProfile(get(profile), 'unknown') + +// Better: use built-in deriveProfileDisplay utility +const name = deriveProfileDisplay(pubkey) +``` + +### User-Specific Collections + +Several modules provide user-specific derived stores that automatically load data for the currently signed-in user: + +```typescript +import { userProfile, userFollows, userMutes, userPins } from '@welshman/app' + +userProfile.subscribe(profile => { + // Current user's profile data +}) + +userFollows.subscribe(follows => { + // Current user's follow list +}) +``` + +### Repository Integration + +All events from subscriptions are automatically: + +- Saved to the repository +- Tracked to their source relay +- Checked against deletion status + +The repository serves as an intelligent cache layer, making subsequent queries for the same data faster. + +## Feeds + +A high-level feed loader utility is also provided, which combines application state with utilities from `@welshman/net` and `@welshman/feeds`. + +```typescript +import {NOTE} from '@welshman/util' +import {makeKindFeed} from '@welshman/feeds' +import {createFeedController} from '@welshman/app' + +const abortController = new AbortController() + +let done = false + +const ctrl = createFeedController({ + feed: makeKindFeed(NOTE), + useWindowing: true, + signal: abortController.signal, + onEvent: e => { + console.log(e) + }, + onExhausted: () => { + done = true + }, +}) + +// Load some notes +ctrl.load(100) + +// Cancel any pending requests +abortController.abort() +``` diff --git a/docs/app/thunks.md b/docs/app/publishing-events.md similarity index 77% rename from docs/app/thunks.md rename to docs/app/publishing-events.md index c4ee3f7..276508c 100644 --- a/docs/app/thunks.md +++ b/docs/app/publishing-events.md @@ -5,9 +5,10 @@ Thunks provide optimistic updates for event publishing. They immediately update ## Overview A thunk: + - Updates local state immediately -- Handles event signing in the background -- Manages publish status per relay +- Handles event signing in the background using the current session +- Tracks publish status per relay - Supports soft-undo via abort - Can be delayed/cancelled - Tracks successful publishes @@ -46,7 +47,17 @@ const publish = async (content: string) => { }, 1000) // Wait for completion - const results = await thunk.result - return results + await thunk.result } ``` + +## Built in commands + +Several thunk factories are provided for more complicated scenarios like updating lists: + +- `follow(pubkey)` +- `unfollow(pubkey)` +- `mute(pubkey)` +- `unmute(pubkey)` +- `pin(tag)` +- `unpin(tag)` diff --git a/docs/app/relay-selection.md b/docs/app/relay-selection.md new file mode 100644 index 0000000..d428483 --- /dev/null +++ b/docs/app/relay-selection.md @@ -0,0 +1,56 @@ +# Router + +The Welshman router can be used to enable the `outbox model` in your Nostr application. It handles relay selection for reading, writing, and discovering events while considering relay quality, user preferences, and network conditions. + +## Overview + +The router provides scenarios for common **Nostr** operations: + +- Reading user profiles +- Publishing events +- Following threads +- Handling DMs +- Searching content + +Each scenario considers: + +- User's relay preferences (NIP-65) +- Event hints in tags +- Relay quality scores +- Fallback policies +- Connection status + +## Basic Usage + +```typescript +import {routerContext, addMaximalFallbacks, Router} from '@welshman/app' + +// Set up global router options +routerContext.getDefaultRelays = () => ["wss://relay.damus.io/", "wss://nos.lol/"] + +// Router can be used directly with options, or via a singleton with global options +const router = Router.get() + +// Get relays for reading a profile +const readRelays = router.ForPubkey(pubkey).getUrls() + +// Get relays for broadcasting events by the current user +const writeRelays = router.FromUser().getUrls() + +// Get relays for a quote +const quoteRelays = Router.get() + .Quote(parentEvent, idOrAddress, relayHints) + .policy(addMaximalFallbacks) + .getUrls() + +``` + +## Router Features + +- Smart relay selection based on relay monitoring +- Quality scoring of relays +- Fallback strategies +- Handling of special relay types (.onion, local) +- NIP-65 support + +The router is central to efficient nostr operations, ensuring events reach their intended audience while minimizing unnecessary network traffic. diff --git a/docs/app/router.md b/docs/app/router.md deleted file mode 100644 index 90622c9..0000000 --- a/docs/app/router.md +++ /dev/null @@ -1,103 +0,0 @@ -# Router - -The Router is the critical component to efficiently enable the `outbox model` in your Nostr application. It handles relay selection for reading, writing, and discovering events while considering relay quality, user preferences, and network conditions. - -## Overview - -The router provides scenarios for common **Nostr** operations: -- Reading user profiles -- Publishing events -- Following threads -- Handling DMs -- Searching content - -Each scenario considers: -- User's relay preferences (NIP-65) -- Event hints in tags -- Relay quality scores -- Fallback policies -- Connection status - -## Basic Usage - -```typescript -import {ctx, setContext} from '@welshman/lib' -import {getDefaultAppContext} from '@welshman/app' - -// Initialize router -setContext({ - app: getDefaultAppContext() -}) - -// Use router scenarios -const router = ctx.app.router - -// Get relays for reading a profile -const readRelays = router.ForPubkey(pubkey).getUrls() - -// Get relays for publishing -const writeRelays = router.FromUser().getUrls() - -// Get relays for a thread -const threadRelays = router.Replies(event).getUrls() -``` - -## Thread Navigation - -```typescript -import {ctx} from '@welshman/lib' -import {createEvent, NOTE} from '@welshman/util' -import {publishThunk} from '@welshman/app' - -const loadThread = async (event: TrustedEvent) => { - // Get relays for root event - const rootRelays = ctx.app.router - .EventRoots(event) - .getUrls() - - // Get relays for replies - const replyRelays = ctx.app.router - .EventParents(event) - .getUrls() - - // Get relays for mentions - const mentionRelays = ctx.app.router - .EventMentions(event) - .getUrls() - - // Load from all relevant relays - await Promise.all([ - subscribe({filters, relays: rootRelays}), - subscribe({filters, relays: replyRelays}), - subscribe({filters, relays: mentionRelays}) - ]) -} - -// Posting a reply -const reply = async (parent: TrustedEvent, content: string) => { - const event = createEvent(NOTE, {content}) - - // Get optimal relays for publishing - const relays = ctx.app.router - .PublishEvent(event) - // Skip .onion relays - .allowOnion(false) - // Allow up to 5 relays - .limit(5) - .getUrls() - - return publishThunk({event, relays}) -} -``` - -## Router Features - -- Smart relay selection based on context -- Quality scoring of relays -- Fallback strategies -- Handling of special relay types (.onion, local) -- Automatic weight calculation -- Connection state awareness -- NIP-65 compliance - -The router is central to efficient nostr operations, ensuring events reach their intended audience while minimizing unnecessary network traffic. diff --git a/docs/app/session.md b/docs/app/session.md index c0b94d9..3483d24 100644 --- a/docs/app/session.md +++ b/docs/app/session.md @@ -1,16 +1,16 @@ # Session Management The session system provides a unified way to handle different authentication methods: -- Secret Key NIP-01 -- Nostr Extensions NIP-07 -- Bunker URL NIP-46 -- Amber or in-device NIP-55 -while managing user state and encryption capabilities. +- NIP-01 via Secret Key +- NIP-07 via Browser Extension +- NIP-46 via Bunker URL or Nostrconnect +- NIP-55 via Android Signer Application ## Overview Sessions are stored in local storage and can be: + - Persisted across page reloads - Used with multiple accounts - Switched dynamically @@ -18,39 +18,6 @@ Sessions are stored in local storage and can be: ## Basic Usage -```typescript -import {ctx, setContext} from '@welshman/lib' -import { - getDefaultNetContext, - getDefaultAppContext, - pubkey, - sessions, - session, - addSession, - getNip07 -} from '@welshman/app' - -// Set up app config -setContext({ - net: getDefaultNetContext(), - app: getDefaultAppContext(), -}) - -// Log in via NIP-07 extension (browser wallet) -if (await getNip07()) { - addSession({ - method: 'nip07', - pubkey: await getNip07().getPublicKey() - }) -} - -// Get current session -console.log(session.get()) // Current active session -console.log(pubkey.get()) // Current pubkey -``` - -## Multiple Sessions - ```typescript import {sessions, pubkey, addSession, dropSession} from '@welshman/app' @@ -118,23 +85,6 @@ const encrypted = await signer.get().nip44.encrypt( ) ``` -## Session Persistence - -Sessions are automatically persisted to local storage. On page load: - -```typescript -import {pubkey, sessions} from '@welshman/app' - -// Sessions load automatically from local storage -console.log(sessions.get()) // All stored sessions - -// the current active session -console.log(session.get()) - -// Last active pubkey is restored -console.log(pubkey.get()) -``` - ## Session Types ```typescript @@ -159,28 +109,3 @@ type SessionNip01 = { secret: string } ``` - -## Error Handling - -```typescript -import {tryCatch} from '@welshman/lib' -import {addSession, getNip07} from '@welshman/app' - -const login = async () => { - const nip07 = await tryCatch(getNip07) - - if (!nip07) { - throw new Error("No NIP-07 extension found") - } - - const pubkey = await tryCatch( - () => nip07.getPublicKey() - ) - - if (!pubkey) { - throw new Error("Failed to get public key") - } - - addSession({method: 'nip07', pubkey}) -} -``` diff --git a/docs/app/storage.md b/docs/app/storage.md index 658b419..b478c63 100644 --- a/docs/app/storage.md +++ b/docs/app/storage.md @@ -1,67 +1,31 @@ # Storage The storage system provides IndexedDB persistence for stores and repositories. -It's critical to initialize this early in your application lifecycle to ensure data consistency. + +Initialize this early in your application lifecycle to ensure data consistency. ```typescript -import { - initStorage, - storageAdapters, - throttled, - repository, - tracker, - relays, - handles, - freshness, - plaintext -} from '@welshman/app' +import {initStorage, defaultStorageAdapters} from '@welshman/app' -// Real world example from Coracle -const initializeStorage = async () => { - const ready = initStorage("coracle-db", 1, { - // Persist relay info - relays: { - keyPath: "url", - store: throttled(3000, relays) +// Use default storage adapters, which track important metadata events, +// relays, handles, zappers, etc. +await initStorage("my-db", 1, { + ...defaultStorageAdapters, + custom: { + keyPath: "key", + init: async () => console.log(await getAll("custom")), + sync: () => { + // Set up a listener for changes, using bulkPut to save records. + // Return an unsubscribe function for cleanup }, - - // Persist NIP-05 handles - handles: { - keyPath: "nip05", - store: throttled(3000, handles) - }, - - // Track data freshness - freshness: storageAdapters.fromObjectStore( - freshness, - {throttle: 3000} - ), - - // Store decrypted content - plaintext: storageAdapters.fromObjectStore( - plaintext, - {throttle: 3000} - ), - - // Store events and their sources - events: storageAdapters.fromRepositoryAndTracker( - repository, - tracker, - {throttle: 3000} - ) - }) - - // Wait for storage to be ready - await ready - - // App can now start loading data -} + }, +}) ``` The storage system: + - Persists data across page reloads - Throttles writes for performance -- Handles store migrations - Syncs bidirectionally - Supports custom adapters diff --git a/docs/app/storesandloaders.md b/docs/app/storesandloaders.md deleted file mode 100644 index 889165b..0000000 --- a/docs/app/storesandloaders.md +++ /dev/null @@ -1,108 +0,0 @@ -# Stores and Loaders - -The `@welshman/app` package provides a powerful system of collection-based reactive stores and loader utilities. - -These utilities follow a consistent pattern for working with various types of Nostr data, making it easy to: - -1. Query data from the repository -2. Transform it into application-specific structures -3. Access it reactively in your UI -4. Trigger network loading when needed - -## Core Concept - -Each collection-based module exports a similar set of utilities: - -```typescript -// Common pattern across collection-based modules -export const { - // Main collection store (derived from repository) - store: follows, - - // Indexed map for efficient lookup - indexStore: followsByPubkey, - - // Function to get a reactive store for a specific item - deriveItem: deriveFollows, - - // Function to trigger loading an item from the network - loadItem: loadFollows -} = collection({ - name: "collection-name", - store: baseStore, - getKey: item => item.keyProperty, - load: async (key) => { /* Loading logic */ } -}) -``` - -## Available Collections - -| Collection | Key | Kind | Description | -|------------|-----|------|-------------| -| `follows` | pubkey | 3 | User follow lists | -| `mutes` | pubkey | 10000 | User mute lists | -| `pins` | pubkey | 10001 | User pinned items | -| `profiles` | pubkey | 0 | User profile metadata | -| `relaySelections` | pubkey | 10002 | User relay preferences | -| `inboxRelaySelections` | pubkey | 10005 | User inbox relay settings | -| `zappers` | lnurl | - | Lightning zapper metadata | -| `handles` | nip05 | - | NIP-05 identifier metadata | - -## Usage Examples - -### Loading and Accessing Data - -```typescript -import { loadProfile, deriveProfile, profilesByPubkey } from '@welshman/app' - -// Trigger loading a profile from the network -await loadProfile('pubkey123') - -// Get a reactive store for a specific profile -const profile = deriveProfile('pubkey123') - -// Access all profiles by pubkey -const allProfiles = profilesByPubkey.get() -const specificProfile = allProfiles.get('pubkey123') -``` - -### User-Specific Collections - -Several modules provide user-specific derived stores that automatically load data for the currently signed-in user: - -```typescript -import { userProfile, userFollows, userMutes, userPins } from '@welshman/app' - -// These are derived stores that automatically: -// 1. Watch for changes to the current user's pubkey -// 2. Load the appropriate data when the user changes -// 3. Provide the data reactively - -userProfile.subscribe(profile => { - // Current user's profile data -}) - -userFollows.subscribe(follows => { - // Current user's follow list -}) -``` - -### Web of Trust Utilities - -The `wot.ts` module provides additional utilities for analyzing the social graph: - -```typescript -import { getFollows, getFollowers, getNetwork, getWotScore } from '@welshman/app' - -// Get users followed by a pubkey -const followedUsers = getFollows('pubkey123') - -// Get users following a pubkey -const followers = getFollowers('pubkey123') - -// Get extended network (follows-of-follows) -const network = getNetwork('pubkey123') - -// Calculate trust score between users -const score = getWotScore('userPubkey', 'targetPubkey') -``` diff --git a/docs/app/subscription.md b/docs/app/subscription.md deleted file mode 100644 index 7071699..0000000 --- a/docs/app/subscription.md +++ /dev/null @@ -1,153 +0,0 @@ -# Subscription System - -The subscription system extends Nostr's base subscription model with intelligent caching, repository integration, and configurable behaviors. - -## Key Concepts - -- **Local Repository**: Events are automatically cached and tracked -- **Cache Intelligence**: Smart decisions about when to use cached data -- **Relay Integration**: Works with the router for optimal relay selection -- **Configurable Behavior**: Control caching and timeouts - -## Configuration Options - -```typescript -type SubscribeRequest = { - // Required - filters: Filter[] // What to query - - // Behavior Control - closeOnEose?: boolean // Auto-close and use cache - timeout?: number // Max time to wait - authTimeout?: number // Time for auth negotiation - requestDelay?: number // Delay between batched requests - - // Optional - relays?: string[] // Specific relays to query - - // Event Handlers - onEvent?: (event: TrustedEvent) => void - onEose?: (url: string) => void - onComplete?: () => void -} -``` - -## Cache Behavior Control - -The `closeOnEose` parameter is crucial for controlling caching behavior: - -```typescript -// WITH closeOnEose: true (default for load()) -// - Checks cache first -// - Returns cached results if complete -// - Closes after EOSE -// - Good for: Known events, historical data -const loadKnownEvent = async (id: string) => { - const events = await load({ - filters: [{ids: [id]}], - closeOnEose: true - }) - return events[0] -} - -// WITH closeOnEose: false -// - Always queries relays -// - Stays open for updates -// - Ignores cache completeness -// - Good for: Replaceable events, live data -const watchProfile = (pubkey: string) => { - return subscribe({ - filters: [{ - kinds: [PROFILE], - authors: [pubkey] - }], - closeOnEose: false // Force relay query - }) -} -``` - -## Common Usage Patterns - -### One-time Queries - -```typescript -// Load specific event -const event = await load({ - filters: [{ids: [eventId]}] - // closeOnEose: true by default -}) - -// Load latest profile -const profile = await load({ - filters: [{ - kinds: [PROFILE], - authors: [pubkey], - limit: 1 - }], - closeOnEose: false // Get latest from network -}) -``` - -### Live Subscriptions - -```typescript -// Watch for updates -const sub = subscribe({ - filters: [{ - kinds: [NOTE], - since: now() // Only new events - }], - closeOnEose: false, // Stay open -}) - -sub.on('event', (url, event) => { - // Handle live events -}) -``` - -### Smart Caching - -```typescript -// Profile loader with refresh control -const loadProfile = async (pubkey: string, options = {}) => { - const { - forceRefresh = false, // Skip cache - timeout = 3000, // Max wait time - relays = [] // Optional relay override - } = options - - // Get optimal relays if not specified - const targetRelays = relays.length > 0 - ? relays - : ctx.app.router.ForPubkey(pubkey).getUrls() - - return new Promise((resolve) => { - const sub = subscribe({ - filters: [{ - kinds: [PROFILE], - authors: [pubkey], - limit: 1 - }], - relays: targetRelays, - closeOnEose: !forceRefresh, // Control cache behavior - timeout, - - onEvent: (url, event) => { - resolve(event) - sub.close() - }, - - onComplete: () => resolve(null) - }) - }) -} -``` - -## Repository Integration - -All events from subscriptions are automatically: -- Saved to the repository -- Tracked to their source relay -- Checked against deletion status - -The repository serves as an intelligent cache layer, making subsequent queries for the same data faster. diff --git a/docs/app/tags.md b/docs/app/tags.md index 4d697a9..4db9dfa 100644 --- a/docs/app/tags.md +++ b/docs/app/tags.md @@ -1,12 +1,13 @@ # Tag Utilities The tag utilities provide helper functions for creating properly formatted Nostr event tags with correct relay hints and metadata. + These are especially useful when creating events that reference other events or users. ## Tag Creators +### Pubkey Tags -### User Tags ```typescript import {tagPubkey} from '@welshman/app' @@ -23,7 +24,7 @@ import { tagEvent, // Basic event reference tagEventForQuote, // For quoting events tagEventForReply, // For reply threads - tagEventForComment, // For NIP-23 comments + tagEventForComment, // For NIP-22 comments tagEventForReaction // For reactions } from '@welshman/app' @@ -36,34 +37,10 @@ const createReply = async (parent: TrustedEvent, content: string) => { // - Relay hints const tags = tagEventForReply(parent) - const event = await signer.get().sign( - createEvent(NOTE, { - content, - tags, - created_at: now() - }) - ) - return publishThunk({ - event, // Use relay hints from tags - relays: ctx.app.router.PublishEvent(event).getUrls() + relays: Router.get().PublishEvent(event).getUrls() + event: await signer.get().sign(createEvent(NOTE, {content, tags})), }) } ``` - -All tag creators: -- Add appropriate relay hints using the router -- Handle replaceable/parameterized events -- Follow adequate NIP-10/NIP-22 conventions for threading -- Include metadata like usernames -- Deduplicate references -- Preserve tag order - -The tagging system is crucial for: -- Thread construction -- Event reactions -- User mentions -- Zap splits - -Tag utilities ensure consistent and correct tag creation across the application while integrating with the router for relay hints. diff --git a/docs/app/topics.md b/docs/app/topics.md deleted file mode 100644 index 78e8e31..0000000 --- a/docs/app/topics.md +++ /dev/null @@ -1,39 +0,0 @@ -# Topics - -The topics system provides a reactive way to track and count hashtags across all events in the repository. It automatically updates as new events arrive or are removed. - -```typescript -import {topics} from '@welshman/app' - -// In a Svelte component - - -
- {#each topicList as {name, count}} - - #{name} - ({count}) - - {/each} -
-``` - -The store: -- Updates automatically with new events -- Maintains topic counts -- Is throttled to prevent excess updates -- Is case-insensitive -- Integrates with the repository - -Think of it as a live tag cloud that stays in sync with your local event cache. - -This is commonly used for: -- Tag clouds -- Topic discovery -- Content organization -- Trending topics diff --git a/docs/app/wot.md b/docs/app/wot.md index 6099819..28a7bb3 100644 --- a/docs/app/wot.md +++ b/docs/app/wot.md @@ -1,6 +1,6 @@ -# Web of Trust (WOT) Module +# Web of Trust (WOT) -The `wot.ts` module provides utilities for implementing a Web of Trust system within Nostr applications. This system analyzes social connections (follows and mutes) to build a reputation graph that can be used for content filtering, user scoring, and discovery. +Welshman provides utilities for implementing a Web of Trust system within Nostr applications. This system analyzes social connections (follows and mutes) to build a reputation graph that can be used for content filtering, user scoring, and discovery. ## Core Concepts diff --git a/docs/content/index.md b/docs/content/index.md index 71c5db4..c32e5d8 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -1,5 +1,7 @@ # @welshman/content +[![version](https://badgen.net/npm/v/@welshman/content)](https://npmjs.com/package/@welshman/content) + `@welshman/content` is a comprehensive content processing library designed specifically for Nostr applications. It provides a robust system for parsing, processing, and rendering Nostr content while handling various special formats and entities common in the Nostr ecosystem. diff --git a/docs/dvm/index.md b/docs/dvm/index.md index f02a168..42cf60b 100644 --- a/docs/dvm/index.md +++ b/docs/dvm/index.md @@ -1,5 +1,7 @@ # @welshman/dvm +[![version](https://badgen.net/npm/v/@welshman/dvm)](https://npmjs.com/package/@welshman/dvm) + `@welshman/dvm` is a comprehensive package for building and interacting with Data Vending Machines (DVMs) in the Nostr ecosystem. It provides both server-side DVM implementation capabilities and client-side request handling. ## What is a DVM? @@ -10,6 +12,90 @@ A Data Vending Machine (DVM) is a Nostr service that: - Responds with new events containing processed data - Optionally provides progress updates during processing +# Request example + +```typescript +import {makeDvmRequest, DVMEvent} from '@welshman/dvm' + +const req = makeDvmRequest({ + // Create and sign a dvm request event, including any desired tags + event: createAndSign({kind: 5300}), + // Publish and subscribe to these relays + relays: ['wss://relay.damus.io', 'wss://dvms.f7z.io'], + // Timeout defaults to 30 seconds + timeout: 30_000, + // Auto close on first result (defaults to true) + autoClose: true, + // Listen for and emit `progress` events + reportProgress: true, +}) + +// Listen for progress, result, etc +req.emitter.on(DVMEvent.Progress, (url, event) => console.log(event)) +req.emitter.on(DVMEvent.Result, (url, event) => console.log(event)) +``` + +# Handler example + +```typescript +import {bytesToHex} from '@noble/hashes/utils' +import {generateSecretKey} from 'nostr-tools' +import {createEvent} from '@welshman/util' +import {subscribe} from '@welshman/net' +import {DVM} from '@welshman/dvm' + +// Your DVM's private key. Store this somewhere safe +// const hexPrivateKey = bytesToHex(generateSecretKey()) +const hexPrivateKey = '9cd387a3aa0c1abc2ef517c8402f29c069b4174e02a426491aec7566501bee67' + +// Tags that we'll return as content discovery suggestions +const tags = [] + +// Populate the tags with music by Ainsley Costello +const sub = subscribe({ + timeout: 30_000, + relays: ["wss://relay.wavlake.com"], + filters: [{ + kinds: [31337], + '#p': ['8806372af51515bf4aef807291b96487ea1826c966a5596bca86697b5d8b23bc'], + }], +}) + +// Push event ids to our suggestions +sub.on('event', (url, e) => tags.push(["e", e.id, url])) + +const dvm = new DVM({ + // The private key used to sign events + sk: hexPrivateKey, + // Relays that the DVM will listen on + relays: ['wss://relay.damus.io', 'wss://dvms.f7z.io'], + // Only listen to requests tagging our dvm + requireMention: true, + // Expire results after 1 hour (the default) + expireAfter: 60 * 60, + // Handlers for various kinds + handlers: { + 5300: dvm => ({ + handleEvent: async function* (event) { + // DVM responses are stringified into the content + const content = JSON.stringify(tags) + + // Yield our response. Kind 7000 can be used for partial results too + yield createEvent(event.kind + 1000, {content}) + }, + }), + } +}) + +// Enable logging +dvm.logEvents = true + +// When you're ready +dvm.start() + +// When you're done +dvm.stop() +``` ## Installation diff --git a/docs/editor/index.md b/docs/editor/index.md index 13b8f12..c22a2ef 100644 --- a/docs/editor/index.md +++ b/docs/editor/index.md @@ -1,5 +1,7 @@ # @welshman/editor +[![version](https://badgen.net/npm/v/@welshman/editor)](https://npmjs.com/package/@welshman/editor) + `@welshman/editor` provides a comprehensive Nostr-ready text editor, built on top of [nostr-editor](https://github.com/cesardeazevedo/nostr-editor). This package powers the editors of [Coracle](https://coracle.social) and [Flotilla](https://flotilla.social). diff --git a/docs/feeds/index.md b/docs/feeds/index.md index e9d45b7..d266327 100644 --- a/docs/feeds/index.md +++ b/docs/feeds/index.md @@ -1,7 +1,10 @@ # @welshman/feeds -A powerful package for building and executing dynamic Nostr feeds. -It provides a declarative way to define complex feed compositions using set operations (union, intersection, difference) and various filtering mechanisms. +[![version](https://badgen.net/npm/v/@welshman/feeds)](https://npmjs.com/package/@welshman/feeds) + +A package for building and executing dynamic Nostr feeds which provides a declarative way to define complex feed compositions using set operations (union, intersection, difference) and various filtering mechanisms. + +Read the spec on [the NIPs repository](https://github.com/nostr-protocol/nips/blob/af4329986cae9b0ef625a01c8cefd5e802ca6895/fe.md). ## What's Included @@ -11,6 +14,36 @@ It provides a declarative way to define complex feed compositions using set oper - **Feed Utils** - Helper functions for creating and manipulating feeds - **Feed Types** - Supports authors, kinds, tags, DVMs, lists, WOT, and more +## Quick Example + +```javascript +// Define a feed using set operations +const feed = intersectionFeed( + unionFeed( + dvmFeed({ + kind: 5300, + pubkey: '19b78ccfa7c5e31e6bacbb3f2a1703f64b62017702e584440bf29a7e16263e8c', + }), + listFeed("10003:19ba654f26afd4930fd3d51baf4e26f1413b7aeec7190cd6c0cdf4d2f14cec6b:"), + ) + wotFeed({min: 0.1}), + scopeFeed("global"), +) + +// Create a controller, providing required context via FeedOptions +const controller = new FeedController({ + feed, + request, + requestDVM, + getPubkeysForScope, + getPubkeysForWOTRange, + onEvent: event => console.log("Event", event), + onExhausted: () => console.log("Exhausted"), +}) + +// Load notes using the feed +const events = await controller.load(10) +``` ## Installation diff --git a/docs/getting-started.md b/docs/getting-started.md index 4227fc4..6bf41a1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,23 +13,23 @@ npm i @welshman/relay # Networking and relay management npm i @welshman/net -# Content parsing and rendering -npm i @welshman/content - # Event signing and encryption npm i @welshman/signer # Dynamic feed compilation npm i @welshman/feeds +# Content parsing and rendering +npm i @welshman/content + +# Rich text editor component +npm i @welshman/editor + # Svelte stores and state management npm i @welshman/store -# Full application framework (requires Svelte) +# Complete application framework npm i @welshman/app - -# Rich text editor component (requires Svelte) -npm i @welshman/editor ``` Choose packages based on your needs: @@ -49,4 +49,4 @@ Choose packages based on your needs: npm i @welshman/content ``` -Each package is independent but integrates seamlessly. All packages are framework-agnostic, but work best with Svelte. +Each package is independent but integrates seamlessly. All packages are framework-agnostic, but work best with Svelte due to svelte stores being a common pattern for state management. diff --git a/docs/lib/deferred.md b/docs/lib/deferred.md index 8b4c4dd..8706e89 100644 --- a/docs/lib/deferred.md +++ b/docs/lib/deferred.md @@ -1,6 +1,6 @@ # Deferred Promises -The Deferred module provides utilities for creating promises with exposed resolve/reject functions and typed error handling. This is particularly useful for managing asynchronous operations where you need external control over promise resolution. +The `Deferred` module provides utilities for creating promises with exposed resolve/reject functions and typed error handling. This is particularly useful for managing asynchronous operations where you need external control over promise resolution. ## Types diff --git a/docs/lib/index.md b/docs/lib/index.md index df4c281..fd31d4f 100644 --- a/docs/lib/index.md +++ b/docs/lib/index.md @@ -1,5 +1,7 @@ # @welshman/lib +[![version](https://badgen.net/npm/v/@welshman/lib)](https://npmjs.com/package/@welshman/lib) + A lightweight TypeScript utility library with zero dependencies, providing essential tools for modern JavaScript development. ## What's Included diff --git a/docs/lib/lru.md b/docs/lib/lru.md index 8afab0d..f3aab9b 100644 --- a/docs/lib/lru.md +++ b/docs/lib/lru.md @@ -1,6 +1,6 @@ # LRU Cache -The LRU (Least Recently Used) Cache implementation provides efficient caching with automatic eviction of least recently used items when the cache reaches its maximum size. +A LRU (Least Recently Used) Cache implementation provides efficient caching with automatic eviction of least recently used items when the cache reaches its maximum size. ## Basic Usage diff --git a/docs/lib/normalize-url.md b/docs/lib/normalize-url.md new file mode 100644 index 0000000..c8b437d --- /dev/null +++ b/docs/lib/normalize-url.md @@ -0,0 +1,69 @@ +# URL Normalization + +A `normalizeUrl` function borrowed from [sindresorhus/normalize-url](https://github.com/sindresorhus/normalize-url) is included for convenience. + +## Basic Usage + +```typescript +normalizeUrl('example') +//=> 'http://example' + +normalizeUrl('sindresorhus.com/about.html#contact', {stripHash: true}); +//=> 'http://sindresorhus.com/about.html' +``` + +## API Reference + +### Configuration + +```typescript +export type Options = { + // Default protocol to prepend + readonly defaultProtocol?: 'https' | 'http' + + // Prepends `defaultProtocol` to the URL if it's protocol-relative. + readonly normalizeProtocol?: boolean + + // Normalizes HTTPS URLs to HTTP. + readonly forceHttp?: boolean + + // Normalizes HTTP URLs to HTTPS. + readonly forceHttps?: boolean + + // Strip the [authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) part of a URL. + readonly stripAuthentication?: boolean + + // Removes hash from the URL. + readonly stripHash?: boolean + + // Remove the protocol from the URL: `http://sindresorhus.com` → `sindresorhus.com`. + readonly stripProtocol?: boolean + + // Strip the [text fragment](https://web.dev/text-fragments/) part of the URL + readonly stripTextFragment?: boolean + + // Removes `www.` from the URL. + readonly stripWWW?: boolean + + // Removes query parameters that matches any of the provided strings or regexes. + readonly removeQueryParameters?: ReadonlyArray | boolean + + // Keeps only query parameters that matches any of the provided strings or regexes. + readonly keepQueryParameters?: ReadonlyArray + + // Removes trailing slash. + readonly removeTrailingSlash?: boolean + + // Remove a sole `/` pathname in the output. This option is independent of `removeTrailingSlash`. + readonly removeSingleSlash?: boolean + + // Removes the default directory index file from path that matches any of the provided strings or regexes. + readonly removeDirectoryIndex?: boolean | ReadonlyArray + + // Removes an explicit port number from the URL. + readonly removeExplicitPort?: boolean + + // Sorts the query parameters alphabetically by key. + readonly sortQueryParameters?: boolean +} +``` diff --git a/docs/lib/task-queue.md b/docs/lib/task-queue.md new file mode 100644 index 0000000..398b5e8 --- /dev/null +++ b/docs/lib/task-queue.md @@ -0,0 +1,59 @@ +# Task Queue + +The `TaskQueue` class provides a simple queue processing system with batched operations and throttling. It's designed to handle asynchronous operations efficiently while maintaining control over processing rates and resource usage. + +## Basic Usage + +```typescript +// Create queue for processing messages +const queue = new TaskQueue({ + chunkSize: 10, + processItem: (n: number) => console.log(n) +}) + +// Add and remove items to/from queue +worker.push(10) +worker.push(21) +worker.remove(10) +worker.push(57) +``` + +## Control Methods + +Control message processing: + +```typescript +// Pause processing +worker.stop() + +// Resume processing +worker.start() + +// Clear queue +worker.clear() +``` + +## API Reference + +### Constructor + +```typescript +class TaskQueue { + constructor(readonly options: TaskQueueOptions) {} +} +``` + +The TaskQueue class accepts messages of type `Item` and processes them. + +### Configuration + +```typescript +type TaskQueueOptions = { + // How many items to process at a time + batchSize: number + + // A function for processing items. Any promises returned will be awaited + processItem: (item: Item) => unknown +} +``` + diff --git a/docs/lib/tools.md b/docs/lib/tools.md index 9eb90e7..9048c1c 100644 --- a/docs/lib/tools.md +++ b/docs/lib/tools.md @@ -1,256 +1,449 @@ # Utility Functions -The `Tools.ts` module provides a comprehensive collection of utility functions for common programming tasks. It includes functions for array manipulation, object handling, type checking, math operations, and more. +The `Tools` module provides a comprehensive collection of utility functions for common programming tasks. It includes functions for array manipulation, object handling, type checking, math operations, and more. -## Types +## Basic functional programming utilities ```typescript -type Nil = null | undefined -type Maybe = T | undefined -type Obj = Record +// Function that does nothing and returns undefined +export declare const noop: (...args: unknown[]) => undefined; + +// Returns the input value unchanged +export declare const identity: (x: T, ...args: unknown[]) => T; + +// Creates a function that always returns the same value +export declare const always: (x: T, ...args: unknown[]) => () => T; + +// Returns the logical NOT of a value +export declare const not: (x: any, ...args: unknown[]) => boolean; + +// Deep equality comparison +export declare const equals: (a: any, b: any) => boolean; ``` -## Categories - -### Type Checking & Basic Operations +## Numbers ```typescript -// Check if value is null or undefined -isNil(x: any): boolean +// Converts string or number to number +export declare const ensureNumber: (x: number | string) => number; -// Execute function if value exists -ifLet(x: T | undefined, f: (x: T) => void) +// Converts a `number | undefined` to a number, defaulting to 0 +export declare const num: (x: number | undefined) => number; -// Return value unchanged -identity(x: T): T +// Adds two numbers, handling undefined values +export declare const add: (x: number | undefined, y: number | undefined) => number; -// Create function that always returns same value -always(x: T): () => T +// Subtracts two numbers, handling undefined values +export declare const sub: (x: number | undefined, y: number | undefined) => number; -// Logical NOT -not(x: any): boolean +// Multiplies two numbers, handling undefined values +export declare const mul: (x: number | undefined, y: number | undefined) => number; -// Create complement of a predicate function -complement(f: (...args: T) => any): (...args: T) => boolean +// Divides two numbers, handling undefined values +export declare const div: (x: number | undefined, y: number) => number; + +// Increments a number by 1, handling undefined values +export declare const inc: (x: number | undefined) => number; + +// Decrements a number by 1, handling undefined values +export declare const dec: (x: number | undefined) => number; + +// Less than comparison, handling undefined values +export declare const lt: (x: number | undefined, y: number | undefined) => boolean; + +// Less than or equal comparison, handling undefined values +export declare const lte: (x: number | undefined, y: number | undefined) => boolean; + +// Greater than comparison, handling undefined values +export declare const gt: (x: number | undefined, y: number | undefined) => boolean; + +// Greater than or equal comparison, handling undefined values +export declare const gte: (x: number | undefined, y: number | undefined) => boolean; + +// Returns maximum value in array, handling undefined values +export declare const max: (xs: (number | undefined)[]) => number; + +// Returns minimum value in array, handling undefined values +export declare const min: (xs: (number | undefined)[]) => number; + +// Returns sum of array values, handling undefined values +export declare const sum: (xs: (number | undefined)[]) => number; + +// Returns average of array values, handling undefined values +export declare const avg: (xs: (number | undefined)[]) => number; + +// Checks if a number is between two values (exclusive) +export declare const between: ([low, high]: [number, number], n: number) => boolean; + +// Checks if a number is between two values (inclusive) +export declare const within: ([low, high]: [number, number], n: number) => boolean; + +// Constrains number between min and max values +export declare const clamp: ([min, max]: [number, number], n: number) => number; + +// Round a number to the nearest float precision +export declare const round: (precision: number, x: number) => number; ``` -### Array Operations +## Timestamps ```typescript -// Get first element -first(xs: T[]): T | undefined +// One minute in seconds +export declare const MINUTE = 60; -// Get first element of first array -ffirst(xs: T[][]): T | undefined +// One hour in seconds +export declare const HOUR: number; -// Get last element -last(xs: T[]): T | undefined +// One day in seconds +export declare const DAY: number; -// Drop first n elements -drop(n: number, xs: T[]): T[] +// One week in seconds +export declare const WEEK: number; -// Take first n elements -take(n: number, xs: T[]): T[] +// One month in seconds (approximate) +export declare const MONTH: number; -// Remove duplicates -uniq(xs: T[]): T[] +// One quarter in seconds (approximate) +export declare const QUARTER: number; -// Remove duplicates by key function -uniqBy(f: (x: T) => any, xs: T[]): T[] +// One year in seconds (approximate) +export declare const YEAR: number; -// Create array of n items using generator function -initArray(n: number, f: () => T): T[] +// Multiplies time unit by count +export declare const int: (unit: number, count?: number) => number; -// Split array into chunks -chunk(chunkLength: number, xs: T[]): T[][] +// Returns current Unix timestamp in seconds +export declare const now: () => number; -// Split array into n chunks -chunks(n: number, xs: T[]): T[][] +// Returns Unix timestamp from specified time ago +export declare const ago: (unit: number, count?: number) => number; + +// Converts seconds to milliseconds +export declare const ms: (seconds: number) => number; ``` -### Object Operations +## Sequences ```typescript -// Create object excluding specified keys -omit(ks: string[], x: T): T +// Returns the first element of an array +export declare const first: (xs: Iterable, ...args: unknown[]) => T | undefined; -// Create object excluding entries with specified values -omitVals(xs: any[], x: T): T +// Returns the first element of the first array in a nested array +export declare const ffirst: (xs: Iterable>, ...args: unknown[]) => T | undefined; -// Create object with only specified keys -pick(ks: string[], x: T): T +// Returns the last element of an array +export declare const last: (xs: Iterable, ...args: unknown[]) => T; -// Transform object keys -mapKeys(f: (v: string) => string, x: T): T +// Returns array with first n elements removed +export declare const drop: (n: number, xs: Iterable) => T[]; -// Transform object values -mapVals(f: (v: V) => U, x: Record): Record +// Returns first n elements of array +export declare const take: (n: number, xs: Iterable) => T[]; -// Merge objects (left priority) -mergeLeft(a: T, b: T): T +// Concatenates multiple arrays, filtering out null/undefined +export declare const concat: (...xs: T[][]) => T[]; -// Merge objects (right priority) -mergeRight(a: T, b: T): T +// Appends element to array +export declare const append: (x: T, xs: T[]) => T[]; -// Deep merge objects -deepMergeLeft(a: Obj, b: Obj): Obj -deepMergeRight(a: Obj, b: Obj): Obj +// Creates union of two arrays +export declare const union: (a: T[], b: T[]) => T[]; + +// Returns elements common to both arrays +export declare const intersection: (a: T[], b: T[]) => T[]; + +// Returns elements in first array not present in second +export declare const difference: (a: T[], b: T[]) => T[]; + +// Removes all instances of an element from array +export declare const remove: (a: T, xs: T[]) => T[]; + +// Returns elements from second array not present in first +export declare const without: (a: T[], b: T[]) => T[]; + +// Toggles presence of element in array +export declare const toggle: (x: T, xs: T[]) => T[]; + +// Generates sequence of numbers from a to b +export declare function range(a: number, b: number, step?: number): Generator; + +// Yields indexed items +export declare function enumerate(items: T[]): Generator<[number, T], void, unknown>; + +// Returns a function that gets property value from object +export declare const pluck: (k: string, xs: Record[]) => T[]; + +// Creates object from array of key-value pairs +export declare const fromPairs: (pairs: [k?: string, v?: T, ...args: unknown[]][]) => Record; + +// Flattens array of arrays into single array +export declare const flatten: (xs: T[][]) => T[]; + +// Splits array into two arrays based on predicate +export declare const partition: (f: (x: T) => boolean, xs: T[]) => T[][]; + +// Returns array with duplicate elements removed +export declare const uniq: (xs: T[]) => T[]; + +// Returns array with elements unique by key function +export declare const uniqBy: (f: (x: T) => any, xs: T[]) => T[]; + +// Returns sorted copy of array +export declare const sort: (xs: T[]) => T[]; + +// Returns array sorted by key function +export declare const sortBy: (f: (x: T) => any, xs: T[]) => T[]; + +// Groups array elements by key function +export declare const groupBy: (f: (x: T) => K, xs: T[]) => Map; + +// Creates map from array using key function +export declare const indexBy: (f: (x: T) => K, xs: T[]) => Map; + +// Creates array of specified length using generator function +export declare const initArray: (n: number, f: () => T) => T[]; + +// Splits array into chunks of specified length +export declare const chunk: (chunkLength: number, xs: T[]) => T[][]; + +// Splits array into specified number of chunks +export declare const chunks: (n: number, xs: T[]) => T[][]; + +// Splits array into two parts at index +export declare const splitAt: (n: number, xs: T[]) => T[][]; + +// Inserts element into array at index +export declare const insert: (n: number, x: T, xs: T[]) => T[]; + +// Returns random element from array +export declare const choice: (xs: T[]) => T; + +// Returns shuffled copy of iterable +export declare const shuffle: (xs: Iterable) => T[]; + +// Returns n random elements from array +export declare const sample: (n: number, xs: T[]) => T[]; + +// Checks if value is iterable +export declare const isIterable: (x: any) => boolean; + +// Ensures value is iterable by wrapping in array if needed +export declare const toIterable: (x: any) => any; + +// Ensures value is array by wrapping if needed +export declare const ensurePlural: (x: T | T[]) => T[]; ``` -### Number Operations +## Objects ```typescript -// Convert Maybe to number -num(x: Maybe): number +// Checks if value is a plain object +export declare const isPojo: (obj: any) => boolean; -// Basic arithmetic with Maybe -add(x: Maybe, y: Maybe): number -sub(x: Maybe, y: Maybe): number -mul(x: Maybe, y: Maybe): number -div(x: Maybe, y: number): number +// Creates new object with only specified keys +export declare const pick: (ks: string[], x: T) => T; -// Increment/Decrement -inc(x: Maybe): number -dec(x: Maybe): number +// Creates new object with specified keys removed +export declare const omit: (ks: string[], x: T) => T; -// Comparisons -lt(x: Maybe, y: Maybe): boolean -lte(x: Maybe, y: Maybe): boolean -gt(x: Maybe, y: Maybe): boolean -gte(x: Maybe, y: Maybe): boolean +// Creates new object excluding entries with specified values +export declare const omitVals: (xs: any[], x: T) => T; -// Array number operations -max(xs: Maybe[]): number -min(xs: Maybe[]): number -sum(xs: Maybe[]): number -avg(xs: Maybe[]): number +// Filters object values based on predicate +export declare const filterVals: >(f: (v: any) => boolean, x: T) => T; + +// Creates new object with transformed keys +export declare const mapKeys: (f: (v: string) => string, x: T) => T; + +// Creates new object with transformed values +export declare const mapVals: (f: (v: V) => U, x: Record) => Record; + +// Merges two objects, with left object taking precedence +export declare const mergeLeft: (a: T, b: T) => T; + +// Merges two objects, with right object taking precedence +export declare const mergeRight: (a: T, b: T) => T; + +// Deep merge two objects, prioritizing the first argument. +export declare const deepMergeLeft: (a: Obj, b: Obj) => Obj; + +// Deep merge two objects, prioritizing the second argument. +export declare const deepMergeRight: (a: Obj, b: Obj) => Obj; + +// Switches on key in object, with default fallback +export declare const switcher: (k: string, m: Record) => T; ``` -### String Operations +## Combinators ```typescript -// Truncate string with ellipsis -ellipsize(s: string, l: number, suffix = "..."): string +// Returns a function that returns the boolean negation of the given function +export declare const complement: (f: (...args: T) => any) => (...args: T) => boolean; -// URL operations -stripProtocol(url: string): string -displayUrl(url: string): string -displayDomain(url: string): string +// Safely executes function and handles errors +export declare const tryCatch: (f: () => T, onError?: (e: Error) => void) => T | undefined; -// Bech32 encoding/decoding -hexToBech32(prefix: string, hex: string): string -bech32ToHex(b32: string): string +// Creates function that only executes once +export declare const once: (f: (...args: any) => void) => (...args: any) => void; + +// Calls a function +export declare const call: (f: () => T, ...args: unknown[]) => T; + +// Memoizes function results based on arguments +export declare const memoize: (f: (...args: any[]) => T) => (...args: any[]) => T; + +// Executes a function if the value is defined +export declare const ifLet: (x: T | undefined, f: (x: T) => void) => void; ``` -### Collection Operations +## Randomness ```typescript -// Create union of arrays -union(a: T[], b: T[]): T[] +// Generates random integer between min and max (inclusive) +export declare const randomInt: (min?: number, max?: number) => number; -// Get intersection of arrays -intersection(a: T[], b: T[]): T[] - -// Get difference of arrays -difference(a: T[], b: T[]): T[] - -// Remove element from array -remove(a: T, xs: T[]): T[] - -// Filter array by another array -without(a: T[], b: T[]): T[] - -// Toggle element in array -toggle(x: T, xs: T[]): T[] - -// Group array by key function -groupBy(f: (x: T) => K, xs: T[]): Map - -// Create map from array -indexBy(f: (x: T) => K, xs: T[]): Map +// Generates random string ID +export declare const randomId: () => string; ``` -### Time Constants +## Async ```typescript -const MINUTE = 60 -const HOUR = 60 * MINUTE -const DAY = 24 * HOUR -const WEEK = 7 * DAY -const MONTH = 30 * DAY -const QUARTER = 90 * DAY -const YEAR = 365 * DAY +// Creates a promise that resolves after specified time +export declare const sleep: (t: number) => Promise; -// Get current timestamp in seconds -now(): number +// Creates a microtask that yields to other tasks in the event loop +export declare const yieldThread: () => any; -// Get timestamp from ago in seconds -ago(unit: number, count = 1): number +// Creates throttled version of function +export declare const throttle: any>(ms: number, f: F) => F | ((...thisArgs: Parameters) => void); -// Convert seconds to milliseconds -ms(seconds: number): number +// Creates throttled function that returns cached value +export declare const throttleWithValue: (ms: number, f: () => T) => () => T; + +// Creates batching function that collects items +export declare const batch: (t: number, f: (xs: T[]) => void) => (x: T) => void; + +// Creates batching function that returns results +export declare const batcher: (t: number, execute: (request: T[]) => U[] | Promise) => (request: T) => Promise; ``` -### Function Utilities +## URLs ```typescript -// Create function that executes once -once(f: (...args: any) => void): (...args: any) => void +// Removes protocol (http://, https://, etc) from URL +export declare const stripProtocol: (url: string) => string; -// Memoize function results -memoize(f: (...args: any[]) => T): (...args: any[]) => T +// Formats URL for display by removing protocol, www, and trailing slash +export declare const displayUrl: (url: string) => string; -// Create throttled function -throttle any>( - ms: number, - f: F -): F - -// Create batching function -batch( - t: number, - f: (xs: T[]) => void -): (x: T) => void +// Extracts and formats domain from URL +export declare const displayDomain: (url: string) => string; ``` -### Network Utilities +## JSON, localStorage, fetch, event emitters, etc ```typescript -// Fetch JSON with options -fetchJson(url: string, opts?: FetchOpts): Promise +// Safely parses JSON string +export declare const parseJson: (json: string | undefined) => any; -// Post JSON data -postJson(url: string, data: T, opts?: FetchOpts): Promise +// Gets and parses JSON from localStorage +export declare const getJson: (k: string) => any; -// Upload file -uploadFile(url: string, file: File): Promise +// Stringifies and stores value in localStorage +export declare const setJson: (k: string, v: any) => void; + +// Options for fetch requests +type FetchOpts = { + method?: string; + headers?: Record; + body?: string | FormData; +}; + +// Fetches JSON from URL with options +export declare const fetchJson: (url: string, opts?: FetchOpts) => Promise; + +// Posts JSON data to URL +export declare const postJson: (url: string, data: T, opts?: FetchOpts) => Promise; + +// Uploads file to URL +export declare const uploadFile: (url: string, file: File) => Promise; + +// A generic type-safe event listener function that works with event emitters. +export declare const on: , E extends keyof EventMap>(target: { + on(event: E, listener: (...args: EventMap[E]) => any): any; + off(event: E, listener: (...args: EventMap[E]) => any): any; +}, eventName: E, callback: (...args: EventMap[E]) => void) => (() => void); ``` -## Usage Examples +## Strings ```typescript -// Array operations -const nums = [1, 2, 2, 3, 3, 3] -uniq(nums) // => [1, 2, 3] +// Truncates string to length, breaking at word boundaries +export declare const ellipsize: (s: string, l: number, suffix?: string) => string; -// Object operations -const obj = {a: 1, b: 2, c: 3} -omit(['a', 'b'], obj) // => {c: 3} - -// Number operations -add(5, undefined) // => 5 -inc(undefined) // => 1 - -// Time operations -ago(DAY, 2) // => timestamp from 2 days ago - -// URL operations -displayUrl('https://www.example.com/') // => 'example.com' - -// Collection operations -const users = [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}] -indexBy(u => u.id, users) // => Map(1 => {id: 1, name: 'Alice'}, ...) - -// Function utilities -const throttledFn = throttle(1000, () => console.log('throttled')) +// Generates a hash string from input string +export declare const hash: (s: string) => string; ``` + +## Curried utilities for working with collections + +```typescript +// Returns a function that gets the nth element of an array +export declare const nth: (i: number) => (xs: T[], ...args: unknown[]) => T; + +// Returns a function that checks if nth element equals value +export declare const nthEq: (i: number, v: any) => (xs: any[], ...args: unknown[]) => boolean; + +// Returns a function that checks if nth element does not equal value +export declare const nthNe: (i: number, v: any) => (xs: any[], ...args: unknown[]) => boolean; + +// Returns a function that checks if key/value pairs of x match all pairs in spec +export declare const spec: (values: Obj | Array) => (x: Obj | Array, ...args: unknown[]) => boolean; + +// Returns a function that checks equality with value +export declare const eq: (v: T) => (x: T) => boolean; + +// Returns a function that checks inequality with value +export declare const ne: (v: T) => (x: T) => boolean; + +// Returns a function that gets property value from object +export declare const prop: (k: string) => (x: Record) => T; + +// Returns a function that adds/updates a property on object +export declare const assoc: (k: K, v: T) => (o: U) => U & Record; + +// Returns a function that removes a property on object +export declare const dissoc: (k: K) => (o: T) => T; +``` + +## Sets + +```typescript +// Adds value to Set at key in object +export declare const addToKey: (m: Record>, k: string, v: T) => void; + +// Pushes value to array at key in object +export declare const pushToKey: (m: Record, k: string, v: T) => void; +``` + +## Maps + +```typescript +// Adds value to Set at key in Map +export declare const addToMapKey: (m: Map>, k: K, v: T) => void; + +// Pushes value to array at key in Map +export declare const pushToMapKey: (m: Map, k: K, v: T) => void; +``` + +## Bech32 <-> hex encoding + +```typescript +// Converts hex string to bech32 format +export declare const hexToBech32: (prefix: string, hex: string) => `${Lowercase}1${string}`; + +// Converts bech32 string to hex format +export declare const bech32ToHex: (b32: string) => string; +``` + diff --git a/docs/lib/worker.md b/docs/lib/worker.md deleted file mode 100644 index b56d98e..0000000 --- a/docs/lib/worker.md +++ /dev/null @@ -1,117 +0,0 @@ -# Worker - -The Worker class provides a robust queue processing system with batched operations, throttling, and message routing capabilities. It's designed to handle asynchronous operations efficiently while maintaining control over processing rates and resource usage. - -## Overview - -```typescript -class Worker { - constructor(readonly opts: WorkerOpts = {}) -} -``` - -The Worker class accepts messages of type `T` and processes them according to configured options and handlers. - -## Configuration - -```typescript -type WorkerOpts = { - // Function to determine routing key for messages - getKey?: (x: T) => any - - // Function to check if message should be deferred - shouldDefer?: (x: T) => boolean - - // Maximum messages to process in one batch - chunkSize?: number // default: 50 - - // Milliseconds between processing batches - delay?: number // default: 50 -} -``` - -## Basic Usage - -```typescript -// Create worker for processing messages -const worker = new Worker({ - chunkSize: 10, - delay: 100, - getKey: msg => msg.type -}) - -// Add message handlers -worker.addHandler('email', async (msg) => { - await sendEmail(msg) -}) - -worker.addHandler('notification', async (msg) => { - await sendNotification(msg) -}) - -// Add messages to queue -worker.push({ - type: 'email', - content: 'Hello' -}) -``` - -## Features - -### Message Routing - -Messages can be routed to specific handlers based on a key: - -```typescript -const worker = new Worker({ - getKey: task => task.priority -}) - -// Handle high priority tasks -worker.addHandler('high', async (task) => { - await processUrgent(task) -}) - -// Handle normal priority tasks -worker.addHandler('normal', async (task) => { - await processNormal(task) -}) -``` - -### Global Handlers - -Handle all messages regardless of routing key: - -```typescript -worker.addGlobalHandler(async (message) => { - console.log('Processing:', message) -}) -``` - -### Message Deferral - -Defer processing of messages that aren't ready: - -```typescript -const worker = new Worker({ - shouldDefer: (task) => !task.isReady(), - delay: 1000 -}) - -worker.push(task) // Will retry until task.isReady() -``` - -### Flow Control - -Control message processing: - -```typescript -// Pause processing -worker.pause() - -// Resume processing -worker.resume() - -// Clear queue -worker.clear() -``` diff --git a/docs/net/index.md b/docs/net/index.md index 3908402..e38dccd 100644 --- a/docs/net/index.md +++ b/docs/net/index.md @@ -1,5 +1,7 @@ # @welshman/net +[![version](https://badgen.net/npm/v/@welshman/net)](https://npmjs.com/package/@welshman/net) + Core networking layer for nostr applications, handling relay connections, message management, and event delivery. ## What's Included @@ -12,6 +14,66 @@ Core networking layer for nostr applications, handling relay connections, messag - **Targets** - Flexible message routing strategies - **Event Tracking** - Monitor which relays have seen events +## Quick Example + +```typescript +import {ctx, setContext} from '@welshman/lib' +import {type TrustedEvent, createEvent, NOTE} from '@welshman/util' +import {subscribe, publish, getDefaultNetContext} from '@welshman/net' + +// Sets up customizable event valdation, handlers, etc +setContext(getDefaultNetContext()) + +// Send a subscription +const sub = subscribe({ + relays: ['wss://relay.example.com/'], + filters: [{kinds: [1], limit: 1}], + closeOnEose: true, + timeout: 10000, +}) + +sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => { + console.log(url, event) + sub.close() +}) + +// Publish an event +const pub = publish({ + relays: ['wss://relay.example.com/'], + event: createEvent(NOTE, {content: 'hi'}), +}) + +pub.emitter.on('*', (status: PublishStatus, url: string) => { + console.log(status, url) +}) + +// The Tracker class can tell you which relays an event was read from or published to +console.log(ctx.net.tracker.getRelays(event.id)) +``` + +The main reason this module exists is to support different backends via Executor and different `target` classes. For example, to add a local relay that automatically gets used: + +```typescript +import {setContext} from '@welshman/lib' +import {LOCAL_RELAY_URL, Relay, Repository} from '@welshman/util' +import {getDefaultNetContext, Multi, Local, Relays, Executor} from '@welshman/net' + +const repository = new Repository() + +const relay = new Relay(repository) + +setContext(getDefaultNetContext({ + getExecutor: (relays: string[]) => { + return new Executor( + new Multi([ + new Local(relay), + new Relays(remoteUrls.map(url => ctx.net.pool.get(url))), + ]) + ) + }, +})) +``` + ## Installation ```bash diff --git a/docs/relay/index.md b/docs/relay/index.md new file mode 100644 index 0000000..1afa062 --- /dev/null +++ b/docs/relay/index.md @@ -0,0 +1,21 @@ +# @welshman/relay + +[![version](https://badgen.net/npm/v/@welshman/relay)](https://npmjs.com/package/@welshman/relay) + +Core networking layer for nostr applications, handling relay connections, message management, and event delivery. + +## What's Included + +- **Event Store** - A Repository class which stores events in memory +- **Relay Adapter** - A LocalRelay class which adapts nostr messages to the repository + +## Quick Example + +```typescript +``` + +## Installation + +```bash +npm install @welshman/relay +``` diff --git a/docs/signer/index.md b/docs/signer/index.md index 72baece..6a20d8c 100644 --- a/docs/signer/index.md +++ b/docs/signer/index.md @@ -1,8 +1,8 @@ # @welshman/signer -A comprehensive Nostr signing implementation that supports multiple authentication methods and encryption standards. -It provides a unified interface for working with different signing mechanisms while maintaining compatibility with various Nostr Implementation Possibilities (NIPs). +[![version](https://badgen.net/npm/v/@welshman/signer)](https://npmjs.com/package/@welshman/signer) +A Nostr signer implementation that supports multiple authentication methods and encryption standards. ## What's Included @@ -13,11 +13,19 @@ It provides a unified interface for working with different signing mechanisms wh - **NIP-55 Signer** - Native app integration via Capacitor - **NIP-59 Utils** - Gift Wrap protocol for secure event encryption +## Quick Example +```typescript +import { makeEvent } from '@welshman/util' +import { ISigner, Nip01Signer, makeSecret } from '@welshman/signer' + +const signer: ISigner = new Nip01Signer(makeSecret()) + +signer.sign(makeEvent(1)).then(signedEvent => console.log(signedEvent)) +``` ## Installation - ```bash npm install @welshman/signer ``` diff --git a/docs/store/index.md b/docs/store/index.md index 2c5e2df..7a0c133 100644 --- a/docs/store/index.md +++ b/docs/store/index.md @@ -1,6 +1,7 @@ # @welshman/store -A utility package designed specifically for Svelte applications, providing enhanced store functionality and utilities for managing state. While it's primarily built for use with Svelte's store system, the concepts could be valuable for developers familiar with reactive programming patterns like RxJS. +[![version](https://badgen.net/npm/v/@welshman/store)](https://npmjs.com/package/@welshman/store) +A utility package providing welshman-specific svelte store functionality and utilities for managing state. While it's primarily built for use with Svelte's store system, the concepts could be valuable for developers familiar with reactive programming patterns like RxJS. ## What's Included @@ -10,6 +11,21 @@ A utility package designed specifically for Svelte applications, providing enhan - **Persistence Layer** - Automatic localStorage synchronization - **Performance Optimizations** - Throttled updates and efficient subscription management +## Quick Example + +```typescript +import {Repository, NAMED_PEOPLE, TrustedEvent, PublishedList, readList} from '@welshman/util' +import {deriveEventsMapped} from '@welshman/store' + +const repository = new Repository() + +// Create a store that performantly maps matching events in the repository to List objects +const lists = deriveEventsMapped(repository, { + filters: [{kinds: [NAMED_PEOPLE]}], + eventToItem: (event: TrustedEvent) => (event.tags.length > 1 ? readList(event) : null), + itemToEvent: (list: PublishedList) => list.event, +}) +``` ## Installation diff --git a/docs/what-is-welshman.md b/docs/what-is-welshman.md index 5db5567..f88a86e 100644 --- a/docs/what-is-welshman.md +++ b/docs/what-is-welshman.md @@ -12,9 +12,3 @@ Need just a content parser? Grab @welshman/content. Building a complex client? S Each module is battle-tested in production, designed to work together but never dependent on each other. Build your next Nostr client with the same tools that power today's leading Nostr applications. - - diff --git a/packages/app/README.md b/packages/app/README.md deleted file mode 100644 index be366fb..0000000 --- a/packages/app/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# @welshman/app [![version](https://badgen.net/npm/v/@welshman/app)](https://npmjs.com/package/@welshman/app) - -Utilities for dealing with svelte stores when using welshman. - -```typescript -import {ctx, setContext} from '@welshman/lib' -import {getNip07} from '@welshman/signer' -import {throttled} from '@welshman/store' -import {createEvent, NOTE} from '@welshman/util' -import { - getDefaultNetContext, - getDefaultAppContext, - signer, - pubkey, - publishThunk, - load, - initStorage, - storageAdapters, - freshness, - plaintext, - repository, - tracker, -} from '@welshman/app' - -// Set up app config -setContext({ - net: getDefaultNetContext(), - app: getDefaultAppContext(), -}) - -// Log in via NIP 07 -addSession({method: 'nip07', pubkey: await getNip07().getPubkey()}) - -// Signer is ready to go -const event = signer.get().encrypt(/* ... */) - -// This will fetch the user's profile automatically, and return an observable that updates -// automatically. Several different stores exist that are ready to go, including handles, -// zappers, relaySelections, relays, follows, mutes. -const profile = deriveProfile(pubkey.get()) - -// A global router helps make intelligent relay selections -const router = ctx.app.router - -// Publish is done using thunks, which optimistically publish to the local database, deferring -// signing and publishing for instant user feedback. Progress is reported as relays accept/reject the event -const thunk = publishThunk({ - relays: router.FromUser().getUrls(), - event: createEvent(NOTE, {content: "hi"}), - delay: 3000, -}) - -// Thunks can be aborted until after `delay`, allowing for soft-undo -thunk.controller.abort() - -// Subscriptions automatically infer relays using `router` if not provided. If the request can be cached, -// results from the local repository are returned immediately. `subscribe` and `load` are both available -const events = await load({filters: [{kinds: [NOTE]}]) - -// Some commands are included -const thunk = follow('97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322') - -// Stores can be easily synchronized with indexeddb. Freshness keeps track of how stale the caches are, -// plaintext maps encrypted events to their decrypted content, repository and tracker hold events and -// event/relay mappings, respectively. -const ready = initStorage("my-db", 1, { - relays: {keyPath: "url", store: throttled(3000, relays)}, - handles: {keyPath: "nip05", store: throttled(3000, handles)}, - freshness: storageAdapters.fromObjectStore(freshness, {throttle: 3000}), - plaintext: storageAdapters.fromObjectStore(plaintext, {throttle: 3000}), - events: storageAdapters.fromRepositoryAndTracker(repository, tracker, {throttle: 3000}), -}) -``` diff --git a/packages/app/src/adapters.ts b/packages/app/src/adapters.ts new file mode 100644 index 0000000..2a90355 --- /dev/null +++ b/packages/app/src/adapters.ts @@ -0,0 +1,103 @@ +import {get, derived} from 'svelte/store' +import {batch, fromPairs} from '@welshman/lib' +import {PROFILE, FOLLOWS, MUTES, RELAYS, INBOX_RELAYS, getPubkeyTagValues, getListTags} from '@welshman/util' +import {throttled, withGetter} from '@welshman/store' +import {RepositoryUpdate} from '@welshman/relay' +import {getAll, bulkPut, bulkDelete} from './storage.js' +import {relays} from './relays.js' +import {handles, onHandle} from './handles.js' +import {zappers, onZapper} from './zappers.js' +import {plaintext} from './plaintext.js' +import {freshness} from './freshness.js' +import {repository} from './core.js' +import {sessions} from './session.js' +import {userFollows} from './user.js' + +export const defaultStorageAdapters = { + relays: { + keyPath: "url", + init: async () => relays.set(await getAll("relays")), + sync: () => throttled(3000, relays).subscribe($relays => bulkPut("relays", $relays)), + }, + handles: { + keyPath: "nip05", + init: async () => handles.set(await getAll("handles")), + sync: () => onHandle(batch(300, $handles => bulkPut("handles", $handles))), + }, + zappers: { + keyPath: "lnurl", + init: async () => zappers.set(await getAll("zappers")), + sync: () => onZapper(batch(300, $zappers => bulkPut("zappers", $zappers))), + }, + freshness: { + keyPath: "key", + init: async () => { + const items = await getAll("freshness") + + freshness.set(fromPairs(items.map(item => [item.key, item.value]))) + }, + sync: () => { + const interval = setInterval(() => { + bulkPut( + "freshness", + Object.entries(freshness.get()).map(([key, value]) => ({key, value})), + ) + }, 10_000) + + return () => clearInterval(interval) + }, + }, + plaintext: { + keyPath: "key", + init: async () => { + const items = await getAll("plaintext") + + plaintext.set(fromPairs(items.map(item => [item.key, item.value]))) + }, + sync: () => { + const interval = setInterval(() => { + bulkPut( + "plaintext", + Object.entries(plaintext.get()).map(([key, value]) => ({key, value})), + ) + }, 10_000) + + return () => clearInterval(interval) + }, + }, + events: { + keyPath: "id", + init: async () => repository.load(await getAll("events")), + sync: () => { + const userFollowPubkeys = withGetter( + derived(userFollows, l => new Set(getPubkeyTagValues(getListTags(l)))) + ) + + const onUpdate = async ({added, removed}: RepositoryUpdate) => { + const sessionKeys = new Set(Object.keys(sessions.get())) + const metaKinds = [PROFILE, FOLLOWS, MUTES, RELAYS, INBOX_RELAYS] + + if (removed.size > 0) { + await bulkDelete("events", Array.from(removed)) + } + + if (added.length > 0) { + await bulkPut( + "events", + added.filter(e => { + if (sessionKeys.has(e.pubkey)) return true + if (e.tags.some(t => sessionKeys.has(t[1]))) return true + if (metaKinds.includes(e.kind) && userFollowPubkeys.get()?.has(e.pubkey)) return true + + return false + }), + ) + } + } + + repository.on("update", onUpdate) + + return () => repository.off("update", onUpdate) + }, + }, +} diff --git a/packages/app/src/collection.ts b/packages/app/src/collection.ts index 56bbf1f..2b44e82 100644 --- a/packages/app/src/collection.ts +++ b/packages/app/src/collection.ts @@ -1,5 +1,5 @@ import {readable, derived, type Readable, type Subscriber} from "svelte/store" -import {indexBy, remove, type Maybe, now} from "@welshman/lib" +import {indexBy, remove, now} from "@welshman/lib" import {withGetter} from "@welshman/store" import {getFreshness, setFreshnessThrottled} from "./freshness.js" @@ -15,7 +15,7 @@ export const collection = ({ load?: (key: string, relays: string[]) => Promise }) => { const indexStore = withGetter(derived(store, $items => indexBy(getKey, $items))) - const pending = new Map>>() + const pending = new Map>() const loadAttempts = new Map() let subscribers: Subscriber[] = [] @@ -76,7 +76,7 @@ export const collection = ({ return fresh } - const deriveItem = (key: Maybe, relays: string[] = []) => { + const deriveItem = (key: string | undefined, relays: string[] = []) => { if (!key) { return readable(undefined) } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 73762bd..4200350 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,3 +1,4 @@ +export * from "./adapters.js" export * from "./context.js" export * from "./core.js" export * from "./collection.js" diff --git a/packages/dvm/README.md b/packages/dvm/README.md deleted file mode 100644 index 301cf00..0000000 --- a/packages/dvm/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# @welshman/dvm [![version](https://badgen.net/npm/v/@welshman/dvm)](https://npmjs.com/package/@welshman/dvm) - -Utilities for building nostr DVMs. - -# Request example - -```javascript -import type {Publish, Subscription} from '@welshman/net' -import {makeDvmRequest, DVMEvent} from '@welshman/dvm' - -const req = makeDvmRequest({ - // Create and sign a dvm request event, including any desired tags - event: createAndSign({kind: 5300}), - // Publish and subscribe to these relays - relays: ['wss://relay.damus.io', 'wss://dvms.f7z.io'], - // Timeout defaults to 30 seconds - timeout: 30_000, - // Auto close on first result (defaults to true) - autoClose: true, - // Listen for and emit `progress` events - reportProgress: true, -}) - -// Listen for progress, result, etc -req.emitter.on(DVMEvent.Progress, (url, event) => console.log(event)) -req.emitter.on(DVMEvent.Result, (url, event) => console.log(event)) -``` - -# Handler example - -```javascript -import {bytesToHex} from '@noble/hashes/utils' -import {generateSecretKey} from 'nostr-tools' -import {createEvent} from '@welshman/util' -import {subscribe} from '@welshman/net' -import {DVM} from '@welshman/dvm' - -// Your DVM's private key. Store this somewhere safe -// const hexPrivateKey = bytesToHex(generateSecretKey()) -const hexPrivateKey = '9cd387a3aa0c1abc2ef517c8402f29c069b4174e02a426491aec7566501bee67' - -// Tags that we'll return as content discovery suggestions -const tags = [] - -// Populate the tags with music by Ainsley Costello -const sub = subscribe({ - timeout: 30_000, - relays: ["wss://relay.wavlake.com"], - filters: [{ - kinds: [31337], - '#p': ['8806372af51515bf4aef807291b96487ea1826c966a5596bca86697b5d8b23bc'], - }], -}) - -// Push event ids to our suggestions -sub.on('event', (url, e) => tags.push(["e", e.id, url])) - -const dvm = new DVM({ - // The private key used to sign events - sk: hexPrivateKey, - // Relays that the DVM will listen on - relays: ['wss://relay.damus.io', 'wss://dvms.f7z.io'], - // Only listen to requests tagging our dvm - requireMention: true, - // Expire results after 1 hour (the default) - expireAfter: 60 * 60, - // Handlers for various kinds - handlers: { - 5300: dvm => ({ - handleEvent: async function* (event) { - // DVM responses are stringified into the content - const content = JSON.stringify(tags) - - // Yield our response. Kind 7000 can be used for partial results too - yield createEvent(event.kind + 1000, {content}) - }, - }), - } -}) - -// Enable logging -dvm.logEvents = true - -// When you're ready -dvm.start() - -// When you're done -dvm.stop() -``` diff --git a/packages/editor/README.md b/packages/editor/README.md deleted file mode 100644 index 4979b87..0000000 --- a/packages/editor/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @welshman/editor [![version](https://badgen.net/npm/v/@welshman/editor)](https://npmjs.com/package/@welshman/editor) - -A batteries-included nostr-editor. diff --git a/packages/feeds/README.md b/packages/feeds/README.md deleted file mode 100644 index 10f9415..0000000 --- a/packages/feeds/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# @welshman/feeds [![version](https://badgen.net/npm/v/@welshman/feeds)](https://npmjs.com/package/@welshman/feeds) - -A custom feed compiler and loader for nostr. Read the spec on [wikifreedia](https://wikifreedia.xyz/cip-01/97c70a44366a6535c1). - -# Example - -```javascript -// Define a feed using set operations -const feed = intersectionFeed( - unionFeed( - dvmFeed({ - kind: 5300, - pubkey: '19b78ccfa7c5e31e6bacbb3f2a1703f64b62017702e584440bf29a7e16263e8c', - }), - listFeed("10003:19ba654f26afd4930fd3d51baf4e26f1413b7aeec7190cd6c0cdf4d2f14cec6b:"), - ) - wotFeed({min: 0.1}), - scopeFeed("global"), -) - -// Create a controller, providing required context via FeedOptions -const controller = new FeedController({ - feed, - request, - requestDVM, - getPubkeysForScope, - getPubkeysForWOTRange, - onEvent: event => console.log("Event", event), - onExhausted: () => console.log("Exhausted"), -}) - -// Load notes using the feed -const events = await controller.load(10) -``` diff --git a/packages/lib/README.md b/packages/lib/README.md deleted file mode 100644 index 2c54b85..0000000 --- a/packages/lib/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# @welshman/lib [![version](https://badgen.net/npm/v/@welshman/lib)](https://npmjs.com/package/@welshman/lib) - -Some general-purpose utilities for use in @welshman apps. - -Includes: - -- LRU cache implementation -- TaskQueue for throttling work to avoid locking up the UI -- URL normalization (taken from normalize-url) -- A global `ctx` variable which can be used for global configuration -- CustomPromise, which provides an error type, and `defer` utility -- Ramda-like utilities, but without auto-currying -- Utils for throttling, working with nil, json, fetch, deep equals, etc. diff --git a/packages/lib/__tests__/Tools.test.ts b/packages/lib/__tests__/Tools.test.ts index eb054c3..9ecb51f 100644 --- a/packages/lib/__tests__/Tools.test.ts +++ b/packages/lib/__tests__/Tools.test.ts @@ -11,13 +11,6 @@ describe("Tools", () => { vi.useRealTimers() }) describe("Basic Utils", () => { - it("should check for nil values", () => { - expect(T.isNil(null)).toBe(true) - expect(T.isNil(undefined)).toBe(true) - expect(T.isNil(0)).toBe(false) - expect(T.isNil("")).toBe(false) - }) - it("should handle ifLet", () => { const fn = vi.fn() T.ifLet(undefined, fn) diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index ffdd257..e4dca91 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -1,50 +1,14 @@ import {bech32, utf8} from "@scure/base" -/** Type representing null or undefined */ -export type Nil = null | undefined +type Obj = Record; -/** Checks if a value is null or undefined */ -export const isNil = (x: any) => [null, undefined].includes(x) - -/** Type representing an optional value */ -export type Maybe = T | undefined - -/** Type that is shorthand for Record */ -export type Obj = Record - -/** - * Executes a function if the value is defined - * @param x - The value to check - * @param f - Function to execute if x is defined - * @returns Result of f(x) if x is defined, undefined otherwise - */ -export const ifLet = (x: T | undefined, f: (x: T) => void) => - x === undefined ? undefined : f(x) +// ---------------------------------------------------------------------------- +// Basic functional programming utilities +// ---------------------------------------------------------------------------- /** Function that does nothing and returns undefined */ export const noop = (...args: unknown[]) => undefined -/** - * Returns the first element of an array - * @param xs - The array - * @returns First element or undefined - */ -export const first = (xs: T[], ...args: unknown[]) => xs[0] - -/** - * Returns the first element of the first array in a nested array - * @param xs - Array of arrays - * @returns First element of first array or undefined - */ -export const ffirst = (xs: T[][], ...args: unknown[]) => xs[0][0] - -/** - * Returns the last element of an array - * @param xs - The array - * @returns Last element or undefined - */ -export const last = (xs: T[], ...args: unknown[]) => xs[xs.length - 1] - /** * Returns the input value unchanged * @param x - Any value @@ -69,51 +33,112 @@ export const always = */ export const not = (x: any, ...args: unknown[]) => !x -/** Returns a function that returns the boolean negation of the given function */ -export const complement = - (f: (...args: T) => any) => - (...args: T) => - !f(...args) +/** + * Deep equality comparison + * @param a - First value + * @param b - Second value + * @returns True if values are deeply equal + */ +export const equals = (a: any, b: any) => { + if (a === b) return true -/** Converts a `Maybe` to a number, defaulting to 0 */ -export const num = (x: Maybe) => x || 0 + if (a instanceof Set && b instanceof Set) { + a = Array.from(a) + b = Array.from(b) + } + + if (a instanceof Set) { + if (!(b instanceof Set) || a.size !== b.size) { + return false + } + + return Array.from(a).every(x => b.has(x)) + } + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (!equals(a[i], b[i])) { + return false + } + } + + return true + } + + if (isPojo(a)) { + if (!isPojo(b)) { + return false + } + + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + + if (aKeys.length !== bKeys.length) { + return false + } + + for (const k of aKeys) { + if (!equals(a[k], b[k])) { + return false + } + } + + return true + } + + return false +} + +// ---------------------------------------------------------------------------- +// Numbers +// ---------------------------------------------------------------------------- + +/** Converts string or number to number */ +export const ensureNumber = (x: number | string) => parseFloat(x as string) + +/** Converts a `number | undefined` to a number, defaulting to 0 */ +export const num = (x: number | undefined) => x || 0 /** Adds two numbers, handling undefined values */ -export const add = (x: Maybe, y: Maybe) => num(x) + num(y) +export const add = (x: number | undefined, y: number | undefined) => num(x) + num(y) /** Subtracts two numbers, handling undefined values */ -export const sub = (x: Maybe, y: Maybe) => num(x) - num(y) +export const sub = (x: number | undefined, y: number | undefined) => num(x) - num(y) /** Multiplies two numbers, handling undefined values */ -export const mul = (x: Maybe, y: Maybe) => num(x) * num(y) +export const mul = (x: number | undefined, y: number | undefined) => num(x) * num(y) /** Divides two numbers, handling undefined values */ -export const div = (x: Maybe, y: number) => num(x) / y +export const div = (x: number | undefined, y: number) => num(x) / y /** Increments a number by 1, handling undefined values */ -export const inc = (x: Maybe) => add(x, 1) +export const inc = (x: number | undefined) => add(x, 1) /** Decrements a number by 1, handling undefined values */ -export const dec = (x: Maybe) => sub(x, 1) +export const dec = (x: number | undefined) => sub(x, 1) /** Less than comparison, handling undefined values */ -export const lt = (x: Maybe, y: Maybe) => num(x) < num(y) +export const lt = (x: number | undefined, y: number | undefined) => num(x) < num(y) /** Less than or equal comparison, handling undefined values */ -export const lte = (x: Maybe, y: Maybe) => num(x) <= num(y) +export const lte = (x: number | undefined, y: number | undefined) => num(x) <= num(y) /** Greater than comparison, handling undefined values */ -export const gt = (x: Maybe, y: Maybe) => num(x) > num(y) +export const gt = (x: number | undefined, y: number | undefined) => num(x) > num(y) /** Greater than or equal comparison, handling undefined values */ -export const gte = (x: Maybe, y: Maybe) => num(x) >= num(y) +export const gte = (x: number | undefined, y: number | undefined) => num(x) >= num(y) /** Returns maximum value in array, handling undefined values */ -export const max = (xs: Maybe[]) => xs.reduce((a: number, b) => Math.max(num(a), num(b)), 0) +export const max = (xs: (number | undefined)[]) => xs.reduce((a: number, b) => Math.max(num(a), num(b)), 0) /** Returns minimum value in array, handling undefined values */ -export const min = (xs: Maybe[]) => { - const [head, ...tail] = xs.filter(x => !isNil(x)) as number[] +export const min = (xs: (number | undefined)[]) => { + const [head, ...tail] = xs.filter(x => x !== undefined) as number[] if (tail.length === 0) return head || 0 @@ -121,174 +146,10 @@ export const min = (xs: Maybe[]) => { } /** Returns sum of array values, handling undefined values */ -export const sum = (xs: Maybe[]) => xs.reduce((a: number, b) => add(a, b), 0) +export const sum = (xs: (number | undefined)[]) => xs.reduce((a: number, b) => add(a, b), 0) /** Returns average of array values, handling undefined values */ -export const avg = (xs: Maybe[]) => sum(xs) / xs.length - -/** - * Returns array with first n elements removed - * @param n - Number of elements to drop - * @param xs - Input array - * @returns Array with first n elements removed - */ -export const drop = (n: number, xs: T[]) => xs.slice(n) - -/** - * Returns first n elements of array - * @param n - Number of elements to take - * @param xs - Input array - * @returns Array of first n elements - */ -export const take = (n: number, xs: T[]) => xs.slice(0, n) - -/** - * Creates new object with specified keys removed - * @param ks - Keys to remove - * @param x - Source object - * @returns New object without specified keys - */ -export const omit = (ks: string[], x: T) => { - const r: T = {...x} - - for (const k of ks) { - delete r[k] - } - - return r -} - -/** - * Creates new object excluding entries with specified values - * @param xs - Values to exclude - * @param x - Source object - * @returns New object without entries containing specified values - */ -export const omitVals = (xs: any[], x: T) => { - const r: Obj = {} - - for (const [k, v] of Object.entries(x)) { - if (!xs.includes(v)) { - r[k] = v - } - } - - return r as T -} - -/** - * Creates new object with only specified keys - * @param ks - Keys to keep - * @param x - Source object - * @returns New object with only specified keys - */ -export const pick = (ks: string[], x: T) => { - const r: T = {...x} - - for (const k of Object.keys(x)) { - if (!ks.includes(k)) { - delete r[k] - } - } - - return r -} - -/** - * Generates sequence of numbers from a to b - * @param a - Start number (inclusive) - * @param b - End number (exclusive) - * @param step - Increment between numbers - * @yields Numbers in sequence - */ -export function* range(a: number, b: number, step = 1) { - for (let i = a; i < b; i += step) { - yield i - } -} - -/** - * Yields indexed items - * @param items - A collection of items - * @yields tuples of [index, item] - */ -export function* enumerate(items: T[]) { - for (let i = 0; i < items.length; i += 1) { - yield [i, items[i]] as [number, T] - } -} - -/** - * Creates new object with transformed keys - * @param f - Function to transform keys - * @param x - Source object - * @returns Object with transformed keys - */ -export const mapKeys = (f: (v: string) => string, x: T) => { - const r: Obj = {} - - for (const [k, v] of Object.entries(x)) { - r[f(k)] = v - } - - return r as T -} - -/** - * Creates new object with transformed values - * @param f - Function to transform values - * @param x - Source object - * @returns Object with transformed values - */ -export const mapVals = (f: (v: V) => U, x: Record) => { - const r: Record = {} - - for (const [k, v] of Object.entries(x)) { - r[k] = f(v) - } - - return r -} - -/** - * Merges two objects, with left object taking precedence - * @param a - Left object - * @param b - Right object - * @returns Merged object with a"s properties overriding b"s - */ -export const mergeLeft = (a: T, b: T) => ({ - ...b, - ...a, -}) - -/** - * Merges two objects, with right object taking precedence - * @param a - Left object - * @param b - Right object - * @returns Merged object with b"s properties overriding a"s - */ -export const mergeRight = (a: T, b: T) => ({ - ...a, - ...b, -}) - -/** Deep merge two objects, prioritizing the first argument. */ -export const deepMergeLeft = (a: Obj, b: Obj) => deepMergeRight(b, a) - -/** Deep merge two objects, prioritizing the second argument. */ -export const deepMergeRight = (a: Obj, b: Obj) => { - a = {...a} - - for (const [k, v] of Object.entries(b)) { - if (isPojo(v) && isPojo(a[k])) { - a[k] = deepMergeRight(a[k], v) - } else { - a[k] = v - } - } - - return a -} +export const avg = (xs: (number | undefined)[]) => sum(xs) / xs.length /** * Checks if a number is between two values (exclusive) @@ -307,74 +168,135 @@ export const between = ([low, high]: [number, number], n: number) => n > low && export const within = ([low, high]: [number, number], n: number) => n >= low && n <= high /** - * Generates random integer between min and max (inclusive) - * @param min - Minimum value - * @param max - Maximum value - * @returns Random integer + * Constrains number between min and max values + * @param bounds - Minimum and maximum allowed values + * @param n - Number to clamp + * @returns Clamped value */ -export const randomInt = (min = 0, max = 9) => min + Math.round(Math.random() * (max - min)) +export const clamp = ([min, max]: [number, number], n: number) => Math.min(max, Math.max(min, n)) /** - * Generates random string ID - * @returns Random string suitable for use as an ID + * Round a number to the nearest float precision + * @param precision - Number of decimal places + * @param x - Number to round + * @returns Formatted number */ -export const randomId = (): string => Math.random().toString().slice(2) +export const round = (precision: number, x: number) => + Math.round(x * Math.pow(10, precision)) / Math.pow(10, precision) + +// ---------------------------------------------------------------------------- +// Timestamps +// ---------------------------------------------------------------------------- + +/** One minute in seconds */ + +export const MINUTE = 60 + +/** One hour in seconds */ +export const HOUR = 60 * MINUTE + +/** One day in seconds */ +export const DAY = 24 * HOUR + +/** One week in seconds */ +export const WEEK = 7 * DAY + +/** One month in seconds (approximate) */ +export const MONTH = 30 * DAY + +/** One quarter in seconds (approximate) */ +export const QUARTER = 90 * DAY + +/** One year in seconds (approximate) */ +export const YEAR = 365 * DAY /** - * Removes protocol (http://, https://, etc) from URL - * @param url - URL to process - * @returns URL without protocol + * Multiplies time unit by count + * @param unit - Time unit in seconds + * @param count - Number of units + * @returns Total seconds */ -export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "") +export const int = (unit: number, count = 1) => unit * count + +/** Returns current Unix timestamp in seconds */ +export const now = () => Math.round(Date.now() / 1000) /** - * Formats URL for display by removing protocol, www, and trailing slash - * @param url - URL to format - * @returns Formatted URL + * Returns Unix timestamp from specified time ago + * @param unit - Time unit in seconds + * @param count - Number of units + * @returns Timestamp in seconds */ -export const displayUrl = (url: string) => - stripProtocol(url) - .replace(/^(www\.)?/i, "") - .replace(/\/$/, "") +export const ago = (unit: number, count = 1) => now() - int(unit, count) /** - * Extracts and formats domain from URL - * @param url - URL to process - * @returns Formatted domain name + * Converts seconds to milliseconds + * @param seconds - Time in seconds + * @returns Time in milliseconds */ -export const displayDomain = (url: string) => displayUrl(first(url.split(/[\/\?]/))) +export const ms = (seconds: number) => seconds * 1000 + +// ---------------------------------------------------------------------------- +// Sequences +// ---------------------------------------------------------------------------- /** - * Creates a promise that resolves after specified time - * @param t - Time in milliseconds - * @returns Promise that resolves after t milliseconds + * Returns the first element of an array + * @param xs - The array + * @returns First element or undefined */ -export const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)) - -/** - * Creates a microtask that yields to other tasks in the event loop - * @returns Promise that resolves after yielding - */ -export const yieldThread = () => { - if ( - typeof window !== "undefined" && - "scheduler" in window && - "yield" in (window as any).scheduler - ) { - return (window as any).scheduler.yield() +export const first = (xs: Iterable, ...args: unknown[]) => { + for (const x of xs) { + return x } - - return new Promise(resolve => { - setTimeout(resolve, 0) - }) } +/** + * Returns the first element of the first array in a nested array + * @param xs - Array of arrays + * @returns First element of first array or undefined + */ +export const ffirst = (xs: Iterable>, ...args: unknown[]) => { + for (const chunk of xs) { + for (const x of chunk) { + return x + } + } +} + +/** + * Returns the last element of an array + * @param xs - The array + * @returns Last element or undefined + */ +export const last = (xs: Iterable, ...args: unknown[]) => { + const a = Array.from(xs) + + return a[a.length - 1] +} + +/** + * Returns array with first n elements removed + * @param n - Number of elements to drop + * @param xs - Input array + * @returns Array with first n elements removed + */ +export const drop = (n: number, xs: Iterable) => Array.from(xs).slice(n) + +/** + * Returns first n elements of array + * @param n - Number of elements to take + * @param xs - Input array + * @returns Array of first n elements + */ +export const take = (n: number, xs: Iterable) => Array.from(xs).slice(0, n) + /** * Concatenates multiple arrays, filtering out null/undefined * @param xs - Arrays to concatenate * @returns Combined array */ -export const concat = (...xs: T[][]) => xs.flatMap(x => (isNil(x) ? [] : x)) +export const concat = (...xs: T[][]) => xs.flatMap(x => (x === undefined ? [] : x)) /** * Appends element to array @@ -441,268 +363,29 @@ export const without = (a: T[], b: T[]) => b.filter(x => !a.includes(x)) export const toggle = (x: T, xs: T[]) => (xs.includes(x) ? remove(x, xs) : append(x, xs)) /** - * Constrains number between min and max values - * @param bounds - Minimum and maximum allowed values - * @param n - Number to clamp - * @returns Clamped value + * Generates sequence of numbers from a to b + * @param a - Start number (inclusive) + * @param b - End number (exclusive) + * @param step - Increment between numbers + * @yields Numbers in sequence */ -export const clamp = ([min, max]: [number, number], n: number) => Math.min(max, Math.max(min, n)) - -/** - * Round a number to the nearest float precision - * @param precision - Number of decimal places - * @param x - Number to round - * @returns Formatted number - */ -export const round = (precision: number, x: number) => - Math.round(x * Math.pow(10, precision)) / Math.pow(10, precision) - -/** - * Safely parses JSON string - * @param json - JSON string to parse - * @returns Parsed object or null if invalid - */ -export const parseJson = (json: string | Nil) => { - if (!json) return undefined - - try { - return JSON.parse(json) - } catch (e) { - return undefined +export function* range(a: number, b: number, step = 1) { + for (let i = a; i < b; i += step) { + yield i } } /** - * Gets and parses JSON from localStorage - * @param k - Storage key - * @returns Parsed value or undefined if invalid/missing + * Yields indexed items + * @param items - A collection of items + * @yields tuples of [index, item] */ -export const getJson = (k: string) => parseJson(localStorage.getItem(k) || "") - -/** - * Stringifies and stores value in localStorage - * @param k - Storage key - * @param v - Value to store - */ -export const setJson = (k: string, v: any) => localStorage.setItem(k, JSON.stringify(v)) - -/** - * Safely executes function and handles errors - * @param f - Function to execute - * @param onError - Optional error handler - * @returns Function result or undefined if error - */ -export const tryCatch = (f: () => T, onError?: (e: Error) => void): T | undefined => { - try { - const r = f() - - if (r instanceof Promise) { - r.catch(e => onError?.(e as Error)) - } - - return r - } catch (e) { - onError?.(e as Error) +export function* enumerate(items: T[]) { + for (let i = 0; i < items.length; i += 1) { + yield [i, items[i]] as [number, T] } - - return undefined } -/** - * Truncates string to length, breaking at word boundaries - * @param s - String to truncate - * @param l - Maximum length - * @param suffix - String to append if truncated - * @returns Truncated string - */ -export const ellipsize = (s: string, l: number, suffix = "...") => { - if (s.length < l * 1.1) { - return s - } - - while (s.length > l && s.includes(" ")) { - s = s.split(" ").slice(0, -1).join(" ") - } - - return s + suffix -} - -/** - * Checks if value is a plain object - * @param obj - Value to check - * @returns True if value is a plain object - */ -export const isPojo = (obj: any) => { - if (obj === null || typeof obj !== "object") { - return false - } - - return Object.getPrototypeOf(obj) === Object.prototype -} - -/** - * Deep equality comparison - * @param a - First value - * @param b - Second value - * @returns True if values are deeply equal - */ -export const equals = (a: any, b: any) => { - if (a === b) return true - - if (a instanceof Set && b instanceof Set) { - a = Array.from(a) - b = Array.from(b) - } - - if (a instanceof Set) { - if (!(b instanceof Set) || a.size !== b.size) { - return false - } - - return Array.from(a).every(x => b.has(x)) - } - - if (Array.isArray(a)) { - if (!Array.isArray(b) || a.length !== b.length) { - return false - } - - for (let i = 0; i < a.length; i++) { - if (!equals(a[i], b[i])) { - return false - } - } - - return true - } - - if (isPojo(a)) { - if (!isPojo(b)) { - return false - } - - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - - if (aKeys.length !== bKeys.length) { - return false - } - - for (const k of aKeys) { - if (!equals(a[k], b[k])) { - return false - } - } - - return true - } - - return false -} - -// Curried utils - -/** Returns a function that gets the nth element of an array */ -export const nth = - (i: number) => - (xs: T[], ...args: unknown[]) => - xs[i] - -/** Returns a function that checks if nth element equals value */ -export const nthEq = - (i: number, v: any) => - (xs: any[], ...args: unknown[]) => - xs[i] === v - -/** Returns a function that checks if nth element does not equal value */ -export const nthNe = - (i: number, v: any) => - (xs: any[], ...args: unknown[]) => - xs[i] !== v - -/** Returns a function that checks if key/value pairs of x match all pairs in spec */ -export const spec = - (values: Obj | Array) => - (x: Obj | Array, ...args: unknown[]) => { - if (Array.isArray(values)) { - for (let i = 0; i < values.length; i++) { - if ((x as Array)[i] !== values[i]) { - return false - } - } - } else { - for (const [k, v] of Object.entries(values)) { - if ((x as Obj)[k] !== v) return false - } - } - - return true - } - -/** Returns a function that checks equality with value */ -export const eq = - (v: T) => - (x: T) => - x === v - -/** Returns a function that checks inequality with value */ -export const ne = - (v: T) => - (x: T) => - x !== v - -/** Returns a function that gets property value from object */ -export const prop = - (k: string) => - (x: Record) => - x[k] as T - -/** Returns a function that adds/updates a property on object */ -export const assoc = - (k: K, v: T) => - (o: U) => - ({...o, [k as K]: v}) as U & Record - -/** Returns a function that removes a property on object */ -export const dissoc = - (k: K) => - (o: T) => - omit([k], o) - -/** Generates a hash string from input string */ -export const hash = (s: string) => - Math.abs(s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0)).toString() - -// Collections - -/** Splits array into two parts at index */ -export const splitAt = (n: number, xs: T[]) => [xs.slice(0, n), xs.slice(n)] - -/** Inserts element into array at index */ -export const insert = (n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n)] - -/** Returns random element from array */ -export const choice = (xs: T[]): T => xs[Math.floor(xs.length * Math.random())] - -/** Returns shuffled copy of iterable */ -export const shuffle = (xs: Iterable): T[] => - Array.from(xs).sort(() => (Math.random() > 0.5 ? 1 : -1)) - -/** Returns n random elements from array */ -export const sample = (n: number, xs: T[]) => shuffle(xs).slice(0, n) - -/** Checks if value is iterable */ -export const isIterable = (x: any) => Symbol.iterator in Object(x) - -/** Ensures value is iterable by wrapping in array if needed */ -export const toIterable = (x: any) => (isIterable(x) ? x : [x]) - -/** Ensures value is array by wrapping if needed */ -export const ensurePlural = (x: T | T[]) => (x instanceof Array ? x : [x]) - -/** Converts string or number to number */ -export const ensureNumber = (x: number | string) => parseFloat(x as string) - /** Returns a function that gets property value from object */ export const pluck = (k: string, xs: Record[]) => xs.map(x => x[k] as T) @@ -723,24 +406,6 @@ export const fromPairs = (pairs: [k?: string, v?: T, ...args: unknown[]][]) = return r } -/** - * Filters object values based on predicate - * @param f - Function to test values - * @param x - Object to filter - * @returns Object with only values that pass predicate - */ -export const filterVals = >(f: (v: any) => boolean, x: T) => { - const r = {} as T - - for (const k in x) { - if (f(x[k])) { - r[k] = x[k] - } - } - - return r -} - /** * Flattens array of arrays into single array * @param xs - Array of arrays to flatten @@ -919,6 +584,231 @@ export const chunks = (n: number, xs: T[]) => { return result } +/** Splits array into two parts at index */ +export const splitAt = (n: number, xs: T[]) => [xs.slice(0, n), xs.slice(n)] + +/** Inserts element into array at index */ +export const insert = (n: number, x: T, xs: T[]) => [...xs.slice(0, n), x, ...xs.slice(n)] + +/** Returns random element from array */ +export const choice = (xs: T[]): T => xs[Math.floor(xs.length * Math.random())] + +/** Returns shuffled copy of iterable */ +export const shuffle = (xs: Iterable): T[] => + Array.from(xs).sort(() => (Math.random() > 0.5 ? 1 : -1)) + +/** Returns n random elements from array */ +export const sample = (n: number, xs: T[]) => shuffle(xs).slice(0, n) + +/** Checks if value is iterable */ +export const isIterable = (x: any) => Symbol.iterator in Object(x) + +/** Ensures value is iterable by wrapping in array if needed */ +export const toIterable = (x: any) => (isIterable(x) ? x : [x]) + +/** Ensures value is array by wrapping if needed */ +export const ensurePlural = (x: T | T[]) => (x instanceof Array ? x : [x]) + +// ---------------------------------------------------------------------------- +// Objects +// ---------------------------------------------------------------------------- + +/** + * Checks if value is a plain object + * @param obj - Value to check + * @returns True if value is a plain object + */ +export const isPojo = (obj: any) => { + if (obj === null || typeof obj !== "object") { + return false + } + + return Object.getPrototypeOf(obj) === Object.prototype +} + +/** + * Creates new object with only specified keys + * @param ks - Keys to keep + * @param x - Source object + * @returns New object with only specified keys + */ +export const pick = (ks: string[], x: T) => { + const r: T = {...x} + + for (const k of Object.keys(x)) { + if (!ks.includes(k)) { + delete r[k] + } + } + + return r +} + +/** + * Creates new object with specified keys removed + * @param ks - Keys to remove + * @param x - Source object + * @returns New object without specified keys + */ +export const omit = (ks: string[], x: T) => { + const r: T = {...x} + + for (const k of ks) { + delete r[k] + } + + return r +} + +/** + * Creates new object excluding entries with specified values + * @param xs - Values to exclude + * @param x - Source object + * @returns New object without entries containing specified values + */ +export const omitVals = (xs: any[], x: T) => { + const r: Obj = {} + + for (const [k, v] of Object.entries(x)) { + if (!xs.includes(v)) { + r[k] = v + } + } + + return r as T +} + +/** + * Filters object values based on predicate + * @param f - Function to test values + * @param x - Object to filter + * @returns Object with only values that pass predicate + */ +export const filterVals = >(f: (v: any) => boolean, x: T) => { + const r = {} as T + + for (const k in x) { + if (f(x[k])) { + r[k] = x[k] + } + } + + return r +} + +/** + * Creates new object with transformed keys + * @param f - Function to transform keys + * @param x - Source object + * @returns Object with transformed keys + */ +export const mapKeys = (f: (v: string) => string, x: T) => { + const r: Obj = {} + + for (const [k, v] of Object.entries(x)) { + r[f(k)] = v + } + + return r as T +} + +/** + * Creates new object with transformed values + * @param f - Function to transform values + * @param x - Source object + * @returns Object with transformed values + */ +export const mapVals = (f: (v: V) => U, x: Record) => { + const r: Record = {} + + for (const [k, v] of Object.entries(x)) { + r[k] = f(v) + } + + return r +} + +/** + * Merges two objects, with left object taking precedence + * @param a - Left object + * @param b - Right object + * @returns Merged object with a"s properties overriding b"s + */ +export const mergeLeft = (a: T, b: T) => ({ + ...b, + ...a, +}) + +/** + * Merges two objects, with right object taking precedence + * @param a - Left object + * @param b - Right object + * @returns Merged object with b"s properties overriding a"s + */ +export const mergeRight = (a: T, b: T) => ({ + ...a, + ...b, +}) + +/** Deep merge two objects, prioritizing the first argument. */ +export const deepMergeLeft = (a: Obj, b: Obj) => deepMergeRight(b, a) + +/** Deep merge two objects, prioritizing the second argument. */ +export const deepMergeRight = (a: Obj, b: Obj) => { + a = {...a} + + for (const [k, v] of Object.entries(b)) { + if (isPojo(v) && isPojo(a[k])) { + a[k] = deepMergeRight(a[k], v) + } else { + a[k] = v + } + } + + return a +} + +/** + * Switches on key in object, with default fallback + * @param k - Key to look up + * @param m - Object with values and optional default + * @returns Value at key or default value + */ +export const switcher = (k: string, m: Record) => + m[k] === undefined ? m.default : m[k] + +// ---------------------------------------------------------------------------- +// Combinators +// ---------------------------------------------------------------------------- + +/** Returns a function that returns the boolean negation of the given function */ +export const complement = + (f: (...args: T) => any) => + (...args: T) => + !f(...args) + +/** + * Safely executes function and handles errors + * @param f - Function to execute + * @param onError - Optional error handler + * @returns Function result or undefined if error + */ +export const tryCatch = (f: () => T, onError?: (e: Error) => void): T | undefined => { + try { + const r = f() + + if (r instanceof Promise) { + r.catch(e => onError?.(e as Error)) + } + + return r + } catch (e) { + onError?.(e as Error) + } + + return undefined +} + /** * Creates function that only executes once * @param f - Function to wrap @@ -961,6 +851,62 @@ export const memoize = (f: (...args: any[]) => T) => { } } +/** + * Executes a function if the value is defined + * @param x - The value to check + * @param f - Function to execute if x is defined + * @returns Result of f(x) if x is defined, undefined otherwise + */ +export const ifLet = (x: T | undefined, f: (x: T) => void) => + x === undefined ? undefined : f(x) + +// ---------------------------------------------------------------------------- +// Randomness +// ---------------------------------------------------------------------------- + +/** + * Generates random integer between min and max (inclusive) + * @param min - Minimum value + * @param max - Maximum value + * @returns Random integer + */ +export const randomInt = (min = 0, max = 9) => min + Math.round(Math.random() * (max - min)) + +/** + * Generates random string ID + * @returns Random string suitable for use as an ID + */ +export const randomId = (): string => Math.random().toString().slice(2) + +// ---------------------------------------------------------------------------- +// Async +// ---------------------------------------------------------------------------- + +/** + * Creates a promise that resolves after specified time + * @param t - Time in milliseconds + * @returns Promise that resolves after t milliseconds + */ +export const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)) + +/** + * Creates a microtask that yields to other tasks in the event loop + * @returns Promise that resolves after yielding + */ +export const yieldThread = () => { + if ( + typeof window !== "undefined" && + "scheduler" in window && + "yield" in (window as any).scheduler + ) { + return (window as any).scheduler.yield() + } + + return new Promise(resolve => { + setTimeout(resolve, 0) + }) +} + /** * Creates throttled version of function * @param ms - Minimum time between calls @@ -973,7 +919,7 @@ export const throttle = any>(ms: number, f: F) => } let paused = false - let nextArgs: Maybe> + let nextArgs: Parameters | undefined const unpause = () => { if (nextArgs) { @@ -1065,137 +1011,66 @@ export const batcher = (t: number, execute: (request: T[]) => U[] | Promis }) } -/** - * Adds value to Set at key in object - * @param m - Object mapping keys to Sets - * @param k - Key to add to - * @param v - Value to add - */ -export const addToKey = (m: Record>, k: string, v: T) => { - const s = m[k] || new Set() - - s.add(v) - m[k] = s -} +// ---------------------------------------------------------------------------- +// URLs +// ---------------------------------------------------------------------------- /** - * Pushes value to array at key in object - * @param m - Object mapping keys to arrays - * @param k - Key to push to - * @param v - Value to push + * Removes protocol (http://, https://, etc) from URL + * @param url - URL to process + * @returns URL without protocol */ -export const pushToKey = (m: Record, k: string, v: T) => { - const a = m[k] || [] - - a.push(v) - m[k] = a -} +export const stripProtocol = (url: string) => url.replace(/.*:\/\//, "") /** - * Adds value to Set at key in Map - * @param m - Map of Sets - * @param k - Key to add to - * @param v - Value to add + * Formats URL for display by removing protocol, www, and trailing slash + * @param url - URL to format + * @returns Formatted URL */ -export const addToMapKey = (m: Map>, k: K, v: T) => { - const s = m.get(k) || new Set() - - s.add(v) - m.set(k, s) -} +export const displayUrl = (url: string) => + stripProtocol(url) + .replace(/^(www\.)?/i, "") + .replace(/\/$/, "") /** - * Pushes value to array at key in Map - * @param m - Map of arrays - * @param k - Key to push to - * @param v - Value to push + * Extracts and formats domain from URL + * @param url - URL to process + * @returns Formatted domain name */ -export const pushToMapKey = (m: Map, k: K, v: T) => { - const a = m.get(k) || [] +export const displayDomain = (url: string) => displayUrl(first(url.split(/[\/\?]/)) || "") - a.push(v) - m.set(k, a) -} +// ---------------------------------------------------------------------------- +// JSON, localStorage, fetch, event emitters, etc +// ---------------------------------------------------------------------------- /** - * A generic type-safe event listener function that works with event emitters. - * - * @param target - The event target object with add/remove listener methods - * @param eventName - The name of the event to listen for - * @param callback - The callback function to execute when the event occurs - * @returns A function that removes the event listener when called + * Safely parses JSON string + * @param json - JSON string to parse + * @returns Parsed object or null if invalid */ -export const on = , E extends keyof EventMap>( - target: { - on(event: E, listener: (...args: EventMap[E]) => any): any - off(event: E, listener: (...args: EventMap[E]) => any): any - }, - eventName: E, - callback: (...args: EventMap[E]) => void, -): (() => void) => { - target.on(eventName, callback) +export const parseJson = (json: string | undefined) => { + if (!json) return undefined - return () => { - target.off(eventName, callback) + try { + return JSON.parse(json) + } catch (e) { + return undefined } } /** - * Switches on key in object, with default fallback - * @param k - Key to look up - * @param m - Object with values and optional default - * @returns Value at key or default value + * Gets and parses JSON from localStorage + * @param k - Storage key + * @returns Parsed value or undefined if invalid/missing */ -export const switcher = (k: string, m: Record) => - m[k] === undefined ? m.default : m[k] - -/** One minute in seconds */ - -export const MINUTE = 60 - -/** One hour in seconds */ -export const HOUR = 60 * MINUTE - -/** One day in seconds */ -export const DAY = 24 * HOUR - -/** One week in seconds */ -export const WEEK = 7 * DAY - -/** One month in seconds (approximate) */ -export const MONTH = 30 * DAY - -/** One quarter in seconds (approximate) */ -export const QUARTER = 90 * DAY - -/** One year in seconds (approximate) */ -export const YEAR = 365 * DAY +export const getJson = (k: string) => parseJson(localStorage.getItem(k) || "") /** - * Multiplies time unit by count - * @param unit - Time unit in seconds - * @param count - Number of units - * @returns Total seconds + * Stringifies and stores value in localStorage + * @param k - Storage key + * @param v - Value to store */ -export const int = (unit: number, count = 1) => unit * count - -/** Returns current Unix timestamp in seconds */ -export const now = () => Math.round(Date.now() / 1000) - -/** - * Returns Unix timestamp from specified time ago - * @param unit - Time unit in seconds - * @param count - Number of units - * @returns Timestamp in seconds - */ -export const ago = (unit: number, count = 1) => now() - int(unit, count) - -/** - * Converts seconds to milliseconds - * @param seconds - Time in seconds - * @returns Time in milliseconds - */ -export const ms = (seconds: number) => seconds * 1000 +export const setJson = (k: string, v: any) => localStorage.setItem(k, JSON.stringify(v)) /** Options for fetch requests */ type FetchOpts = { @@ -1261,6 +1136,191 @@ export const uploadFile = (url: string, file: File) => { return fetchJson(url, {method: "POST", body}) } +/** + * A generic type-safe event listener function that works with event emitters. + * + * @param target - The event target object with add/remove listener methods + * @param eventName - The name of the event to listen for + * @param callback - The callback function to execute when the event occurs + * @returns A function that removes the event listener when called + */ +export const on = , E extends keyof EventMap>( + target: { + on(event: E, listener: (...args: EventMap[E]) => any): any + off(event: E, listener: (...args: EventMap[E]) => any): any + }, + eventName: E, + callback: (...args: EventMap[E]) => void, +): (() => void) => { + target.on(eventName, callback) + + return () => { + target.off(eventName, callback) + } +} + +// ---------------------------------------------------------------------------- +// Strings +// ---------------------------------------------------------------------------- + +/** + * Truncates string to length, breaking at word boundaries + * @param s - String to truncate + * @param l - Maximum length + * @param suffix - String to append if truncated + * @returns Truncated string + */ +export const ellipsize = (s: string, l: number, suffix = "...") => { + if (s.length < l * 1.1) { + return s + } + + while (s.length > l && s.includes(" ")) { + s = s.split(" ").slice(0, -1).join(" ") + } + + return s + suffix +} + +/** Generates a hash string from input string */ +export const hash = (s: string) => + Math.abs(s.split("").reduce((a, b) => ((a << 5) - a + b.charCodeAt(0)) | 0, 0)).toString() + +// ---------------------------------------------------------------------------- +// Curried utilities for working with collections +// ---------------------------------------------------------------------------- + +/** Returns a function that gets the nth element of an array */ +export const nth = + (i: number) => + (xs: T[], ...args: unknown[]) => + xs[i] + +/** Returns a function that checks if nth element equals value */ +export const nthEq = + (i: number, v: any) => + (xs: any[], ...args: unknown[]) => + xs[i] === v + +/** Returns a function that checks if nth element does not equal value */ +export const nthNe = + (i: number, v: any) => + (xs: any[], ...args: unknown[]) => + xs[i] !== v + +/** Returns a function that checks if key/value pairs of x match all pairs in spec */ +export const spec = + (values: Obj | Array) => + (x: Obj | Array, ...args: unknown[]) => { + if (Array.isArray(values)) { + for (let i = 0; i < values.length; i++) { + if ((x as Array)[i] !== values[i]) { + return false + } + } + } else { + for (const [k, v] of Object.entries(values)) { + if ((x as Obj)[k] !== v) return false + } + } + + return true + } + +/** Returns a function that checks equality with value */ +export const eq = + (v: T) => + (x: T) => + x === v + +/** Returns a function that checks inequality with value */ +export const ne = + (v: T) => + (x: T) => + x !== v + +/** Returns a function that gets property value from object */ +export const prop = + (k: string) => + (x: Record) => + x[k] as T + +/** Returns a function that adds/updates a property on object */ +export const assoc = + (k: K, v: T) => + (o: U) => + ({...o, [k as K]: v}) as U & Record + +/** Returns a function that removes a property on object */ +export const dissoc = + (k: K) => + (o: T) => + omit([k], o) + +// ---------------------------------------------------------------------------- +// Sets +// ---------------------------------------------------------------------------- + +/** + * Adds value to Set at key in object + * @param m - Object mapping keys to Sets + * @param k - Key to add to + * @param v - Value to add + */ +export const addToKey = (m: Record>, k: string, v: T) => { + const s = m[k] || new Set() + + s.add(v) + m[k] = s +} + +/** + * Pushes value to array at key in object + * @param m - Object mapping keys to arrays + * @param k - Key to push to + * @param v - Value to push + */ +export const pushToKey = (m: Record, k: string, v: T) => { + const a = m[k] || [] + + a.push(v) + m[k] = a +} + +// ---------------------------------------------------------------------------- +// Maps +// ---------------------------------------------------------------------------- + +/** + * Adds value to Set at key in Map + * @param m - Map of Sets + * @param k - Key to add to + * @param v - Value to add + */ +export const addToMapKey = (m: Map>, k: K, v: T) => { + const s = m.get(k) || new Set() + + s.add(v) + m.set(k, s) +} + +/** + * Pushes value to array at key in Map + * @param m - Map of arrays + * @param k - Key to push to + * @param v - Value to push + */ +export const pushToMapKey = (m: Map, k: K, v: T) => { + const a = m.get(k) || [] + + a.push(v) + m.set(k, a) +} + +// ---------------------------------------------------------------------------- +// Bech32 <-> hex encoding +// ---------------------------------------------------------------------------- + /** * Converts hex string to bech32 format * @param prefix - Bech32 prefix diff --git a/packages/net/README.md b/packages/net/README.md deleted file mode 100644 index b543670..0000000 --- a/packages/net/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# @welshman/net [![version](https://badgen.net/npm/v/@welshman/net)](https://npmjs.com/package/@welshman/net) - -Utilities having to do with connection management and nostr messages. - -```typescript -import {ctx, setContext} from '@welshman/lib' -import {type TrustedEvent, createEvent, NOTE} from '@welshman/util' -import {subscribe, publish, getDefaultNetContext} from '@welshman/net' - -// Sets up customizable event valdation, handlers, etc -setContext(getDefaultNetContext()) - -// Send a subscription -const sub = subscribe({ - relays: ['wss://relay.example.com/'], - filters: [{kinds: [1], limit: 1}], - closeOnEose: true, - timeout: 10000, -}) - -sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => { - console.log(url, event) - sub.close() -}) - -// Publish an event -const pub = publish({ - relays: ['wss://relay.example.com/'], - event: createEvent(NOTE, {content: 'hi'}), -}) - -pub.emitter.on('*', (status: PublishStatus, url: string) => { - console.log(status, url) -}) - -// The Tracker class can tell you which relays an event was read from or published to -console.log(ctx.net.tracker.getRelays(event.id)) -``` - -The main reason this module exists is to support different backends via Executor and different `target` classes. For example, to add a local relay that automatically gets used: - -```typescript -import {setContext} from '@welshman/lib' -import {LOCAL_RELAY_URL, Relay, Repository} from '@welshman/util' -import {getDefaultNetContext, Multi, Local, Relays, Executor} from '@welshman/net' - -const repository = new Repository() - -const relay = new Relay(repository) - -setContext(getDefaultNetContext({ - getExecutor: (relays: string[]) => { - return new Executor( - new Multi([ - new Local(relay), - new Relays(remoteUrls.map(url => ctx.net.pool.get(url))), - ]) - ) - }, -})) -``` diff --git a/packages/net/src/publish.ts b/packages/net/src/publish.ts index fa4fbcb..db5c10c 100644 --- a/packages/net/src/publish.ts +++ b/packages/net/src/publish.ts @@ -166,3 +166,5 @@ export class MultiPublish extends EventEmitter { this.removeAllListeners() } } + +export const publish = (options: MultiPublishOptions) => new MultiPublish(options) diff --git a/packages/relay/README.md b/packages/relay/README.md deleted file mode 100644 index b543670..0000000 --- a/packages/relay/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# @welshman/net [![version](https://badgen.net/npm/v/@welshman/net)](https://npmjs.com/package/@welshman/net) - -Utilities having to do with connection management and nostr messages. - -```typescript -import {ctx, setContext} from '@welshman/lib' -import {type TrustedEvent, createEvent, NOTE} from '@welshman/util' -import {subscribe, publish, getDefaultNetContext} from '@welshman/net' - -// Sets up customizable event valdation, handlers, etc -setContext(getDefaultNetContext()) - -// Send a subscription -const sub = subscribe({ - relays: ['wss://relay.example.com/'], - filters: [{kinds: [1], limit: 1}], - closeOnEose: true, - timeout: 10000, -}) - -sub.on(SubscriptionEvent.Event, (url: string, event: TrustedEvent) => { - console.log(url, event) - sub.close() -}) - -// Publish an event -const pub = publish({ - relays: ['wss://relay.example.com/'], - event: createEvent(NOTE, {content: 'hi'}), -}) - -pub.emitter.on('*', (status: PublishStatus, url: string) => { - console.log(status, url) -}) - -// The Tracker class can tell you which relays an event was read from or published to -console.log(ctx.net.tracker.getRelays(event.id)) -``` - -The main reason this module exists is to support different backends via Executor and different `target` classes. For example, to add a local relay that automatically gets used: - -```typescript -import {setContext} from '@welshman/lib' -import {LOCAL_RELAY_URL, Relay, Repository} from '@welshman/util' -import {getDefaultNetContext, Multi, Local, Relays, Executor} from '@welshman/net' - -const repository = new Repository() - -const relay = new Relay(repository) - -setContext(getDefaultNetContext({ - getExecutor: (relays: string[]) => { - return new Executor( - new Multi([ - new Local(relay), - new Relays(remoteUrls.map(url => ctx.net.pool.get(url))), - ]) - ) - }, -})) -``` diff --git a/packages/relay/src/repository.ts b/packages/relay/src/repository.ts index 189fdbf..dc478d1 100644 --- a/packages/relay/src/repository.ts +++ b/packages/relay/src/repository.ts @@ -29,6 +29,11 @@ const getDay = (ts: number) => Math.floor(ts / DAY) export let repositorySingleton: Repository +export type RepositoryUpdate = { + added: TrustedEvent[] + removed: Set +} + export class Repository extends Emitter { eventsById = new Map() eventsByWrap = new Map() diff --git a/packages/signer/README.md b/packages/signer/README.md deleted file mode 100644 index 30b217f..0000000 --- a/packages/signer/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# @welshman/signer [![version](https://badgen.net/npm/v/@welshman/signer)](https://npmjs.com/package/@welshman/signer) - -Implementations of signer utilities and classes. - -## Nips supported - -- NIP 01 (private key login) -- NIP 07 -- NIP 46 -- NIP 55 -- NIP 59 (gift wrapping, works with any signer that supports encryption) - -## Examples - -### NIP 01 - -```typescript -import {makeSecret, Nip01Signer} from '@welshman/signer' - -const signer = Nip01Signer.fromSecret(makeSecret()) -``` - -### NIP 07 - -```typescript -import {getNip07, Nip07Signer} from '@welshman/signer' - -if (getNip07()) { - const signer = new Nip07Signer() -} -``` - -### NIP 55 - -```typescript -import {getNip07, Nip07Signer} from '@welshman/signer' - -if (getNip07()) { - const signer = new Nip07Signer() -} -``` - -### NIP 46 - -```typescript -import {createEvent, NOTE} from '@welshman/util' -import {makeSecret, Nip46Broker, Nip46Signer} from '@welshman/signer' - -const clientSecret = makeSecret() -const relays = ['wss://relay.signer.example/'] -const broker = Nip46Broker.get({relays, clientSecret}) -const signer = new Nip46Signer(broker) -const ncUrl = broker.makeNostrconnectUrl({name: "My app"}) -const abortController = new AbortController() - -let response -try { - response = await broker.waitForNostrconnect(url, abortController) -} catch (e: any) { - if (e?.error) { - showWarning(`Received error from signer: ${e.error}`) - } else if (e) { - console.error(e) - } -} - -if (response) { - // Now we know the bunker's pubkey and can do stuff with the signer - const signerPubkey = response.event.pubkey - - // Next time we want to use our signer, we can instantiate it like so: - const newBroker = Nip46Broker.get({relays, clientSecret, signerPubkey}) - const newSigner = new Nip46Signer(newBroker) -} -``` - -### Using signers - -```typescript -import {createEvent, NOTE, DIRECT_MESSAGE} from '@welshman/util' - -const signer = // Create your signer... -const nip59 = Nip59.fromSigner(signer) - -// Sign an event -const event = await signer.sign(createEvent(NOTE, {content: "hi"})) - -// Wrap a NIP 17 DM -const rumor = await nip59.wrap(recipientPubkey, createEvent(DIRECT_MESSAGE, {content: "hi"})) - -// Note that it returns a rumor; be sure to publish the `wrap` -const wrap = rumor.wrap -``` diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 238c0b1..11de39a 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -10,7 +10,6 @@ import { partition, first, } from "@welshman/lib" -import {Maybe} from "@welshman/lib" import {Repository} from "@welshman/relay" import {matchFilters, getIdAndAddress, getIdFilters, Filter, TrustedEvent} from "@welshman/util" @@ -142,7 +141,7 @@ export const adapter = ({ export type DeriveEventsMappedOptions = { filters: Filter[] - eventToItem: (event: TrustedEvent) => Maybe> + eventToItem: (event: TrustedEvent) => T | T[] | Promise | undefined itemToEvent: (item: T) => TrustedEvent throttle?: number includeDeleted?: boolean diff --git a/packages/util/README.md b/packages/util/README.md deleted file mode 100644 index e62bc23..0000000 --- a/packages/util/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @welshman/util [![version](https://badgen.net/npm/v/@welshman/util)](https://npmjs.com/package/@welshman/util) - -Some nostr-specific utilities. For the most part, these will not have side effects or manage state. Includes: - -- Event kind constants -- A nostr address class -- Utilities for working with nostr filters and tags -- Helpers for working with zap events and lightning invoices -- A `Encryptable` for ensuring payloads get encrypted -- An implementation of an in-memory relay, backed by an events repository -- Utilities for building events, validating signatures, and checking event type (replaceable, etc.) -- Types and utilities for NIP 89 handlers -- Types and utilities for NIP 51 lists -- Types and utilities for NIP 01 profile metadata -- Types and utilities for NIP 11 relay profiles