├── config ├── .gitignore └── default.ts ├── src ├── message │ ├── sender │ │ ├── notice.ts │ │ ├── closed.ts │ │ └── auth.ts │ ├── handler.ts │ ├── handler │ │ ├── close.ts │ │ ├── auth.ts │ │ ├── req.ts │ │ └── event.ts │ └── factory.ts ├── connection.ts ├── repository │ ├── event.ts │ ├── noop │ │ └── event.ts │ ├── factory.ts │ ├── in-memory │ │ └── event.ts │ └── kv │ │ └── d1 │ │ └── event.ts ├── app.ts ├── config.ts ├── index.test.ts ├── Account.ts ├── auth.ts ├── client.tsx ├── nostr.ts ├── index.ts ├── relay.ts └── nostr.test.ts ├── public ├── index.css └── index.js ├── .editorconfig ├── vite.config.ts ├── tsconfig.json ├── migrations └── 0001_create_events_table.sql ├── .gitignore ├── eslint.config.mjs ├── package.json ├── wrangler.jsonc ├── LICENSE └── README.md /config/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !default.* 4 | -------------------------------------------------------------------------------- /src/message/sender/notice.ts: -------------------------------------------------------------------------------- 1 | export function sendNotice(ws: WebSocket, message: string): void { 2 | ws.send(JSON.stringify(["NOTICE", message])); 3 | } 4 | -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | [hidden] { 2 | display: none; 3 | } 4 | 5 | #copy { 6 | border: none; 7 | padding: 0; 8 | background-color: inherit; 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /src/message/handler.ts: -------------------------------------------------------------------------------- 1 | import { Bindings } from "../app"; 2 | 3 | export interface MessageHandler { 4 | handle( 5 | ctx: DurableObjectState, 6 | ws: WebSocket, 7 | env?: Bindings, 8 | ): void | Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/message/sender/closed.ts: -------------------------------------------------------------------------------- 1 | export function sendClosed( 2 | ws: WebSocket, 3 | subscriptionId: string, 4 | prefix: string, 5 | message: string, 6 | ): void { 7 | ws.send(JSON.stringify(["CLOSED", subscriptionId, `${prefix}: ${message}`])); 8 | } 9 | -------------------------------------------------------------------------------- /src/message/sender/auth.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from "../../auth"; 2 | 3 | export function sendAuthChallenge(ws: WebSocket): string { 4 | const challenge = Auth.Challenge.generate(); 5 | ws.send(JSON.stringify(["AUTH", challenge])); 6 | return challenge; 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { 8 | configPath: "./wrangler.toml", 9 | }, 10 | }, 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "lib": ["ESNext"], 9 | "types": ["@cloudflare/workers-types/2023-07-01"], 10 | "jsx": "react-jsx", 11 | "jsxImportSource": "hono/jsx" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import { Auth } from "./auth"; 2 | 3 | // Limited to 2,048 bytes 4 | export type Connection = { 5 | id: string; // UUID: 36 bytes 6 | ipAddress: string | null; // IPv4: 15 bytes, IPv6: 39 bytes 7 | url: string; 8 | auth?: Auth.Session; // { challenge: 36 bytes UUID, challengedAt: 8 bytes timestamp } 9 | pubkeys: Set; // pubkey: 64 bytes 10 | }; 11 | export type Connections = Map; 12 | -------------------------------------------------------------------------------- /src/repository/event.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter } from "nostr-tools"; 2 | 3 | export interface EventRepository { 4 | save(event: Event, ipAddress: string | null): Promise; 5 | saveReplaceableEvent(event: Event, ipAddress: string | null): Promise; 6 | saveAddressableEvent(event: Event, ipAddress: string | null): Promise; 7 | deleteBy(event: Event): Promise; 8 | find(filter: Filter): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /migrations/0001_create_events_table.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0001 2025-05-27T09:09:56.124Z 2 | 3 | CREATE TABLE events ( 4 | id BLOB PRIMARY KEY, 5 | pubkey BLOB NOT NULL, 6 | kind INTEGER NOT NULL, 7 | tags TEXT NULL, 8 | created_at INTEGER NOT NULL 9 | ) STRICT; 10 | 11 | CREATE INDEX pubkey ON events (pubkey, kind, created_at DESC); 12 | CREATE INDEX kind ON events (kind, created_at DESC); 13 | CREATE INDEX created_at ON events (created_at DESC); 14 | -------------------------------------------------------------------------------- /src/repository/noop/event.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools"; 2 | import { EventRepository } from "../event"; 3 | 4 | export class NoopEventRepository implements EventRepository { 5 | async save(): Promise {} 6 | 7 | async saveReplaceableEvent(): Promise {} 8 | 9 | async saveAddressableEvent(): Promise {} 10 | 11 | async deleteBy(): Promise {} 12 | 13 | async find(): Promise { 14 | return []; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Relay } from "./relay"; 2 | 3 | export type Bindings = { 4 | RELAY: DurableObjectNamespace; 5 | events: KVNamespace; 6 | accounts: KVNamespace; 7 | DB: D1Database; 8 | API_TOKEN: string; 9 | ACCOUNT_ID: string; 10 | KV_ID_EVENTS: string; 11 | LOCAL?: string; 12 | }; 13 | 14 | export type Variables = { 15 | pubkey: string; 16 | }; 17 | 18 | export type Env = { 19 | Bindings: Bindings; 20 | Variables: Variables; 21 | }; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # prod 2 | dist/ 3 | 4 | # dev 5 | .yarn/ 6 | !.yarn/releases 7 | .vscode/* 8 | !.vscode/launch.json 9 | !.vscode/*.code-snippets 10 | .idea/workspace.xml 11 | .idea/usage.statistics.xml 12 | .idea/shelf 13 | 14 | # deps 15 | node_modules/ 16 | .wrangler 17 | 18 | # env 19 | .env 20 | .env.production 21 | .dev.vars 22 | 23 | # logs 24 | logs/ 25 | *.log 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | pnpm-debug.log* 30 | lerna-debug.log* 31 | 32 | # misc 33 | .DS_Store 34 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Nip11 } from "nostr-typedef"; 2 | import defaultConfig from "../config/default"; 3 | import overrideConfig from "../config/override"; 4 | import { RepositoryType } from "./repository/factory"; 5 | 6 | export type Config = { 7 | nip11?: Nip11.RelayInfo; 8 | auth_timeout?: number; 9 | auth_limit?: number; 10 | default_limit?: number; 11 | repository_type?: RepositoryType; 12 | }; 13 | 14 | export const config = { ...defaultConfig, ...overrideConfig }; 15 | export const nip11 = { ...defaultConfig.nip11, ...overrideConfig.nip11 }; 16 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import app from "."; 3 | 4 | describe("/", () => { 5 | it("Top page returns 200 OK", async () => { 6 | const response = await app.request("/"); 7 | expect(response.status).toBe(200); 8 | }); 9 | it("NIP-11 returns 200 OK", async () => { 10 | const response = await app.request("/", { 11 | headers: { Accept: "application/nostr+json" }, 12 | }); 13 | expect(response.status).toBe(200); 14 | expect(response.headers.get("Content-Type")).toBe("application/json"); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import eslintConfigPrettier from "eslint-config-prettier"; 5 | 6 | /** @type {import('eslint').Linter.Config[]} */ 7 | export default [ 8 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 9 | { languageOptions: { globals: globals.browser } }, 10 | pluginJs.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ...tseslint.config({ 13 | rules: { 14 | "@typescript-eslint/no-namespace": "off", 15 | }, 16 | }), 17 | { ignores: [".wrangler/"] }, 18 | eslintConfigPrettier, 19 | ]; 20 | -------------------------------------------------------------------------------- /src/repository/factory.ts: -------------------------------------------------------------------------------- 1 | import { Bindings } from "../app"; 2 | import { EventRepository } from "./event"; 3 | import { InMemoryEventRepository } from "./in-memory/event"; 4 | import { KvD1EventRepository } from "./kv/d1/event"; 5 | 6 | export type RepositoryType = "in-memory" | "kv-d1"; 7 | 8 | export class RepositoryFactory { 9 | static create(type: RepositoryType, env: Bindings): EventRepository { 10 | switch (type) { 11 | case "in-memory": { 12 | return new InMemoryEventRepository(); 13 | } 14 | case "kv-d1": { 15 | return new KvD1EventRepository(env); 16 | } 17 | default: { 18 | throw new TypeError(`${type} is not supported.`); 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Account.ts: -------------------------------------------------------------------------------- 1 | import { Bindings } from "./app"; 2 | 3 | type Data = { 4 | agreedAt: number; 5 | }; 6 | 7 | export class Account { 8 | #pubkey: string; 9 | #env: Bindings; 10 | 11 | constructor(pubkey: string, env: Bindings) { 12 | this.#pubkey = pubkey; 13 | this.#env = env; 14 | } 15 | 16 | async exists(): Promise { 17 | const data = await this.#env.accounts.get(this.#pubkey, "json"); 18 | return data !== null; 19 | } 20 | 21 | async register(): Promise { 22 | await this.#env.accounts.put( 23 | this.#pubkey, 24 | JSON.stringify({ agreedAt: Date.now() } satisfies Data), 25 | ); 26 | } 27 | 28 | async unregister(): Promise { 29 | await this.#env.accounts.delete(this.#pubkey); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/default.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "../src/config"; 2 | 3 | export default { 4 | nip11: { 5 | name: "Snowflare", 6 | description: "", 7 | icon: "https://cdn.jsdelivr.net/gh/jdecked/twemoji@15.1.0/assets/72x72/2744.png", 8 | pubkey: "", 9 | contact: "", 10 | supported_nips: [1, 9, 11, 42, 46, 70], 11 | software: "https://github.com/SnowCait/snowflare", 12 | version: "0.1.0", 13 | limitation: { 14 | max_subscriptions: 20, 15 | max_filters: 10, 16 | max_limit: 500, 17 | max_subid_length: 50, 18 | auth_required: false, 19 | restricted_writes: true, 20 | }, 21 | }, 22 | auth_timeout: 600, // seconds 23 | auth_limit: 5, // Within the Connection size limit 24 | default_limit: 50, 25 | repository_type: "kv-d1", 26 | } as const satisfies Config; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snowflare", 3 | "scripts": { 4 | "dev": "wrangler dev", 5 | "deploy": "wrangler deploy --minify", 6 | "lint": "prettier --check . && eslint .", 7 | "format": "prettier --write .", 8 | "test": "vitest" 9 | }, 10 | "dependencies": { 11 | "cloudflare": "^4.3.0", 12 | "hono": "^4.6.14", 13 | "nostr-tools": "^2.10.4" 14 | }, 15 | "devDependencies": { 16 | "@cloudflare/vitest-pool-workers": "^0.8.11", 17 | "@cloudflare/workers-types": "^4.20241218.0", 18 | "@eslint/js": "^9.17.0", 19 | "eslint": "^9.17.0", 20 | "eslint-config-prettier": "^10.1.1", 21 | "globals": "^16.0.0", 22 | "nostr-typedef": "^0.13.0", 23 | "prettier": "^3.5.3", 24 | "typescript-eslint": "^8.18.1", 25 | "vitest": "^3.1.4", 26 | "wrangler": "^4.7.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/message/handler/close.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from "nostr-tools/filter"; 2 | import { Connection } from "../../connection"; 3 | import { MessageHandler } from "../handler"; 4 | 5 | export class CloseMessageHandler implements MessageHandler { 6 | #subscriptionId: string; 7 | 8 | constructor(subscriptionId: string) { 9 | this.#subscriptionId = subscriptionId; 10 | } 11 | 12 | async handle(ctx: DurableObjectState, ws: WebSocket): Promise { 13 | const connection = ws.deserializeAttachment() as Connection; 14 | const subscriptions = await ctx.storage.get>( 15 | connection.id, 16 | ); 17 | if (subscriptions === undefined) { 18 | return; 19 | } 20 | subscriptions.delete(this.#subscriptionId); 21 | await ctx.storage.put(connection.id, subscriptions); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | const registerButton = document.getElementById("register"); 3 | registerButton.addEventListener("click", async () => { 4 | const url = `${location.origin}/register`; 5 | const method = "PUT"; 6 | const token = await window.NostrTools.nip98.getToken(url, method, (event) => 7 | window.nostr.signEvent(event), 8 | ); 9 | const response = await fetch(url, { 10 | method, 11 | headers: { 12 | Authorization: `Nostr ${token}`, 13 | }, 14 | }); 15 | if (response.ok) { 16 | registerButton.hidden = true; 17 | document.getElementById("registered").hidden = false; 18 | } 19 | }); 20 | 21 | const copy = document.getElementById("copy"); 22 | copy.addEventListener("click", () => { 23 | navigator.clipboard.writeText("wss://snowflare.cc/"); 24 | copy.textContent = "✅"; 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools"; 2 | import { config } from "./config"; 3 | import { normalizeURL } from "nostr-tools/utils"; 4 | 5 | export namespace Auth { 6 | export class Challenge { 7 | static generate(): string { 8 | return crypto.randomUUID(); 9 | } 10 | 11 | static validate(event: Event, auth: Session, url: string): boolean { 12 | if (event.kind !== 22242) { 13 | return false; 14 | } 15 | if (auth.challengedAt + config.auth_timeout * 1000 < Date.now()) { 16 | return false; 17 | } 18 | const challenge = event.tags.find(([t]) => t === "challenge")?.at(1); 19 | if (challenge !== auth.challenge) { 20 | return false; 21 | } 22 | const relay = event.tags.find(([t]) => t === "relay")?.at(1); 23 | if (relay === undefined || normalizeURL(relay) !== normalizeURL(url)) { 24 | return false; 25 | } 26 | return true; 27 | } 28 | } 29 | 30 | export type Session = { 31 | challenge: string; 32 | challengedAt: number; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "snowflare", 4 | "main": "src/index.ts", 5 | "compatibility_date": "2024-12-18", 6 | "compatibility_flags": ["nodejs_compat"], 7 | "d1_databases": [ 8 | { 9 | "binding": "DB", 10 | "database_id": "%DB_ID%", 11 | "database_name": "snowflare-events", 12 | }, 13 | ], 14 | "kv_namespaces": [ 15 | { 16 | "binding": "events", 17 | "id": "%KV_ID_EVENTS%", 18 | }, 19 | { 20 | "binding": "accounts", 21 | "id": "%KV_ID_ACCOUNTS%", 22 | }, 23 | ], 24 | "migrations": [ 25 | { 26 | "new_sqlite_classes": ["Relay"], 27 | "tag": "v1", 28 | }, 29 | ], 30 | "assets": { 31 | "directory": "./public/", 32 | }, 33 | "durable_objects": { 34 | "bindings": [ 35 | { 36 | "class_name": "Relay", 37 | "name": "RELAY", 38 | }, 39 | ], 40 | }, 41 | "observability": { 42 | "enabled": true, 43 | "head_sampling_rate": 1, 44 | "logs": { 45 | "invocation_logs": false, 46 | }, 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SnowCait 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/message/factory.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter } from "nostr-tools"; 2 | import { MessageHandler } from "./handler"; 3 | import { EventMessageHandler } from "./handler/event"; 4 | import { ReqMessageHandler } from "./handler/req"; 5 | import { CloseMessageHandler } from "./handler/close"; 6 | import { AuthMessageHandler } from "./handler/auth"; 7 | import { EventRepository } from "../repository/event"; 8 | 9 | export class MessageHandlerFactory { 10 | static create( 11 | message: string, 12 | eventsRepository: EventRepository, 13 | ): MessageHandler | undefined { 14 | try { 15 | const [type, idOrEvent, ...filters] = JSON.parse(message) as [ 16 | string, 17 | string | Event, 18 | Filter, 19 | ]; 20 | switch (type) { 21 | case "EVENT": { 22 | if (typeof idOrEvent !== "object") { 23 | return; 24 | } 25 | return new EventMessageHandler(idOrEvent, eventsRepository); 26 | } 27 | case "REQ": { 28 | if ( 29 | typeof idOrEvent !== "string" || 30 | filters.length < 1 || 31 | filters.some((filter) => typeof filter !== "object") 32 | ) { 33 | return; 34 | } 35 | return new ReqMessageHandler(idOrEvent, filters, eventsRepository); 36 | } 37 | case "CLOSE": { 38 | if (typeof idOrEvent !== "string") { 39 | return; 40 | } 41 | return new CloseMessageHandler(idOrEvent); 42 | } 43 | case "AUTH": { 44 | if (typeof idOrEvent !== "object") { 45 | return; 46 | } 47 | return new AuthMessageHandler(idOrEvent); 48 | } 49 | default: { 50 | return; 51 | } 52 | } 53 | } catch { 54 | return; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snowflare 2 | 3 | Nostr relay running on Cloudflare Workers. 4 | 5 | ## Install 6 | 7 | ```shell 8 | npm install 9 | ``` 10 | 11 | ### Repository: In memory 12 | 13 | Nothing to do. 14 | 15 | ### Repository: KV + D1 16 | 17 | ```shell 18 | npx wrangler kv namespace create accounts 19 | npx wrangler kv namespace create events 20 | npx wrangler d1 create snowflare-events 21 | ``` 22 | 23 | #### Local 24 | 25 | Database: 26 | 27 | ```shell 28 | npx wrangler d1 migrations apply snowflare-events --local 29 | ``` 30 | 31 | Environment variables: 32 | 33 | Create `.dev.vars` file and set your API Token, Account ID and KV ID of events to use [Cloudflare API](https://developers.cloudflare.com/api/resources/kv/subresources/namespaces/subresources/keys/methods/bulk_get/). 34 | 35 | ``` 36 | API_TOKEN= 37 | ACCOUNT_ID= 38 | KV_ID_EVENTS= 39 | LOCAL=true 40 | ``` 41 | 42 | #### Production 43 | 44 | Database: 45 | 46 | ```shell 47 | npx wrangler d1 migrations apply snowflare-events --remote 48 | ``` 49 | 50 | Environment variables: 51 | 52 | ```shell 53 | npx wrangler secret put API_TOKEN 54 | npx wrangler secret put ACCOUNT_ID 55 | npx wrangler secret put KV_ID_EVENTS 56 | ``` 57 | 58 | ## Development 59 | 60 | Copy `config/default.ts` to `config/override.ts` and override them. 61 | 62 | ```shell 63 | npm run dev 64 | ``` 65 | 66 | ## Deploy 67 | 68 | ```shell 69 | npm run deploy 70 | ``` 71 | 72 | ## Export 73 | 74 | ```shell 75 | npx wrangler d1 export snowflare-events --local --output=./backup.local.sql 76 | ``` 77 | 78 | When running in production, replace `local` with `remote`. Note that there will be rows read. 79 | 80 | ## Assets and libraries 81 | 82 | The icon by [jdecked/twemoji](https://github.com/jdecked/twemoji) is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). 83 | [Simple.css](https://github.com/kevquirk/simple.css) is licensed under [MIT](https://github.com/kevquirk/simple.css/blob/main/LICENSE). 84 | [Nostr-Login](https://github.com/nostrband/nostr-login) is licensed under [MIT](https://github.com/nostrband/nostr-login/blob/main/LICENSE). 85 | See [package.json](package.json) for other libraries. 86 | -------------------------------------------------------------------------------- /src/message/handler/auth.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools"; 2 | import { MessageHandler } from "../handler"; 3 | import { config, nip11 } from "../../config"; 4 | import { Auth } from "../../auth"; 5 | import { Connection } from "../../connection"; 6 | import { Account } from "../../Account"; 7 | import { Bindings } from "../../app"; 8 | 9 | export class AuthMessageHandler implements MessageHandler { 10 | #event: Event; 11 | 12 | constructor(event: Event) { 13 | this.#event = event; 14 | } 15 | 16 | async handle( 17 | _: DurableObjectState, 18 | ws: WebSocket, 19 | env: Bindings, 20 | ): Promise { 21 | const connection = ws.deserializeAttachment() as Connection; 22 | 23 | if (connection.pubkeys.has(this.#event.pubkey)) { 24 | ws.send( 25 | JSON.stringify([ 26 | "OK", 27 | this.#event.id, 28 | true, 29 | "duplicate: already authenticated", 30 | ]), 31 | ); 32 | return; 33 | } 34 | 35 | const limit = config.auth_limit; 36 | if (connection.pubkeys.size >= limit) { 37 | console.debug("[AUTH too many]", connection.pubkeys.size); 38 | ws.send( 39 | JSON.stringify(["NOTICE", `too many authentications (> ${limit})`]), 40 | ); 41 | return; 42 | } 43 | 44 | if ( 45 | connection.auth === undefined || 46 | !Auth.Challenge.validate(this.#event, connection.auth, connection.url) 47 | ) { 48 | console.debug("[AUTH invalid]", { event: this.#event }); 49 | ws.send(JSON.stringify(["OK", this.#event.id, false, "invalid: auth"])); 50 | return; 51 | } 52 | 53 | if (nip11.limitation.auth_required || nip11.limitation.restricted_writes) { 54 | const registered = await new Account(this.#event.pubkey, env).exists(); 55 | if (!registered) { 56 | console.debug("[AUTH restricted]", { event: this.#event }); 57 | ws.send( 58 | JSON.stringify([ 59 | "OK", 60 | this.#event.id, 61 | false, 62 | "restricted: required to register", 63 | ]), 64 | ); 65 | return; 66 | } 67 | } 68 | 69 | connection.pubkeys.add(this.#event.pubkey); 70 | ws.serializeAttachment(connection); 71 | ws.send(JSON.stringify(["OK", this.#event.id, true, ""])); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/repository/in-memory/event.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter, matchFilter, sortEvents } from "nostr-tools"; 2 | import { EventRepository } from "../event"; 3 | import { config, nip11 } from "../../config"; 4 | import { hexRegExp } from "../../nostr"; 5 | import { EventDeletion } from "nostr-tools/kinds"; 6 | 7 | export class InMemoryEventRepository implements EventRepository { 8 | #events = new Map(); 9 | 10 | async save(event: Event): Promise { 11 | this.#events.set(event.id, event); 12 | } 13 | 14 | async saveReplaceableEvent(event: Event): Promise { 15 | const results = [...this.#events] 16 | .filter(([, e]) => e.kind === event.kind && e.pubkey === event.pubkey) 17 | .map(([, e]) => e); 18 | console.debug("[existing replaceable event]", { results }); 19 | 20 | await this.#saveLatestEvent(event, results); 21 | } 22 | 23 | async saveAddressableEvent(event: Event): Promise { 24 | const identifier = event.tags.find(([name]) => name === "d")?.at(1) ?? ""; 25 | const results = [...this.#events] 26 | .filter( 27 | ([, e]) => 28 | e.kind === event.kind && 29 | e.pubkey === event.pubkey && 30 | e.tags.find(([name, value]) => name === "d" && value === identifier), 31 | ) 32 | .map(([, e]) => e); 33 | console.debug("[existing addressable event]", { results }); 34 | 35 | await this.#saveLatestEvent(event, results); 36 | } 37 | 38 | async #saveLatestEvent( 39 | event: Event, 40 | results: { id: string; created_at: number }[], 41 | ): Promise { 42 | if (results.length === 0) { 43 | await this.save(event); 44 | return; 45 | } 46 | 47 | const latest = results[0]; 48 | if ( 49 | latest.created_at > event.created_at || 50 | (latest.created_at === event.created_at && 51 | event.id.localeCompare(latest.id) >= 0) 52 | ) { 53 | return; 54 | } 55 | 56 | for (const e of results) { 57 | this.#events.delete(e.id); 58 | } 59 | await this.save(event); 60 | } 61 | 62 | async deleteBy(event: Event): Promise { 63 | const ids = event.tags 64 | .filter(([name, value]) => name === "e" && hexRegExp.test(value)) 65 | .map(([, id]) => id); 66 | const uniqueIds = [...new Set(ids)]; 67 | for (const id of uniqueIds) { 68 | const e = this.#events.get(id); 69 | if ( 70 | e === undefined || 71 | e.pubkey !== event.pubkey || 72 | e.kind === EventDeletion 73 | ) { 74 | continue; 75 | } 76 | this.#events.delete(id); 77 | } 78 | } 79 | 80 | async find(filter: Filter): Promise { 81 | const limit = Math.min( 82 | filter.limit ?? config.default_limit, 83 | nip11.limitation.max_limit, 84 | ); 85 | return sortEvents( 86 | [...this.#events.values()].filter((event) => matchFilter(filter, event)), 87 | ).slice(0, limit); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/client.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | 3 | const app = new Hono(); 4 | 5 | app.get("/", (c) => { 6 | return c.html( 7 | 8 | 9 | 10 | Snowflare 11 | 12 | 16 | 17 | 21 | 25 | 26 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 |

Snowflare

39 |

40 | wss://snowflare.cc/ 41 | is a Nostr relay which requires 42 | registration to write. 43 |

44 |

45 | By registering, you are deemed to have agreed to the Terms of 46 | Service and Privacy Policy. 47 |

48 | 49 | 52 |
53 |
54 |

Terms of Service

55 |

56 | This service is provided as is without warranty of any kind. 57 |
58 | The administrator does not take any responsibility. 59 |
60 | The administrator may delete data or restrict access at their 61 | discretion. 62 |
63 | In addition to illegal activities, nuisance activities such as 64 | spam are also prohibited. 65 |

66 |

Privacy Policy

67 |

68 | Transmitted data and sender information (such as the IP address) 69 | will be stored. 70 |
Transmitted data will be made public. If encrypted, it will 71 | be made public in its encrypted state. 72 |
Sender information will not be made public, but may be 73 | provided to the police, etc. if necessary. 74 |

75 |
76 |
77 | 78 | , 79 | ); 80 | }); 81 | 82 | export default app; 83 | -------------------------------------------------------------------------------- /src/message/handler/req.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter, sortEvents } from "nostr-tools"; 2 | import { MessageHandler } from "../handler"; 3 | import { Connection } from "../../connection"; 4 | import { EventRepository } from "../../repository/event"; 5 | import { validateFilter } from "../../nostr"; 6 | import { nip11 } from "../../config"; 7 | 8 | export class ReqMessageHandler implements MessageHandler { 9 | #subscriptionId: string; 10 | #filters: Filter[]; 11 | #eventsRepository: EventRepository; 12 | 13 | constructor( 14 | subscriptionId: string, 15 | filters: Filter[], 16 | eventsRepository: EventRepository, 17 | ) { 18 | this.#subscriptionId = subscriptionId; 19 | this.#filters = filters; 20 | this.#eventsRepository = eventsRepository; 21 | } 22 | 23 | async handle(ctx: DurableObjectState, ws: WebSocket): Promise { 24 | if (this.#subscriptionId.length > nip11.limitation.max_subid_length) { 25 | console.debug("[too long subscription id]", this.#subscriptionId); 26 | ws.send( 27 | JSON.stringify([ 28 | "CLOSED", 29 | this.#subscriptionId, 30 | "unsupported: too long subscription id", 31 | ]), 32 | ); 33 | return; 34 | } 35 | 36 | if (this.#filters.length > nip11.limitation.max_filters) { 37 | ws.send( 38 | JSON.stringify([ 39 | "CLOSED", 40 | this.#subscriptionId, 41 | "unsupported: too many filters", 42 | ]), 43 | ); 44 | return; 45 | } 46 | 47 | if (this.#filters.some((filter) => !validateFilter(filter))) { 48 | ws.send( 49 | JSON.stringify([ 50 | "CLOSED", 51 | this.#subscriptionId, 52 | "unsupported: filters contain unsupported elements", 53 | ]), 54 | ); 55 | return; 56 | } 57 | 58 | const connection = ws.deserializeAttachment() as Connection; 59 | const subscriptions = 60 | (await ctx.storage.get>(connection.id)) ?? 61 | new Map(); 62 | subscriptions.set(this.#subscriptionId, this.#filters); 63 | if (subscriptions.size > nip11.limitation.max_subscriptions) { 64 | console.debug( 65 | "[too many subscriptions]", 66 | connection.id, 67 | subscriptions.size, 68 | ); 69 | ws.send( 70 | JSON.stringify([ 71 | "CLOSED", 72 | this.#subscriptionId, 73 | "unsupported: too many subscriptions", 74 | ]), 75 | ); 76 | return; 77 | } 78 | 79 | await ctx.storage.put(connection.id, subscriptions); 80 | 81 | const promises = this.#filters.map((filter) => 82 | this.#eventsRepository.find(filter), 83 | ); 84 | const possibleDuplicateEvents = await Promise.all(promises); 85 | const events = possibleDuplicateEvents 86 | .flat() 87 | .reduce((distinctEvents, event): Event[] => { 88 | if (!distinctEvents.some((e) => e.id === event.id)) { 89 | distinctEvents.push(event); 90 | } 91 | return distinctEvents; 92 | }, []); 93 | for (const event of sortEvents(events)) { 94 | ws.send(JSON.stringify(["EVENT", this.#subscriptionId, event])); 95 | } 96 | 97 | ws.send(JSON.stringify(["EOSE", this.#subscriptionId])); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/nostr.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "nostr-tools/core"; 2 | import { Filter, matchFilter } from "nostr-tools/filter"; 3 | import { NostrConnect } from "nostr-tools/kinds"; 4 | import { nip11 } from "./config"; 5 | 6 | export const hexRegExp = /^[0-9a-z]{64}$/; 7 | export const tagsFilterRegExp = /^#[a-zA-Z]$/; 8 | 9 | export const idsFilterKeys: string[] = ["ids", "limit"] satisfies Array< 10 | keyof Pick 11 | >; 12 | export const nonTagKeys: string[] = [ 13 | "ids", 14 | "authors", 15 | "kinds", 16 | "since", 17 | "until", 18 | "limit", 19 | ] satisfies Array< 20 | keyof Pick 21 | >; 22 | export const hexTagKeys: string[] = [ 23 | "#e", 24 | "#E", 25 | "#p", 26 | "#P", 27 | "#q", 28 | ] satisfies Array>; 29 | 30 | export function validateFilter(filter: Filter): boolean { 31 | if ( 32 | filter.ids !== undefined && 33 | !( 34 | Array.isArray(filter.ids) && 35 | filter.ids.length > 0 && 36 | filter.ids.every((id) => hexRegExp.test(id)) 37 | ) 38 | ) { 39 | return false; 40 | } 41 | 42 | if ( 43 | filter.authors !== undefined && 44 | !( 45 | Array.isArray(filter.authors) && 46 | filter.authors.length > 0 && 47 | filter.authors.every((pubkey) => hexRegExp.test(pubkey)) 48 | ) 49 | ) { 50 | return false; 51 | } 52 | 53 | if ( 54 | filter.kinds !== undefined && 55 | !( 56 | Array.isArray(filter.kinds) && 57 | filter.kinds.length > 0 && 58 | filter.kinds.every( 59 | (kind) => Number.isInteger(kind) && 0 <= kind && kind <= 65535, 60 | ) 61 | ) 62 | ) { 63 | return false; 64 | } 65 | 66 | if ( 67 | filter.since !== undefined && 68 | !(Number.isInteger(filter.since) && filter.since >= 0) 69 | ) { 70 | return false; 71 | } 72 | 73 | if ( 74 | filter.until !== undefined && 75 | !(Number.isInteger(filter.until) && filter.until >= 0) 76 | ) { 77 | return false; 78 | } 79 | 80 | if ( 81 | filter.limit !== undefined && 82 | !( 83 | Number.isInteger(filter.limit) && 84 | 0 < filter.limit && 85 | filter.limit <= nip11.limitation.max_limit 86 | ) 87 | ) { 88 | return false; 89 | } 90 | 91 | if ( 92 | !Object.entries(filter) 93 | .filter(([key]) => !nonTagKeys.includes(key)) 94 | .every( 95 | ([key, value]) => 96 | tagsFilterRegExp.test(key) && 97 | Array.isArray(value) && 98 | value.length > 0 && 99 | value.every((v) => typeof v === "string") && 100 | (!hexTagKeys.includes(key) || value.every((v) => hexRegExp.test(v))), 101 | ) 102 | ) { 103 | return false; 104 | } 105 | 106 | // NIP-46 107 | if ( 108 | filter.kinds !== undefined && 109 | filter.kinds.includes(NostrConnect) && 110 | filter["#p"] === undefined 111 | ) { 112 | return false; 113 | } 114 | 115 | return true; 116 | } 117 | 118 | export function broadcastable(filter: Filter, event: Event): boolean { 119 | if (!matchFilter(filter, event)) { 120 | return false; 121 | } 122 | 123 | // NIP-46 124 | if (event.kind === NostrConnect && filter["#p"] === undefined) { 125 | return false; 126 | } 127 | 128 | return true; 129 | } 130 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { Relay } from "./relay"; 3 | import { nip11 } from "./config"; 4 | import { Env } from "./app"; 5 | import { HTTPException } from "hono/http-exception"; 6 | import { nip98 } from "nostr-tools"; 7 | import client from "./client"; 8 | import { Account } from "./Account"; 9 | import { createMiddleware } from "hono/factory"; 10 | 11 | const app = new Hono(); 12 | 13 | app.get("/", async (c) => { 14 | if (c.req.header("Upgrade") === "websocket") { 15 | const stub = c.env.RELAY.getByName("relay"); 16 | return await stub.fetch(c.req.raw); 17 | } else if (c.req.header("Accept") === "application/nostr+json") { 18 | c.header("Access-Control-Allow-Origin", "*"); 19 | return c.json(nip11); 20 | } else { 21 | return client.fetch(c.req.raw); 22 | } 23 | }); 24 | 25 | app.options("/", (c) => { 26 | c.header("Access-Control-Allow-Methods", "GET, OPTIONS"); 27 | return new Response(null, { 28 | status: 204, 29 | }); 30 | }); 31 | 32 | app.get("/metrics", async (c) => { 33 | const stub = c.env.RELAY.getByName("relay"); 34 | const metrics = await stub.metrics(); 35 | return c.json(metrics); 36 | }); 37 | 38 | app.delete("/prune", async (c) => { 39 | const stub = c.env.RELAY.getByName("relay"); 40 | const deleted = await stub.prune(); 41 | return c.json({ deleted }); 42 | }); 43 | 44 | const auth = createMiddleware(async (c, next) => { 45 | const token = c.req.header("Authorization"); 46 | if (token === undefined) { 47 | throw new HTTPException(401); 48 | } 49 | try { 50 | const event = await nip98.unpackEventFromToken(token); 51 | if (!(await nip98.validateEvent(event, c.req.url, c.req.method))) { 52 | throw Error(); 53 | } 54 | c.set("pubkey", event.pubkey); 55 | await next(); 56 | } catch { 57 | throw new HTTPException(401); 58 | } 59 | }); 60 | 61 | app.use("/register", auth); 62 | 63 | app.get("/register", async (c) => { 64 | const pubkey = c.get("pubkey"); 65 | const exists = await new Account(pubkey, c.env).exists(); 66 | return new Response(null, { status: exists ? 204 : 404 }); 67 | }); 68 | 69 | app.put("/register", async (c) => { 70 | const pubkey = c.get("pubkey"); 71 | const account = new Account(pubkey, c.env); 72 | if (await account.exists()) { 73 | return new Response(null, { status: 204 }); 74 | } else { 75 | await account.register(); 76 | return new Response(null, { status: 201 }); 77 | } 78 | }); 79 | 80 | app.delete("/register", async (c) => { 81 | const pubkey = c.get("pubkey"); 82 | await new Account(pubkey, c.env).unregister(); 83 | return new Response(null, { status: 204 }); 84 | }); 85 | 86 | //#region Maintenance 87 | 88 | app.use("/maintenance", auth); 89 | app.use("/maintenance", async (c, next) => { 90 | const pubkey = c.get("pubkey"); 91 | if (pubkey !== nip11.pubkey) { 92 | throw new HTTPException(403); 93 | } 94 | await next(); 95 | }); 96 | 97 | app.put("/maintenance", async (c) => { 98 | const stub = c.env.RELAY.getByName("relay"); 99 | await stub.enableMaintenance(); 100 | return new Response(null, { status: 204 }); 101 | }); 102 | 103 | app.delete("/maintenance", async (c) => { 104 | const stub = c.env.RELAY.getByName("relay"); 105 | await stub.disableMaintenance(); 106 | return new Response(null, { status: 204 }); 107 | }); 108 | 109 | //#endregion 110 | 111 | export default app; 112 | 113 | export { Relay }; 114 | -------------------------------------------------------------------------------- /src/message/handler/event.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter, verifyEvent } from "nostr-tools"; 2 | import { MessageHandler } from "../handler"; 3 | import { Connection } from "../../connection"; 4 | import { nip11 } from "../../config"; 5 | import { EventRepository } from "../../repository/event"; 6 | import { 7 | EventDeletion, 8 | isEphemeralKind, 9 | isAddressableKind, 10 | isReplaceableKind, 11 | } from "nostr-tools/kinds"; 12 | import { sendAuthChallenge } from "../sender/auth"; 13 | import { broadcastable } from "../../nostr"; 14 | 15 | export class EventMessageHandler implements MessageHandler { 16 | #event: Event; 17 | #eventsRepository: EventRepository; 18 | 19 | constructor(event: Event, eventsRepository: EventRepository) { 20 | this.#event = event; 21 | this.#eventsRepository = eventsRepository; 22 | } 23 | 24 | async handle(ctx: DurableObjectState, ws: WebSocket): Promise { 25 | if (!verifyEvent(this.#event)) { 26 | console.debug("[EVENT invalid]", { event: this.#event }); 27 | ws.send(JSON.stringify(["NOTICE", "invalid: event"])); 28 | return; 29 | } 30 | 31 | const connection = ws.deserializeAttachment() as Connection; 32 | const { auth } = connection; 33 | 34 | if (auth === undefined || !connection.pubkeys.has(this.#event.pubkey)) { 35 | const isProtected = this.#event.tags.some(([name]) => name === "-"); 36 | 37 | if ( 38 | nip11.limitation.auth_required || 39 | nip11.limitation.restricted_writes || 40 | isProtected 41 | ) { 42 | const challenge = sendAuthChallenge(ws); 43 | connection.auth = { 44 | challenge, 45 | challengedAt: Date.now(), 46 | }; 47 | ws.serializeAttachment(connection); 48 | const message = isProtected 49 | ? "this event may only be published by its author" 50 | : "we only accept events from registered users"; 51 | ws.send( 52 | JSON.stringify([ 53 | "OK", 54 | this.#event.id, 55 | false, 56 | `auth-required: ${message}`, 57 | ]), 58 | ); 59 | return; 60 | } 61 | } 62 | 63 | if (isReplaceableKind(this.#event.kind)) { 64 | await this.#eventsRepository.saveReplaceableEvent( 65 | this.#event, 66 | connection.ipAddress, 67 | ); 68 | } else if (isAddressableKind(this.#event.kind)) { 69 | if ( 70 | !this.#event.tags.some( 71 | ([name, value]) => name === "d" && typeof value === "string", 72 | ) 73 | ) { 74 | console.debug("[EVENT missing d tag]", { event: this.#event }); 75 | ws.send( 76 | JSON.stringify([ 77 | "OK", 78 | this.#event.id, 79 | false, 80 | "invalid: addressable event requires d tag", 81 | ]), 82 | ); 83 | return; 84 | } 85 | await this.#eventsRepository.saveAddressableEvent( 86 | this.#event, 87 | connection.ipAddress, 88 | ); 89 | } else if (!isEphemeralKind(this.#event.kind)) { 90 | await this.#eventsRepository.save(this.#event, connection.ipAddress); 91 | if (this.#event.kind === EventDeletion) { 92 | await this.#eventsRepository.deleteBy(this.#event); 93 | } 94 | } 95 | 96 | ws.send(JSON.stringify(["OK", this.#event.id, true, ""])); 97 | 98 | await this.#broadcast(ctx); 99 | } 100 | 101 | async #broadcast(ctx: DurableObjectState): Promise { 102 | const subscriptionsMap = await ctx.storage.list>(); 103 | subscriptionsMap.delete("maintenance"); // Exclude non-connections 104 | const availableConnectionIds = new Set(); 105 | for (const ws of ctx.getWebSockets()) { 106 | const { id } = ws.deserializeAttachment() as Connection; 107 | availableConnectionIds.add(id); 108 | const subscriptions = subscriptionsMap.get(id); 109 | if (subscriptions === undefined) { 110 | continue; 111 | } 112 | for (const [id, filters] of subscriptions) { 113 | if (filters.some((filter) => broadcastable(filter, this.#event))) { 114 | ws.send(JSON.stringify(["EVENT", id, this.#event])); 115 | } 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/relay.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from "cloudflare:workers"; 2 | import { Connection } from "./connection"; 3 | import { config, nip11 } from "./config"; 4 | import { sendAuthChallenge } from "./message/sender/auth"; 5 | import { sendClosed } from "./message/sender/closed"; 6 | import { sendNotice } from "./message/sender/notice"; 7 | import { MessageHandlerFactory } from "./message/factory"; 8 | import { Bindings } from "./app"; 9 | import { EventRepository } from "./repository/event"; 10 | import { RepositoryFactory } from "./repository/factory"; 11 | import { Filter } from "nostr-tools/filter"; 12 | 13 | export class Relay extends DurableObject { 14 | #eventsRepository: EventRepository; 15 | 16 | constructor(ctx: DurableObjectState, env: Bindings) { 17 | console.debug("[relay constructor]"); 18 | 19 | super(ctx, env); 20 | 21 | this.#eventsRepository = RepositoryFactory.create( 22 | config.repository_type, 23 | this.env, 24 | ); 25 | } 26 | 27 | async fetch(request: Request): Promise { 28 | console.debug("[relay fetch]"); 29 | 30 | const maintenance = await this.ctx.storage.get("maintenance"); 31 | if (maintenance) { 32 | return new Response(null, { 33 | status: 503, 34 | headers: { "Retry-After": `${3600}` }, // seconds 35 | }); 36 | } 37 | 38 | const webSocketPair = new WebSocketPair(); 39 | const { 0: client, 1: server } = webSocketPair; 40 | this.ctx.acceptWebSocket(server); 41 | 42 | const ipAddress = request.headers.get("CF-Connecting-IP"); 43 | const connectionId = crypto.randomUUID(); 44 | 45 | if (nip11.limitation.auth_required) { 46 | const challenge = sendAuthChallenge(server); 47 | const connection = { 48 | id: connectionId, 49 | ipAddress, 50 | url: this.#convertToWebSocketUrl(request.url), 51 | auth: { 52 | challenge, 53 | challengedAt: Date.now(), 54 | }, 55 | pubkeys: new Set(), 56 | } satisfies Connection; 57 | server.serializeAttachment(connection); 58 | } else { 59 | const connection = { 60 | id: connectionId, 61 | ipAddress, 62 | url: this.#convertToWebSocketUrl(request.url), 63 | pubkeys: new Set(), 64 | } satisfies Connection; 65 | server.serializeAttachment(connection); 66 | } 67 | 68 | return new Response(null, { 69 | status: 101, 70 | webSocket: client, 71 | }); 72 | } 73 | 74 | async metrics(): Promise<{ 75 | connections: number; 76 | subscriptions: number; 77 | filters: number; 78 | }> { 79 | const subscriptionsMap = 80 | await this.ctx.storage.list>(); 81 | const connectionIds = this.ctx 82 | .getWebSockets() 83 | .map((ws) => (ws.deserializeAttachment() as Connection).id); 84 | const subscriptionsList = subscriptionsMap 85 | .entries() 86 | .filter(([connectionId]) => connectionIds.includes(connectionId)) 87 | .map(([, subscriptions]) => subscriptions) 88 | .toArray(); 89 | return { 90 | connections: connectionIds.length, 91 | subscriptions: subscriptionsList 92 | .map((subscriptions) => subscriptions.size) 93 | .reduce((sum, value) => sum + value, 0), 94 | filters: subscriptionsList 95 | .flatMap((subscriptions) => 96 | [...subscriptions].map(([, filters]) => filters.length), 97 | ) 98 | .reduce((sum, value) => sum + value, 0), 99 | }; 100 | } 101 | 102 | async prune(): Promise { 103 | const connections = await this.ctx.storage.list>(); 104 | connections.delete("maintenance"); // Exclude non-connections 105 | const availableConnectionIds = this.ctx 106 | .getWebSockets() 107 | .map((ws) => (ws.deserializeAttachment() as Connection).id); 108 | console.debug("[prune]", connections.size, availableConnectionIds.length); 109 | let deleted = 0; 110 | for (const [id] of connections) { 111 | if (availableConnectionIds.includes(id)) { 112 | continue; 113 | } 114 | await this.ctx.storage.delete(id); 115 | deleted++; 116 | } 117 | return deleted; 118 | } 119 | 120 | #convertToWebSocketUrl(url: string): string { 121 | const u = new URL(url); 122 | u.protocol = u.protocol === "http:" ? "ws:" : "wss:"; 123 | return u.href; 124 | } 125 | 126 | async webSocketMessage( 127 | ws: WebSocket, 128 | message: string | ArrayBuffer, 129 | ): Promise { 130 | if (message instanceof ArrayBuffer) { 131 | return; 132 | } 133 | 134 | const handler = MessageHandlerFactory.create( 135 | message, 136 | this.#eventsRepository, 137 | ); 138 | await handler?.handle(this.ctx, ws, this.env); 139 | } 140 | 141 | async webSocketClose( 142 | ws: WebSocket, 143 | code: number, 144 | reason: string, 145 | wasClean: boolean, 146 | ): Promise { 147 | console.debug("[ws close]", code, reason, wasClean, ws.readyState); 148 | this.#cleanUp(ws); 149 | } 150 | 151 | async #cleanUp(ws: WebSocket): Promise { 152 | const { id } = ws.deserializeAttachment() as Connection; 153 | await this.ctx.storage.delete(id); 154 | } 155 | 156 | webSocketError(ws: WebSocket, error: unknown): void | Promise { 157 | console.error("[ws error]", ws.readyState, error); 158 | } 159 | 160 | //#region Maintenance 161 | 162 | async enableMaintenance(): Promise { 163 | console.debug("[maintenance]", "enable"); 164 | const subscriptionsMap = 165 | await this.ctx.storage.list>(); 166 | await this.ctx.storage.deleteAll(); 167 | await this.ctx.storage.put("maintenance", true); 168 | 169 | for (const ws of this.ctx.getWebSockets()) { 170 | const { id } = ws.deserializeAttachment() as Connection; 171 | const subscriptions = subscriptionsMap.get(id); 172 | if (subscriptions !== undefined) { 173 | for (const [id] of subscriptions) { 174 | sendClosed(ws, id, "error", "closed due to maintenance"); 175 | } 176 | } 177 | sendNotice(ws, "disconnected due to maintenance"); 178 | ws.close(); 179 | } 180 | } 181 | 182 | async disableMaintenance(): Promise { 183 | console.debug("[maintenance]", "disable"); 184 | await this.ctx.storage.delete("maintenance"); 185 | } 186 | 187 | //#endregion 188 | } 189 | -------------------------------------------------------------------------------- /src/nostr.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { broadcastable, validateFilter } from "./nostr"; 3 | import { finalizeEvent, generateSecretKey } from "nostr-tools/pure"; 4 | import { NostrConnect } from "nostr-tools/kinds"; 5 | 6 | describe("validate filter", () => { 7 | it("all", () => { 8 | expect(validateFilter({})).toBe(true); 9 | }); 10 | it("ids", () => { 11 | expect(validateFilter({ ids: "" as any })).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 12 | expect(validateFilter({ ids: [] })).toBe(false); 13 | expect( 14 | validateFilter({ 15 | ids: [ 16 | "0000000000000000000000000000000000000000000000000000000000000000", 17 | ], 18 | }), 19 | ).toBe(true); 20 | expect( 21 | validateFilter({ 22 | ids: [""], 23 | }), 24 | ).toBe(false); 25 | expect( 26 | validateFilter({ 27 | ids: [1 as any], // eslint-disable-line @typescript-eslint/no-explicit-any 28 | }), 29 | ).toBe(false); 30 | }); 31 | it("authors", () => { 32 | expect(validateFilter({ authors: "" as any })).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 33 | expect(validateFilter({ authors: [] })).toBe(false); 34 | expect( 35 | validateFilter({ 36 | authors: [ 37 | "0000000000000000000000000000000000000000000000000000000000000000", 38 | ], 39 | }), 40 | ).toBe(true); 41 | expect( 42 | validateFilter({ 43 | authors: [""], 44 | }), 45 | ).toBe(false); 46 | expect( 47 | validateFilter({ 48 | authors: [1 as any], // eslint-disable-line @typescript-eslint/no-explicit-any 49 | }), 50 | ).toBe(false); 51 | expect( 52 | validateFilter({ 53 | ids: [1 as any], // eslint-disable-line @typescript-eslint/no-explicit-any 54 | }), 55 | ).toBe(false); 56 | }); 57 | it("kinds", () => { 58 | expect(validateFilter({ kinds: 0 as any })).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 59 | expect(validateFilter({ kinds: [] })).toBe(false); 60 | expect( 61 | validateFilter({ 62 | kinds: [0], 63 | }), 64 | ).toBe(true); 65 | expect( 66 | validateFilter({ 67 | kinds: [-1], 68 | }), 69 | ).toBe(false); 70 | expect( 71 | validateFilter({ 72 | kinds: [0, 65536], 73 | }), 74 | ).toBe(false); 75 | expect( 76 | validateFilter({ 77 | kinds: ["" as any], // eslint-disable-line @typescript-eslint/no-explicit-any 78 | }), 79 | ).toBe(false); 80 | }); 81 | it("since", () => { 82 | expect(validateFilter({ since: 0 })).toBe(true); 83 | expect(validateFilter({ since: "" as any })).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 84 | expect(validateFilter({ since: -1 })).toBe(false); 85 | }); 86 | it("until", () => { 87 | expect(validateFilter({ until: 0 })).toBe(true); 88 | expect(validateFilter({ until: "" as any })).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 89 | expect(validateFilter({ until: -1 })).toBe(false); 90 | }); 91 | it("limit", () => { 92 | expect(validateFilter({ limit: 0 })).toBe(false); 93 | expect(validateFilter({ limit: 1 })).toBe(true); 94 | expect(validateFilter({ limit: 500 })).toBe(true); 95 | expect(validateFilter({ limit: 501 })).toBe(false); 96 | expect(validateFilter({ limit: "" as any })).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 97 | }); 98 | it("tags", () => { 99 | expect(validateFilter({ "#t": [] })).toBe(false); 100 | expect(validateFilter({ "#t": ["test"] })).toBe(true); 101 | expect(validateFilter({ "#d": [""] })).toBe(true); 102 | expect(validateFilter({ "#t": [0 as any] })).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 103 | expect(validateFilter({ "#e": ["0"] })).toBe(false); 104 | expect(validateFilter({ "#E": ["0"] })).toBe(false); 105 | expect(validateFilter({ "#p": ["0"] })).toBe(false); 106 | expect(validateFilter({ "#P": ["0"] })).toBe(false); 107 | expect(validateFilter({ "#q": ["0"] })).toBe(false); 108 | expect( 109 | validateFilter({ 110 | "#e": [ 111 | "0000000000000000000000000000000000000000000000000000000000000000", 112 | ], 113 | "#p": [ 114 | "0000000000000000000000000000000000000000000000000000000000000000", 115 | ], 116 | }), 117 | ).toBe(true); 118 | expect( 119 | validateFilter({ 120 | "#e": [ 121 | "0000000000000000000000000000000000000000000000000000000000000000", 122 | ], 123 | "#p": ["0"], 124 | }), 125 | ).toBe(false); 126 | expect(validateFilter({ unknown: ["test"] } as any)).toBe(false); // eslint-disable-line @typescript-eslint/no-explicit-any 127 | }); 128 | it("search", () => { 129 | expect(validateFilter({ search: "test" })).toBe(false); // Unsupported 130 | }); 131 | it("nostr connect", () => { 132 | expect( 133 | validateFilter({ 134 | kinds: [NostrConnect], 135 | "#p": [ 136 | "0000000000000000000000000000000000000000000000000000000000000000", 137 | ], 138 | }), 139 | ).toBe(true); 140 | expect( 141 | validateFilter({ 142 | "#p": [ 143 | "0000000000000000000000000000000000000000000000000000000000000000", 144 | ], 145 | }), 146 | ).toBe(true); 147 | expect( 148 | validateFilter({ 149 | kinds: [NostrConnect], 150 | }), 151 | ).toBe(false); 152 | }); 153 | }); 154 | 155 | describe("broadcastable", () => { 156 | const seckey = generateSecretKey(); 157 | const normalEvent = finalizeEvent( 158 | { kind: 1, content: "", tags: [], created_at: 0 }, 159 | seckey, 160 | ); 161 | const nostrConnectEvent = finalizeEvent( 162 | { 163 | kind: NostrConnect, 164 | content: "", 165 | tags: [ 166 | [ 167 | "p", 168 | "0000000000000000000000000000000000000000000000000000000000000000", 169 | ], 170 | ], 171 | created_at: 0, 172 | }, 173 | seckey, 174 | ); 175 | 176 | it("all", () => { 177 | expect(broadcastable({}, normalEvent)).toBe(true); 178 | expect(broadcastable({}, nostrConnectEvent)).toBe(false); 179 | }); 180 | 181 | it("nostr connect", () => { 182 | expect( 183 | broadcastable( 184 | { 185 | kinds: [NostrConnect], 186 | "#p": [ 187 | "0000000000000000000000000000000000000000000000000000000000000000", 188 | ], 189 | }, 190 | nostrConnectEvent, 191 | ), 192 | ).toBe(true); 193 | expect( 194 | broadcastable( 195 | { 196 | "#p": [ 197 | "0000000000000000000000000000000000000000000000000000000000000000", 198 | ], 199 | }, 200 | nostrConnectEvent, 201 | ), 202 | ).toBe(true); 203 | expect(broadcastable({ kinds: [NostrConnect] }, nostrConnectEvent)).toBe( 204 | false, 205 | ); 206 | expect( 207 | broadcastable( 208 | { 209 | kinds: [NostrConnect], 210 | "#p": [ 211 | "11111111111111111111111111111111111111111111111111111111111111111", 212 | ], 213 | }, 214 | nostrConnectEvent, 215 | ), 216 | ).toBe(false); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /src/repository/kv/d1/event.ts: -------------------------------------------------------------------------------- 1 | import { Event, Filter, sortEvents } from "nostr-tools"; 2 | import { EventRepository } from "../../event"; 3 | import { Bindings } from "../../../app"; 4 | import { config, nip11 } from "../../../config"; 5 | import { 6 | hexRegExp, 7 | hexTagKeys, 8 | idsFilterKeys, 9 | tagsFilterRegExp, 10 | } from "../../../nostr"; 11 | import { EventDeletion } from "nostr-tools/kinds"; 12 | import Cloudflare from "cloudflare"; 13 | 14 | export class KvD1EventRepository implements EventRepository { 15 | #env: Bindings; 16 | 17 | constructor(env: Bindings) { 18 | this.#env = env; 19 | } 20 | 21 | async save(event: Event, ipAddress: string | null): Promise { 22 | console.debug("[save event]", { event }); 23 | await this.#saveToKV(event, ipAddress); 24 | await this.#saveToD1(event); // Execute after KV 25 | } 26 | 27 | async #saveToKV(event: Event, ipAddress: string | null): Promise { 28 | await this.#env.events.put(event.id, JSON.stringify(event), { 29 | metadata: { ipAddress, receivedAt: Date.now() }, 30 | }); 31 | } 32 | 33 | async #saveToD1(event: Event): Promise { 34 | const indexedTags = event.tags.filter( 35 | ([name, value]) => 36 | tagsFilterRegExp.test(`#${name}`) && typeof value === "string", 37 | ); 38 | const indexedTagsMap = indexedTags.reduce((tags, [name, value]) => { 39 | const values = tags.get(name) ?? []; 40 | if (!values.includes(value)) { 41 | values.push(value); 42 | } 43 | return tags.set(name, values); 44 | }, new Map()); 45 | const result = await this.#env.DB.prepare( 46 | "INSERT INTO events (id, pubkey, kind, tags, created_at) VALUES (UNHEX(?1), UNHEX(?2), ?3, json(?4), ?5)", 47 | ) 48 | .bind( 49 | event.id, 50 | event.pubkey, 51 | event.kind, 52 | indexedTagsMap.size > 0 53 | ? JSON.stringify(Object.fromEntries(indexedTagsMap)) 54 | : null, 55 | event.created_at, 56 | ) 57 | .run(); 58 | 59 | console.debug("[save result]", { result }); 60 | } 61 | 62 | async saveReplaceableEvent( 63 | event: Event, 64 | ipAddress: string | null, 65 | ): Promise { 66 | const { results } = await this.#env.DB.prepare( 67 | "SELECT LOWER(HEX(id)) as id, created_at FROM events WHERE kind = ? AND pubkey = UNHEX(?) ORDER BY created_at DESC", 68 | ) 69 | .bind(event.kind, event.pubkey) 70 | .run<{ id: string; created_at: number }>(); 71 | 72 | console.debug("[existing replaceable event]", { results }); 73 | 74 | await this.#saveLatestEvent(event, results, ipAddress); 75 | } 76 | 77 | async saveAddressableEvent( 78 | event: Event, 79 | ipAddress: string | null, 80 | ): Promise { 81 | const identifier = event.tags.find(([name]) => name === "d")?.at(1) ?? ""; 82 | const { results } = await this.#env.DB.prepare( 83 | ` 84 | SELECT LOWER(HEX(id)) as id, created_at FROM events 85 | WHERE kind = ? AND pubkey = UNHEX(?) AND EXISTS(SELECT 1 FROM json_each(json_extract(tags, '$.d')) WHERE json_each.value = ?) 86 | ORDER BY created_at DESC 87 | `, 88 | ) 89 | .bind(event.kind, event.pubkey, identifier) 90 | .run<{ id: string; created_at: number }>(); 91 | 92 | console.debug("[existing addressable event]", { results }); 93 | 94 | await this.#saveLatestEvent(event, results, ipAddress); 95 | } 96 | 97 | async #saveLatestEvent( 98 | event: Event, 99 | results: { id: string; created_at: number }[], 100 | ipAddress: string | null, 101 | ): Promise { 102 | if (results.length === 0) { 103 | await this.save(event, ipAddress); 104 | return; 105 | } 106 | 107 | const latest = results[0]; 108 | if ( 109 | latest.created_at > event.created_at || 110 | (latest.created_at === event.created_at && 111 | event.id.localeCompare(latest.id) >= 0) 112 | ) { 113 | return; 114 | } 115 | 116 | await this.#delete(results.map(({ id }) => id)); 117 | await this.save(event, ipAddress); 118 | } 119 | 120 | /** 121 | * If it is inserted between select and delete, it may not be possible to delete it, 122 | * but this is a rare case and considering the D1 cost (to use covering index), it is allowed at the moment. 123 | * If this is not acceptable, process everything in a batch. 124 | */ 125 | async deleteBy(event: Event): Promise { 126 | const ids = event.tags 127 | .filter(([name, value]) => name === "e" && hexRegExp.test(value)) 128 | .map(([, id]) => id); 129 | const uniqueIds = [...new Set(ids)]; 130 | 131 | if (uniqueIds.length === 0) { 132 | return; 133 | } 134 | 135 | const { results } = await this.#env.DB.prepare( 136 | `SELECT LOWER(HEX(id)) as id, LOWER(HEX(pubkey)) as pubkey, kind FROM events WHERE id IN (${uniqueIds.map((id) => `UNHEX("${id}")`).join(",")})`, 137 | ).run<{ id: string; pubkey: string; kind: number }>(); 138 | const deleteIds = results 139 | .filter( 140 | ({ pubkey, kind }) => pubkey === event.pubkey && kind !== EventDeletion, 141 | ) 142 | .map(({ id }) => id); 143 | await this.#delete(deleteIds); 144 | } 145 | 146 | async #delete(ids: string[]): Promise { 147 | if (ids.length === 0) { 148 | return; 149 | } 150 | 151 | const result = await this.#env.DB.prepare( 152 | `DELETE FROM events WHERE id IN (${ids.map((id) => `UNHEX("${id}")`).join(",")})`, 153 | ).run(); 154 | console.debug("[delete result]", { result }); 155 | 156 | for (const id of ids) { 157 | await this.#env.events.delete(id); 158 | } 159 | } 160 | 161 | async find(filter: Filter): Promise { 162 | if ( 163 | Object.keys(filter).every((key) => idsFilterKeys.includes(key)) && 164 | Array.isArray(filter.ids) && 165 | filter.ids.length <= nip11.limitation.max_limit 166 | ) { 167 | return this.#findByIds(filter.ids); 168 | } else { 169 | return this.#findByQuery(filter); 170 | } 171 | } 172 | 173 | async #findByIds(ids: string[]): Promise { 174 | // Workaround: The local environment runs on Miniflare but cannot be accessed via the REST API. 175 | if (this.#env.LOCAL === "true") { 176 | const events = await Promise.all( 177 | ids.map(async (id) => { 178 | try { 179 | const json = await this.#env.events.get(id); 180 | if (json === null) { 181 | return null; 182 | } 183 | return JSON.parse(json) as Event; 184 | } catch (error) { 185 | console.error("[json parse failed]", error, `(${ids.length})`); 186 | return null; 187 | } 188 | }), 189 | ); 190 | return sortEvents(events.filter((event) => event !== null)); 191 | } else { 192 | const chunk = (array: T[], size: number): T[][] => 193 | Array.from({ length: Math.ceil(array.length / size) }, (_, i) => 194 | array.slice(i * size, i * size + size), 195 | ); 196 | 197 | const client = new Cloudflare({ apiToken: this.#env.API_TOKEN }); 198 | const events = await Promise.all( 199 | chunk(ids, 100).map(async (ids: string[]): Promise => { 200 | const response = await client.kv.namespaces.keys.bulkGet( 201 | this.#env.KV_ID_EVENTS, 202 | { 203 | account_id: this.#env.ACCOUNT_ID, 204 | keys: ids.slice(0, 100), 205 | type: "json", 206 | }, 207 | ); 208 | return Object.values(response?.values ?? {}).filter( 209 | (v) => v !== null, 210 | ); 211 | }), 212 | ); 213 | return sortEvents(events.flat()); 214 | } 215 | } 216 | 217 | async #findByQuery(filter: Filter): Promise { 218 | const wheres: string[] = []; 219 | const params: string[] = []; 220 | 221 | if (filter.ids !== undefined) { 222 | wheres.push( 223 | `id IN (${filter.ids.map((id) => `UNHEX("${id}")`).join(",")})`, 224 | ); 225 | } 226 | 227 | if (filter.authors !== undefined) { 228 | wheres.push( 229 | `pubkey IN (${filter.authors.map((pubkey) => `UNHEX("${pubkey}")`).join(",")})`, 230 | ); 231 | } 232 | 233 | if (filter.kinds !== undefined) { 234 | wheres.push(`kind IN (${filter.kinds.join(",")})`); 235 | } 236 | 237 | if (filter.since !== undefined) { 238 | wheres.push(`created_at >= ${filter.since}`); 239 | } 240 | 241 | if (filter.until !== undefined) { 242 | wheres.push(`created_at <= ${filter.until}`); 243 | } 244 | 245 | const tagsFilter = Object.entries(filter).filter(([key]) => 246 | key.startsWith("#"), 247 | ); 248 | if (tagsFilter.length > 0) { 249 | for (const [key, values] of tagsFilter) { 250 | // For type inference 251 | if ( 252 | !Array.isArray(values) || 253 | !values.every((v) => typeof v === "string") 254 | ) { 255 | console.error("[logic error]", { filter }); 256 | continue; 257 | } 258 | 259 | const uniqueValues = [...new Set(values)]; 260 | if (hexTagKeys.includes(key)) { 261 | wheres.push( 262 | `EXISTS(SELECT 1 FROM json_each(json_extract(tags, '$.${key[1]}')) WHERE json_each.value IN (${uniqueValues.map((v) => `"${v}"`).join(",")}))`, 263 | ); 264 | } else { 265 | wheres.push( 266 | `EXISTS(SELECT 1 FROM json_each(json_extract(tags, '$.${key[1]}')) WHERE json_each.value IN (${uniqueValues.map(() => "?").join(",")}))`, 267 | ); 268 | params.push(...uniqueValues); 269 | } 270 | } 271 | } 272 | 273 | // D1 limit 274 | if (params.length > 100) { 275 | console.error("[too many bound parameters]", params.length, { filter }); 276 | } 277 | 278 | const select = "SELECT LOWER(HEX(id)) as id FROM events"; 279 | const orderBy = "ORDER BY created_at DESC"; 280 | const limit = `LIMIT ${filter.limit ?? config.default_limit}`; 281 | 282 | const query = ( 283 | wheres.length > 0 284 | ? [select, "WHERE", wheres.join(" AND "), orderBy, limit] 285 | : [select, orderBy, limit] 286 | ).join(" "); 287 | 288 | const result = await this.#env.DB.prepare(query) 289 | .bind(...params) 290 | .run<{ id: string }>(); 291 | if (result.meta.rows_read >= 10000) { 292 | console.debug("[find result]", { 293 | ...result, 294 | results: result.results.length, 295 | filter, 296 | query, 297 | params, 298 | }); 299 | } 300 | 301 | return this.#findByIds(result.results.map(({ id }) => id)); 302 | } 303 | } 304 | --------------------------------------------------------------------------------