tinyrelayfile
tinyrelay/tinyclient/tiny.js/

tinyclient/tiny.js raw
1// Shared browser helpers. This file is loaded before every feature module and
2// the signer bridge. It creates tiny.util for byte, URL, encoding and wiki
3// name operations, tiny.signing for verified event signing and publication,
4// and tiny.require for loading page-specific bundles once.
5(() => {
6 "use strict";
7 const tiny = globalThis.tiny || {};
8 const bytes = value => {
9 if (value instanceof Uint8Array) return value;
10 if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
11 if (value instanceof ArrayBuffer) return new Uint8Array(value);
12 throw new TypeError("expected bytes");
13 };
14 const hex = value => Array.from(bytes(value), byte => byte.toString(16).padStart(2, "0")).join("");
15 const sha256 = async value => new Uint8Array(await crypto.subtle.digest("SHA-256", bytes(value)));
16 // fromHex parses a 64-character lowercase hex hash or key.
17 const fromHex = (value, message = "expected a 64-character lowercase hex value") => {
18 if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) throw Error(message);
19 return Uint8Array.from(value.match(/../g), byte => parseInt(byte, 16));
20 };
21 const element = (tag, text) => {
22 const node = document.createElement(tag);
23 if (text !== undefined) node.textContent = text;
24 return node;
25 };
26 // relayURL normalizes a ws or wss URL without credentials or a fragment, or
27 // returns null. Strict mode also rejects query strings, which operator-entered
28 // relay lists never need.
29 const relayURL = (value, {strict = false} = {}) => {
30 if (typeof value !== "string" || value.length > 2048) return null;
31 let url;
32 try {
33 url = new URL(value.trim());
34 } catch {
35 return null;
36 }
37 if (
38 (url.protocol !== "wss:" && url.protocol !== "ws:") ||
39 !url.hostname ||
40 url.username ||
41 url.password ||
42 url.hash
43 )
44 return null;
45 if (strict && url.search) return null;
46 url.hostname = url.hostname.toLowerCase();
47 if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, "");
48 return url.toString().replace(/\/$/, "");
49 };
50 const b64url = value => {
51 let binary = "";
52 bytes(value).forEach(byte => {
53 binary += String.fromCharCode(byte);
54 });
55 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
56 };
57 const fromB64url = value => {
58 const text = value.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((value.length + 3) % 4);
59 return Uint8Array.from(atob(text), char => char.charCodeAt(0));
60 };
61 // bech32Encode renders bytes as a NIP-19 bare string such as an nsec or
62 // npub. The browser bundle decodes these but has no plain encoder.
63 const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
64 const bech32Polymod = values => {
65 const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
66 let checksum = 1;
67 for (const value of values) {
68 const top = checksum >>> 25;
69 checksum = (((checksum & 0x1ffffff) << 5) ^ value) >>> 0;
70 generators.forEach((generator, index) => { if ((top >>> index) & 1) checksum = (checksum ^ generator) >>> 0; });
71 }
72 return checksum;
73 };
74 const bech32Encode = (prefix, value) => {
75 const words = [];
76 let accumulator = 0, bits = 0;
77 for (const byte of bytes(value)) {
78 accumulator = ((accumulator << 8) | byte) >>> 0;
79 bits += 8;
80 while (bits >= 5) {
81 bits -= 5;
82 words.push((accumulator >>> bits) & 31);
83 }
84 }
85 if (bits > 0) words.push((accumulator << (5 - bits)) & 31);
86 const expanded = [...prefix].map(char => char.charCodeAt(0) >>> 5).concat(0, [...prefix].map(char => char.charCodeAt(0) & 31));
87 const polymod = bech32Polymod(expanded.concat(words, [0, 0, 0, 0, 0, 0])) ^ 1;
88 const checksum = Array.from({length: 6}, (_, index) => (polymod >>> (5 * (5 - index))) & 31);
89 return prefix + "1" + words.concat(checksum).map(word => bech32Charset[word]).join("");
90 };
91 // wikiName turns a title into a NIP-54 page name the way the relay does:
92 // lowercase, whitespace as hyphens, letters, digits and marks kept,
93 // everything else dropped, hyphens collapsed and trimmed at both ends.
94 const wikiName = title => {
95 let out = "";
96 for (const ch of String(title ?? "").toLowerCase()) {
97 if (/[\s\-_]/u.test(ch)) {
98 if (out && !out.endsWith("-")) out += "-";
99 } else if (/[\p{L}\p{Nd}\p{M}]/u.test(ch)) {
100 out += ch;
101 }
102 }
103 return out.replace(/^-+|-+$/g, "");
104 };
105 tiny.util = Object.freeze({...tiny.util, bytes, hex, sha256, fromHex, element, relayURL, b64url, fromB64url, bech32Encode, wikiName});
106 // signer returns the active signer: a resumed remote signer first, then a
107 // NIP-07 extension.
108 tiny.signer = () => globalThis.tinySigner || globalThis.nostr;
109 const nip44 = (method, pubkey, text, signer = tiny.signer()) => {
110 if (typeof signer?.["nip44" + method] === "function") return signer["nip44" + method](pubkey, text);
111 const inner = signer?.nip44?.[method.toLowerCase()];
112 if (typeof inner === "function") return inner.call(signer.nip44, pubkey, text);
113 throw Error("The connected signer does not support NIP-44.");
114 };
115 tiny.nip44Encrypt = (pubkey, text, signer) => nip44("Encrypt", pubkey, text, signer);
116 tiny.nip44Decrypt = (pubkey, text, signer) => nip44("Decrypt", pubkey, text, signer);
117 // Event publication is one browser boundary shared by forms, rooms and
118 // WebMCP. Signers are treated as untrusted extensions: the returned event
119 // must preserve the requested unsigned fields and pass the verifier before
120 // it is sent to the relay.
121 // Signers may reconstruct the event object while crossing an extension or
122 // NIP-46 boundary. Compare the signed event's unsigned fields by value,
123 // rather than by JSON property insertion order. Array order remains part of
124 // the event and changed values are still rejected.
125 const sameValue = (left, right) => {
126 if (left === right) return true;
127 if (typeof left !== typeof right || left === null || right === null) return false;
128 if (Array.isArray(left) || Array.isArray(right)) {
129 return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
130 }
131 if (typeof left === "object") {
132 const leftKeys = Object.keys(left), rightKeys = Object.keys(right);
133 return leftKeys.length === rightKeys.length && leftKeys.every(key => Object.prototype.hasOwnProperty.call(right, key) && sameValue(left[key], right[key]));
134 }
135 return false;
136 };
137 const signEvent = async (unsigned, {signal} = {}) => {
138 if (!globalThis.nostr?.signEvent) throw Error("Connect a signer first.");
139 if (typeof globalThis.NostrSigner?.verifyEvent !== "function") throw Error("The signer verifier is still loading. Try again.");
140 const requested = JSON.parse(JSON.stringify(unsigned));
141 const event = await globalThis.nostr.signEvent(JSON.parse(JSON.stringify(requested)));
142 const actual = event && {kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content};
143 if (actual && Object.prototype.hasOwnProperty.call(requested, "pubkey")) actual.pubkey = event.pubkey;
144 if (!sameValue(actual, requested) || globalThis.NostrSigner.verifyEvent(event) !== true) throw Error("The signer returned an invalid or changed event.");
145 if (signal?.aborted) throw Error("Sending canceled.");
146 return event;
147 };
148 const publishEvent = async (unsigned, options = {}) => {
149 const event = await signEvent(unsigned, options);
150 if (typeof tiny.signedFetch !== "function") throw Error("The signed request bridge is still loading. Try again.");
151 const response = await tiny.signedFetch(options.path || "/events", "POST", JSON.stringify(event), {contentType: "application/json", signal: options.signal});
152 let result = {};
153 try { result = await response.json(); } catch {}
154 if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");
155 return event;
156 };
157 tiny.signing = Object.freeze({signEvent, publish: publishEvent});
158 // Feature bundles load on the pages that use them and again after an
159 // in-place navigation lands on such a page. Each script loads once.
160 tiny.bundles = {
161 files: ["file-catalog.js", "blossom-encryption.js", "blossom-manifests.js", "blossom-upload.js", "file-messages.js", "file-workspace.js"],
162 private: ["private-services.js"]
163 };
164 const loaded = new Map();
165 const loadScript = name => {
166 if (!loaded.has(name)) {
167 const version = document.documentElement.dataset.scripts || "";
168 const present = [...(document.scripts || [])].some(script => (script.src || "").includes("/scripts/" + name));
169 loaded.set(name, present ? Promise.resolve() : new Promise((resolve, reject) => {
170 const script = document.createElement("script");
171 script.src = (tiny.localPath ? tiny.localPath("/scripts/" + name) : "/scripts/" + name) + (version ? "?v=" + version : "");
172 script.onload = () => resolve();
173 script.onerror = () => { loaded.delete(name); reject(Error("Could not load " + name)); };
174 document.head.append(script);
175 }));
176 }
177 return loaded.get(name);
178 };
179 tiny.require = async bundle => {
180 for (const name of tiny.bundles[bundle] || [bundle]) await loadScript(name);
181 };
182 // Feature modules add to these groups; guards can read them before load.
183 tiny.blossom = tiny.blossom || {};
184 tiny.files = tiny.files || {};
185 globalThis.tiny = tiny;
186})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs