@nostrify/nostrify 0.46.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/.turbo/turbo-build.log +5 -0
  2. package/BunkerURI.test.ts +28 -0
  3. package/BunkerURI.ts +58 -0
  4. package/CHANGELOG.md +562 -0
  5. package/LICENSE +21 -0
  6. package/NBrowserSigner.test.ts +170 -0
  7. package/NBrowserSigner.ts +100 -0
  8. package/NCache.bench.ts +81 -0
  9. package/NCache.test.ts +22 -0
  10. package/NCache.ts +73 -0
  11. package/NConnectSigner.test.ts +102 -0
  12. package/NConnectSigner.ts +189 -0
  13. package/NIP05.test.ts +67 -0
  14. package/NIP05.ts +52 -0
  15. package/NIP50.test.ts +58 -0
  16. package/NIP50.ts +24 -0
  17. package/NIP98.test.ts +181 -0
  18. package/NIP98.ts +97 -0
  19. package/NKinds.test.ts +42 -0
  20. package/NKinds.ts +26 -0
  21. package/NPool.test.ts +117 -0
  22. package/NPool.ts +224 -0
  23. package/NRelay1.test.ts +174 -0
  24. package/NRelay1.ts +440 -0
  25. package/NSchema.test.ts +94 -0
  26. package/NSchema.ts +255 -0
  27. package/NSecSigner.bench.ts +55 -0
  28. package/NSecSigner.test.ts +26 -0
  29. package/NSecSigner.ts +60 -0
  30. package/NSet.bench.ts +10 -0
  31. package/NSet.test.ts +92 -0
  32. package/NSet.ts +203 -0
  33. package/README.md +314 -0
  34. package/RelayError.test.ts +23 -0
  35. package/RelayError.ts +22 -0
  36. package/dist/BunkerURI.d.ts +23 -0
  37. package/dist/BunkerURI.d.ts.map +1 -0
  38. package/dist/BunkerURI.js +52 -0
  39. package/dist/BunkerURI.js.map +1 -0
  40. package/dist/NBrowserSigner.d.ts +27 -0
  41. package/dist/NBrowserSigner.d.ts.map +1 -0
  42. package/dist/NBrowserSigner.js +96 -0
  43. package/dist/NBrowserSigner.js.map +1 -0
  44. package/dist/NCache.d.ts +34 -0
  45. package/dist/NCache.d.ts.map +1 -0
  46. package/dist/NCache.js +63 -0
  47. package/dist/NCache.js.map +1 -0
  48. package/dist/NConnectSigner.d.ts +50 -0
  49. package/dist/NConnectSigner.d.ts.map +1 -0
  50. package/dist/NConnectSigner.js +130 -0
  51. package/dist/NConnectSigner.js.map +1 -0
  52. package/dist/NIP05.d.ts +15 -0
  53. package/dist/NIP05.d.ts.map +1 -0
  54. package/dist/NIP05.js +40 -0
  55. package/dist/NIP05.js.map +1 -0
  56. package/dist/NIP50.d.ts +10 -0
  57. package/dist/NIP50.d.ts.map +1 -0
  58. package/dist/NIP50.js +26 -0
  59. package/dist/NIP50.js.map +1 -0
  60. package/dist/NIP98.d.ts +15 -0
  61. package/dist/NIP98.d.ts.map +1 -0
  62. package/dist/NIP98.js +71 -0
  63. package/dist/NIP98.js.map +1 -0
  64. package/dist/NKinds.d.ts +13 -0
  65. package/dist/NKinds.d.ts.map +1 -0
  66. package/dist/NKinds.js +27 -0
  67. package/dist/NKinds.js.map +1 -0
  68. package/dist/NPool.d.ts +91 -0
  69. package/dist/NPool.d.ts.map +1 -0
  70. package/dist/NPool.js +185 -0
  71. package/dist/NPool.js.map +1 -0
  72. package/dist/NRelay1.d.ts +80 -0
  73. package/dist/NRelay1.d.ts.map +1 -0
  74. package/dist/NRelay1.js +336 -0
  75. package/dist/NRelay1.js.map +1 -0
  76. package/dist/NSchema.d.ts +73 -0
  77. package/dist/NSchema.d.ts.map +1 -0
  78. package/dist/NSchema.js +215 -0
  79. package/dist/NSchema.js.map +1 -0
  80. package/dist/NSecSigner.d.ts +29 -0
  81. package/dist/NSecSigner.d.ts.map +1 -0
  82. package/dist/NSecSigner.js +52 -0
  83. package/dist/NSecSigner.js.map +1 -0
  84. package/dist/NSet.d.ts +76 -0
  85. package/dist/NSet.d.ts.map +1 -0
  86. package/dist/NSet.js +174 -0
  87. package/dist/NSet.js.map +1 -0
  88. package/dist/RelayError.d.ts +10 -0
  89. package/dist/RelayError.d.ts.map +1 -0
  90. package/dist/RelayError.js +23 -0
  91. package/dist/RelayError.js.map +1 -0
  92. package/dist/ln/LNURL.d.ts +55 -0
  93. package/dist/ln/LNURL.d.ts.map +1 -0
  94. package/dist/ln/LNURL.js +105 -0
  95. package/dist/ln/LNURL.js.map +1 -0
  96. package/dist/ln/mod.d.ts +4 -0
  97. package/dist/ln/mod.d.ts.map +1 -0
  98. package/dist/ln/mod.js +6 -0
  99. package/dist/ln/mod.js.map +1 -0
  100. package/dist/ln/types/LNURLCallback.d.ts +8 -0
  101. package/dist/ln/types/LNURLCallback.d.ts.map +1 -0
  102. package/dist/ln/types/LNURLCallback.js +3 -0
  103. package/dist/ln/types/LNURLCallback.js.map +1 -0
  104. package/dist/ln/types/LNURLDetails.d.ts +20 -0
  105. package/dist/ln/types/LNURLDetails.d.ts.map +1 -0
  106. package/dist/ln/types/LNURLDetails.js +3 -0
  107. package/dist/ln/types/LNURLDetails.js.map +1 -0
  108. package/dist/mod.d.ts +16 -0
  109. package/dist/mod.d.ts.map +1 -0
  110. package/dist/mod.js +32 -0
  111. package/dist/mod.js.map +1 -0
  112. package/dist/test/ErrorRelay.d.ts +21 -0
  113. package/dist/test/ErrorRelay.d.ts.map +1 -0
  114. package/dist/test/ErrorRelay.js +26 -0
  115. package/dist/test/ErrorRelay.js.map +1 -0
  116. package/dist/test/MockRelay.d.ts +20 -0
  117. package/dist/test/MockRelay.d.ts.map +1 -0
  118. package/dist/test/MockRelay.js +66 -0
  119. package/dist/test/MockRelay.js.map +1 -0
  120. package/dist/test/TestRelayServer.d.ts +25 -0
  121. package/dist/test/TestRelayServer.d.ts.map +1 -0
  122. package/dist/test/TestRelayServer.js +134 -0
  123. package/dist/test/TestRelayServer.js.map +1 -0
  124. package/dist/test/mod.d.ts +8 -0
  125. package/dist/test/mod.d.ts.map +1 -0
  126. package/dist/test/mod.js +28 -0
  127. package/dist/test/mod.js.map +1 -0
  128. package/dist/tsconfig.tsbuildinfo +1 -0
  129. package/dist/uploaders/BlossomUploader.d.ts +26 -0
  130. package/dist/uploaders/BlossomUploader.d.ts.map +1 -0
  131. package/dist/uploaders/BlossomUploader.js +71 -0
  132. package/dist/uploaders/BlossomUploader.js.map +1 -0
  133. package/dist/uploaders/NostrBuildUploader.d.ts +24 -0
  134. package/dist/uploaders/NostrBuildUploader.d.ts.map +1 -0
  135. package/dist/uploaders/NostrBuildUploader.js +67 -0
  136. package/dist/uploaders/NostrBuildUploader.js.map +1 -0
  137. package/dist/uploaders/mod.d.ts +3 -0
  138. package/dist/uploaders/mod.d.ts.map +1 -0
  139. package/dist/uploaders/mod.js +8 -0
  140. package/dist/uploaders/mod.js.map +1 -0
  141. package/dist/utils/CircularSet.d.ts +13 -0
  142. package/dist/utils/CircularSet.d.ts.map +1 -0
  143. package/dist/utils/CircularSet.js +35 -0
  144. package/dist/utils/CircularSet.js.map +1 -0
  145. package/dist/utils/Machina.d.ts +36 -0
  146. package/dist/utils/Machina.d.ts.map +1 -0
  147. package/dist/utils/Machina.js +66 -0
  148. package/dist/utils/Machina.js.map +1 -0
  149. package/dist/utils/N64.d.ts +9 -0
  150. package/dist/utils/N64.d.ts.map +1 -0
  151. package/dist/utils/N64.js +23 -0
  152. package/dist/utils/N64.js.map +1 -0
  153. package/dist/utils/mod.d.ts +3 -0
  154. package/dist/utils/mod.d.ts.map +1 -0
  155. package/dist/utils/mod.js +8 -0
  156. package/dist/utils/mod.js.map +1 -0
  157. package/ln/LNURL.test.ts +87 -0
  158. package/ln/LNURL.ts +146 -0
  159. package/ln/mod.ts +4 -0
  160. package/ln/types/LNURLCallback.ts +7 -0
  161. package/ln/types/LNURLDetails.ts +19 -0
  162. package/mod.ts +16 -0
  163. package/package.json +23 -0
  164. package/test/ErrorRelay.test.ts +19 -0
  165. package/test/ErrorRelay.ts +40 -0
  166. package/test/MockRelay.test.ts +20 -0
  167. package/test/MockRelay.ts +92 -0
  168. package/test/TestRelayServer.ts +156 -0
  169. package/test/mod.ts +28 -0
  170. package/tsconfig.json +14 -0
  171. package/uploaders/BlossomUploader.test.ts +26 -0
  172. package/uploaders/BlossomUploader.ts +98 -0
  173. package/uploaders/NostrBuildUploader.test.ts +22 -0
  174. package/uploaders/NostrBuildUploader.ts +89 -0
  175. package/uploaders/mod.ts +2 -0
  176. package/utils/CircularSet.test.ts +15 -0
  177. package/utils/CircularSet.ts +34 -0
  178. package/utils/Machina.test.ts +91 -0
  179. package/utils/Machina.ts +66 -0
  180. package/utils/N64.test.ts +27 -0
  181. package/utils/N64.ts +23 -0
  182. package/utils/mod.ts +2 -0
@@ -0,0 +1,92 @@
1
+ // deno-lint-ignore-file require-await
2
+
3
+ import {
4
+ NostrEvent,
5
+ NostrFilter,
6
+ NostrRelayCLOSED,
7
+ NostrRelayCOUNT,
8
+ NostrRelayEOSE,
9
+ NostrRelayEVENT,
10
+ NRelay,
11
+ } from '@nostrify/types';
12
+ import { matchFilters } from 'nostr-tools';
13
+
14
+ import { Machina } from '../utils/Machina';
15
+ import { NSet } from '../NSet';
16
+
17
+ /** Mock relay for testing. */
18
+ export class MockRelay extends NSet implements NRelay {
19
+ readonly subs: Map<
20
+ string,
21
+ { filters: NostrFilter[]; machina: Machina<NostrEvent> }
22
+ > = new Map();
23
+
24
+ async *req(
25
+ filters: NostrFilter[],
26
+ opts?: { signal?: AbortSignal },
27
+ ): AsyncIterable<NostrRelayEVENT | NostrRelayEOSE | NostrRelayCLOSED> {
28
+ const uuid = crypto.randomUUID();
29
+ const machina = new Machina<NostrEvent>(opts?.signal);
30
+
31
+ this.subs.set(uuid, { filters, machina });
32
+
33
+ try {
34
+ for (const event of await this.query(filters)) {
35
+ yield ['EVENT', uuid, event];
36
+ }
37
+
38
+ yield ['EOSE', uuid];
39
+
40
+ for await (const event of machina) {
41
+ yield ['EVENT', uuid, event];
42
+ }
43
+ } finally {
44
+ this.subs.delete(uuid);
45
+ }
46
+ }
47
+
48
+ async event(event: NostrEvent): Promise<void> {
49
+ this.add(event);
50
+
51
+ for (const { filters, machina } of this.subs.values()) {
52
+ if (matchFilters(filters, event)) {
53
+ machina.push(event);
54
+ }
55
+ }
56
+ }
57
+
58
+ async query(filters: NostrFilter[]): Promise<NostrEvent[]> {
59
+ const events: NostrEvent[] = [];
60
+
61
+ for (const event of this) {
62
+ if (matchFilters(filters, event)) {
63
+ this.cache.get(event.id);
64
+ events.push(event);
65
+ }
66
+ }
67
+
68
+ return events;
69
+ }
70
+
71
+ async remove(filters: NostrFilter[]): Promise<void> {
72
+ for (const event of this) {
73
+ if (matchFilters(filters, event)) {
74
+ this.delete(event);
75
+ }
76
+ }
77
+ }
78
+
79
+ async count(filters: NostrFilter[]): Promise<NostrRelayCOUNT[2]> {
80
+ const events = await this.query(filters);
81
+ return {
82
+ count: events.length,
83
+ approximate: false,
84
+ };
85
+ }
86
+
87
+ close(): Promise<void> {
88
+ return Promise.resolve();
89
+ }
90
+
91
+ override [Symbol.toStringTag] = 'MockRelay';
92
+ }
@@ -0,0 +1,156 @@
1
+ import { MockRelay } from './mod';
2
+ import { NostrClientMsg, NostrEvent, NostrRelayMsg } from '@nostrify/types';
3
+ import { NSchema as n } from '../NSchema';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import { createServer, Server } from 'http';
6
+ import { AddressInfo } from 'net';
7
+
8
+ interface TestRelayServerOpts {
9
+ handleMessage?(socket: WebSocket, msg: NostrClientMsg): Promise<void> | void;
10
+ }
11
+
12
+ export class TestRelayServer {
13
+ private port = 0;
14
+ private httpServer: Server;
15
+ private wsServer: WebSocketServer;
16
+ private connections = new Set<WebSocket>();
17
+ private controllers = new Map<string, AbortController>();
18
+ private store = new MockRelay();
19
+
20
+ constructor(private opts?: TestRelayServerOpts) {
21
+ this.httpServer = createServer();
22
+ this.wsServer = new WebSocketServer({ server: this.httpServer });
23
+ this.setupWebSocketServer();
24
+ this.httpServer.listen(0, '127.0.0.1', () => {
25
+ this.port = (this.httpServer.address() as AddressInfo).port;
26
+ });
27
+ }
28
+
29
+ private setupWebSocketServer(): void {
30
+ this.wsServer.on('connection', (socket: WebSocket) => {
31
+ this.connections.add(socket);
32
+
33
+ socket.on('close', () => {
34
+ this.connections.delete(socket);
35
+ // Clean up any subscriptions for this socket
36
+ for (const [subId, controller] of this.controllers.entries()) {
37
+ controller.abort();
38
+ this.controllers.delete(subId);
39
+ }
40
+ });
41
+
42
+ socket.on('message', (data: Buffer) => {
43
+ try {
44
+ const result = n.json().pipe(n.clientMsg()).safeParse(data.toString());
45
+ if (result.success) {
46
+ const handleMessage = this.opts?.handleMessage ??
47
+ this.handleMessage.bind(this);
48
+ handleMessage(socket, result.data);
49
+ }
50
+ } catch (error) {
51
+ // Handle parsing errors silently
52
+ }
53
+ });
54
+
55
+ socket.on('error', (error) => {
56
+ console.error('WebSocket error:', error);
57
+ this.connections.delete(socket);
58
+ });
59
+ });
60
+ }
61
+
62
+ send(socket: WebSocket, msg: NostrRelayMsg): void {
63
+ if (socket.readyState === WebSocket.OPEN) {
64
+ socket.send(JSON.stringify(msg));
65
+ }
66
+ }
67
+
68
+ private async handleMessage(
69
+ socket: WebSocket,
70
+ msg: NostrClientMsg,
71
+ ): Promise<void> {
72
+ switch (msg[0]) {
73
+ case 'REQ': {
74
+ const [_, subId, ...filters] = msg;
75
+
76
+ const controller = new AbortController();
77
+ this.controllers.set(subId, controller);
78
+
79
+ try {
80
+ for await (
81
+ const msg of this.store.req(filters, { signal: controller.signal })
82
+ ) {
83
+ msg[1] = subId;
84
+ this.send(socket, msg);
85
+ }
86
+ } catch {
87
+ // do nothing
88
+ }
89
+
90
+ break;
91
+ }
92
+
93
+ case 'CLOSE': {
94
+ const subId = msg[1];
95
+ this.controllers.get(subId)?.abort();
96
+ this.controllers.delete(subId);
97
+ break;
98
+ }
99
+
100
+ case 'EVENT': {
101
+ const [_, event] = msg;
102
+ await this.store.event(event);
103
+ this.send(socket, ['OK', event.id, true, '']);
104
+ break;
105
+ }
106
+ }
107
+ }
108
+
109
+ get url(): string {
110
+ const addr = this.httpServer.address() as AddressInfo;
111
+ return `ws://${addr.address}:${addr.port}`;
112
+ }
113
+
114
+ async close(): Promise<void> {
115
+ return new Promise((resolve) => {
116
+ // Close all WebSocket connections
117
+ this.connections.forEach((conn) => {
118
+ if (conn.readyState === WebSocket.OPEN) {
119
+ conn.close();
120
+ }
121
+ });
122
+ this.connections.clear();
123
+
124
+ // Abort all controllers
125
+ this.controllers.forEach((controller) => controller.abort());
126
+ this.controllers.clear();
127
+
128
+ // Close WebSocket server
129
+ this.wsServer.close(() => {
130
+ // Close HTTP server
131
+ this.httpServer.close(() => {
132
+ resolve();
133
+ });
134
+ });
135
+ });
136
+ }
137
+
138
+ open(): void {
139
+ if (!this.httpServer.listening) {
140
+ this.httpServer = createServer();
141
+ this.wsServer = new WebSocketServer({ server: this.httpServer });
142
+ this.setupWebSocketServer();
143
+ this.httpServer.listen(0, '127.0.0.1', () => {
144
+ this.port = (this.httpServer.address() as AddressInfo).port;
145
+ });
146
+ }
147
+ }
148
+
149
+ event(event: NostrEvent): Promise<void> {
150
+ return this.store.event(event);
151
+ }
152
+
153
+ async [Symbol.asyncDispose](): Promise<void> {
154
+ await this.close();
155
+ }
156
+ }
package/test/mod.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { NostrEvent } from '@nostrify/types';
2
+ import { finalizeEvent, generateSecretKey } from 'nostr-tools';
3
+ import { readFile } from 'node:fs/promises';
4
+
5
+ export { ErrorRelay } from './ErrorRelay';
6
+ export { MockRelay } from './MockRelay';
7
+
8
+ /** Import a JSONL fixture by name in tests. */
9
+ export async function jsonlEvents(path: string): Promise<NostrEvent[]> {
10
+ const data = await readFile(path, { encoding: 'utf8' });
11
+ return data.split('\n').map((line) => JSON.parse(line));
12
+ }
13
+
14
+ /** Generate an event for use in tests. */
15
+ export function genEvent(
16
+ t: Partial<NostrEvent> = {},
17
+ sk: Uint8Array = generateSecretKey(),
18
+ ): NostrEvent {
19
+ const { id, kind, pubkey, tags, content, created_at, sig } = finalizeEvent({
20
+ kind: 255,
21
+ created_at: 0,
22
+ content: '',
23
+ tags: [],
24
+ ...t,
25
+ }, sk);
26
+
27
+ return { id, kind, pubkey, tags, content, created_at, sig };
28
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": [
4
+ "./**/*.ts"
5
+ ],
6
+ "exclude": [
7
+ "./dist",
8
+ "./**/*.bench.ts",
9
+ "./**/*.test.ts"
10
+ ],
11
+ "compilerOptions": {
12
+ "outDir": "./dist"
13
+ }
14
+ }
@@ -0,0 +1,26 @@
1
+ import { assertEquals } from '@std/assert';
2
+ import { generateSecretKey } from 'nostr-tools';
3
+
4
+ import { BlossomUploader } from './BlossomUploader.ts';
5
+ import { NSecSigner } from '../NSecSigner.ts';
6
+
7
+ Deno.test('BlossomUploader.upload', { ignore: Deno.env.get('CI') === 'true' }, async () => {
8
+ const fsFile = await Deno.open(new URL('../../../fixtures/voadi.png', import.meta.url));
9
+ const blob = await (new Response(fsFile.readable)).blob();
10
+ const file = new File([blob], 'voadi.png', { type: 'image/png' });
11
+
12
+ const uploader = new BlossomUploader({
13
+ servers: ['https://blossom.primal.net/'],
14
+ signer: new NSecSigner(generateSecretKey()),
15
+ });
16
+
17
+ const tags = await uploader.upload(file);
18
+
19
+ assertEquals(tags, [
20
+ ['url', 'https://blossom.primal.net/7508bd9d8b0ed6e0891a3b973adf6011b1e49f6174910d6a1eb722a4a2e30539.png'],
21
+ ['x', '7508bd9d8b0ed6e0891a3b973adf6011b1e49f6174910d6a1eb722a4a2e30539'],
22
+ ['ox', '7508bd9d8b0ed6e0891a3b973adf6011b1e49f6174910d6a1eb722a4a2e30539'],
23
+ ['size', '172'],
24
+ ['m', 'image/png'],
25
+ ]);
26
+ });
@@ -0,0 +1,98 @@
1
+ import { NostrSigner, NUploader } from '@nostrify/types';
2
+ import { encodeHex } from '@std/encoding/hex';
3
+ import { z } from 'zod';
4
+
5
+ import { N64 } from '../utils/N64';
6
+
7
+ /** BlossomUploader options. */
8
+ export interface BlossomUploaderOpts {
9
+ /** Blossom servers to use. */
10
+ servers: Request['url'][];
11
+ /** Signer for Blossom authorizations. */
12
+ signer: NostrSigner;
13
+ /** Custom fetch implementation. */
14
+ fetch?: typeof fetch;
15
+ /** Number of milliseconds until each request should expire. (Default: `60_000`) */
16
+ expiresIn?: number;
17
+ }
18
+
19
+ /** Upload files to Blossom servers. */
20
+ export class BlossomUploader implements NUploader {
21
+ private servers: Request['url'][];
22
+ private signer: NostrSigner;
23
+ private fetch: typeof fetch;
24
+ private expiresIn: number;
25
+
26
+ constructor(opts: BlossomUploaderOpts) {
27
+ this.servers = opts.servers;
28
+ this.signer = opts.signer;
29
+ this.fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
30
+ this.expiresIn = opts.expiresIn ?? 60_000;
31
+ }
32
+
33
+ async upload(
34
+ file: File,
35
+ opts?: { signal?: AbortSignal },
36
+ ): Promise<[['url', string], ...string[][]]> {
37
+ const x = encodeHex(
38
+ await crypto.subtle.digest('SHA-256', await file.arrayBuffer()),
39
+ );
40
+
41
+ const now = Date.now();
42
+ const expiration = now + this.expiresIn;
43
+
44
+ const event = await this.signer.signEvent({
45
+ kind: 24242,
46
+ content: `Upload ${file.name}`,
47
+ created_at: Math.floor(now / 1000),
48
+ tags: [
49
+ ['t', 'upload'],
50
+ ['x', x],
51
+ ['size', file.size.toString()],
52
+ ['expiration', Math.floor(expiration / 1000).toString()],
53
+ ],
54
+ });
55
+
56
+ const authorization = `Nostr ${N64.encodeEvent(event)}`;
57
+
58
+ return Promise.any(this.servers.map(async (server) => {
59
+ const url = new URL('/upload', server);
60
+
61
+ const response = await this.fetch(url, {
62
+ method: 'PUT',
63
+ body: file,
64
+ headers: {
65
+ authorization,
66
+ 'content-type': file.type,
67
+ },
68
+ signal: opts?.signal,
69
+ });
70
+
71
+ const json = await response.json();
72
+ const data = BlossomUploader.schema().parse(json);
73
+
74
+ const tags: [['url', string], ...string[][]] = [
75
+ ['url', data.url],
76
+ ['x', data.sha256],
77
+ ['ox', data.sha256],
78
+ ['size', data.size.toString()],
79
+ ];
80
+
81
+ if (data.type) {
82
+ tags.push(['m', data.type]);
83
+ }
84
+
85
+ return tags;
86
+ }));
87
+ }
88
+
89
+ /** Blossom "BlobDescriptor" schema. */
90
+ private static schema() {
91
+ return z.object({
92
+ url: z.string(),
93
+ sha256: z.string(),
94
+ size: z.number(),
95
+ type: z.string().optional(),
96
+ });
97
+ }
98
+ }
@@ -0,0 +1,22 @@
1
+ import { assertEquals } from '@std/assert';
2
+
3
+ import { NostrBuildUploader } from './NostrBuildUploader.ts';
4
+
5
+ Deno.test('NostrBuildUploader.upload', { ignore: Deno.env.get('CI') === 'true' }, async () => {
6
+ const fsFile = await Deno.open(new URL('../../../fixtures/voadi.png', import.meta.url));
7
+ const blob = await (new Response(fsFile.readable)).blob();
8
+ const file = new File([blob], 'voadi.png', { type: 'image/png' });
9
+
10
+ const uploader = new NostrBuildUploader();
11
+ const tags = await uploader.upload(file);
12
+
13
+ assertEquals(tags, [
14
+ ['url', 'https://image.nostr.build/7508bd9d8b0ed6e0891a3b973adf6011b1e49f6174910d6a1eb722a4a2e30539.png'],
15
+ ['m', 'image/png'],
16
+ ['x', '21608eecb7df80ca3838deb428fd6568a0d0d3b1baac56491e2247a1c110649a'],
17
+ ['ox', '7508bd9d8b0ed6e0891a3b973adf6011b1e49f6174910d6a1eb722a4a2e30539'],
18
+ ['size', '171'],
19
+ ['dim', '16x16'],
20
+ ['blurhash', 'LCB20ssn0+NcbsfjRmaz12WW}osn'],
21
+ ]);
22
+ });
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod';
2
+
3
+ import { N64 } from '../utils/N64';
4
+ import { NIP98 } from '../NIP98';
5
+ import { NostrSigner } from '@nostrify/types';
6
+ import { NUploader } from '@nostrify/types';
7
+
8
+ /** NostrBuildUploader options. */
9
+ export interface NostrBuildUploaderOpts {
10
+ /** nostr.build endpoint to use. Default: `https://nostr.build/api/v2/upload/files` */
11
+ endpoint?: string;
12
+ /** Signer to authenticate with NIP-98 requests. */
13
+ signer?: NostrSigner;
14
+ /** Custom fetch implementation. */
15
+ fetch?: typeof fetch;
16
+ }
17
+
18
+ /** Upload files to nostr.build or another compatible server. */
19
+ export class NostrBuildUploader implements NUploader {
20
+ private endpoint: string;
21
+ private signer?: NostrSigner;
22
+ private fetch: typeof fetch;
23
+
24
+ constructor(opts?: NostrBuildUploaderOpts) {
25
+ this.endpoint = opts?.endpoint ?? 'https://nostr.build/api/v2/upload/files';
26
+ this.signer = opts?.signer;
27
+ this.fetch = opts?.fetch ?? globalThis.fetch.bind(globalThis);
28
+ }
29
+
30
+ async upload(
31
+ file: File,
32
+ opts?: { signal?: AbortSignal },
33
+ ): Promise<[['url', string], ...string[][]]> {
34
+ const formData = new FormData();
35
+ formData.append('fileToUpload', file);
36
+
37
+ const request = new Request(this.endpoint, {
38
+ method: 'POST',
39
+ body: formData,
40
+ signal: opts?.signal,
41
+ });
42
+
43
+ if (this.signer) {
44
+ const t = await NIP98.template(request);
45
+ const event = await this.signer.signEvent(t);
46
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
47
+ }
48
+
49
+ const response = await this.fetch(request);
50
+ const json = await response.json();
51
+ const [data] = NostrBuildUploader.schema().parse(json).data;
52
+
53
+ const tags: [['url', string], ...string[][]] = [
54
+ ['url', data.url],
55
+ ['m', data.mime],
56
+ ['x', data.sha256],
57
+ ['ox', data.original_sha256],
58
+ ['size', data.size.toString()],
59
+ ];
60
+
61
+ if (data.dimensions) {
62
+ tags.push(['dim', `${data.dimensions.width}x${data.dimensions.height}`]);
63
+ }
64
+
65
+ if (data.blurhash) {
66
+ tags.push(['blurhash', data.blurhash]);
67
+ }
68
+
69
+ return tags;
70
+ }
71
+
72
+ /** nostr.build API response schema. */
73
+ private static schema() {
74
+ return z.object({
75
+ data: z.object({
76
+ url: z.string().url(),
77
+ blurhash: z.string().optional().catch(undefined),
78
+ sha256: z.string(),
79
+ original_sha256: z.string(),
80
+ mime: z.string(),
81
+ size: z.number(),
82
+ dimensions: z.object({
83
+ width: z.number().positive(),
84
+ height: z.number().positive(),
85
+ }).optional().catch(undefined),
86
+ }).array().min(1),
87
+ });
88
+ }
89
+ }
@@ -0,0 +1,2 @@
1
+ export { BlossomUploader } from './BlossomUploader';
2
+ export { NostrBuildUploader } from './NostrBuildUploader';
@@ -0,0 +1,15 @@
1
+ import { assertEquals } from '@std/assert';
2
+
3
+ import { CircularSet } from './CircularSet.ts';
4
+
5
+ Deno.test('CircularSet', () => {
6
+ const set = new CircularSet<number>(3);
7
+
8
+ set.add(1);
9
+ set.add(2);
10
+ set.add(3);
11
+ set.add(3);
12
+ set.add(4);
13
+
14
+ assertEquals([...set], [2, 3, 4]);
15
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Like a Circular Buffer, but the values are deduplicated.
3
+ * Shares the properties of both a Circular Buffer and a Set.
4
+ */
5
+ export class CircularSet<T> {
6
+ private set: Set<T>;
7
+
8
+ constructor(private capacity: number) {
9
+ this.set = new Set();
10
+ }
11
+
12
+ add(item: T): void {
13
+ if (this.set.has(item)) {
14
+ return;
15
+ }
16
+
17
+ if (this.set.size >= this.capacity) {
18
+ const oldest = this.set.values().next().value;
19
+ if (oldest) {
20
+ this.set.delete(oldest);
21
+ }
22
+ }
23
+
24
+ this.set.add(item);
25
+ }
26
+
27
+ has(item: T): boolean {
28
+ return this.set.has(item);
29
+ }
30
+
31
+ [Symbol.iterator](): Iterator<T> {
32
+ return this.set.values();
33
+ }
34
+ }
@@ -0,0 +1,91 @@
1
+ import { assertEquals, assertRejects } from '@std/assert';
2
+
3
+ import { Machina } from './Machina.ts';
4
+
5
+ Deno.test('push, iterate, & close', async () => {
6
+ const results = [];
7
+ const machina = new Machina<number>();
8
+
9
+ machina.push(1);
10
+ machina.push(2);
11
+ setTimeout(() => machina.push(3), 100);
12
+
13
+ for await (const msg of machina) {
14
+ results.push(msg);
15
+
16
+ if (results.length === 3) {
17
+ break;
18
+ }
19
+ }
20
+
21
+ assertEquals(results, [1, 2, 3]);
22
+ });
23
+
24
+ Deno.test('close & reopen', async () => {
25
+ const machina = new Machina<number>();
26
+
27
+ machina.push(777);
28
+ for await (const msg of machina) {
29
+ assertEquals(msg, 777);
30
+ break;
31
+ }
32
+
33
+ machina.push(888);
34
+ for await (const msg of machina) {
35
+ assertEquals(msg, 888);
36
+ break;
37
+ }
38
+ });
39
+
40
+ Deno.test('aborts with signal', async () => {
41
+ const machina = new Machina<number>(AbortSignal.timeout(100));
42
+
43
+ await assertRejects(async () => {
44
+ for await (const _msg of machina) {
45
+ // Should never reach here.
46
+ }
47
+ });
48
+ });
49
+
50
+ Deno.test('already aborted signal in constructor', async () => {
51
+ const machina = new Machina<number>(AbortSignal.abort()); // doesn't throw
52
+
53
+ await assertRejects(async () => {
54
+ for await (const _msg of machina) {
55
+ // Should never reach here.
56
+ }
57
+ });
58
+ });
59
+
60
+ Deno.test('push after abort', async () => {
61
+ const controller = new AbortController();
62
+ const machina = new Machina<number>(controller.signal);
63
+
64
+ controller.abort();
65
+ machina.push(999);
66
+
67
+ await assertRejects(async () => {
68
+ for await (const _msg of machina) {
69
+ // Should never reach here.
70
+ }
71
+ }, DOMException);
72
+ });
73
+
74
+ Deno.test('multiple messages in queue', async () => {
75
+ const results = [];
76
+ const machina = new Machina<number>();
77
+
78
+ machina.push(10);
79
+ machina.push(20);
80
+ machina.push(30);
81
+
82
+ for await (const msg of machina) {
83
+ results.push(msg);
84
+
85
+ if (results.length === 3) {
86
+ break;
87
+ }
88
+ }
89
+
90
+ assertEquals(results, [10, 20, 30]);
91
+ });