@stvor/sdk 2.2.2 → 2.3.1

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.
@@ -1,23 +1,99 @@
1
1
  import type { StvorAppConfig, UserId, MessageContent } from './types.js';
2
2
  import { RelayClient } from './relay-client.js';
3
3
  type MessageHandler = (from: UserId, msg: string | Uint8Array) => void;
4
+ type UserAvailableHandler = (userId: UserId) => void;
4
5
  export declare class StvorFacadeClient {
5
6
  readonly userId: UserId;
6
7
  private readonly relay;
8
+ private readonly defaultTimeout;
7
9
  private crypto;
8
10
  private handlers;
11
+ private userAvailableHandlers;
9
12
  private knownPubKeys;
10
- constructor(userId: UserId, relay: RelayClient);
13
+ private pendingKeyResolvers;
14
+ constructor(userId: UserId, relay: RelayClient, defaultTimeout?: number);
11
15
  private handleRelayMessage;
12
16
  internalInitialize(): Promise<void>;
13
- send(recipientId: UserId, content: MessageContent): Promise<void>;
17
+ /**
18
+ * Check if a user's public key is available locally
19
+ */
20
+ isUserAvailable(userId: UserId): boolean;
21
+ /**
22
+ * Get list of all known users (whose public keys we have)
23
+ */
24
+ getAvailableUsers(): UserId[];
25
+ /**
26
+ * Wait until a specific user's public key becomes available.
27
+ * This is the recommended way to ensure you can send messages.
28
+ *
29
+ * @param userId - The user to wait for
30
+ * @param timeoutMs - Maximum time to wait (default: 10000ms)
31
+ * @throws StvorError with RECIPIENT_TIMEOUT if timeout expires
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * await alice.waitForUser('bob@example.com');
36
+ * await alice.send('bob@example.com', 'Hello!');
37
+ * ```
38
+ */
39
+ waitForUser(userId: UserId, timeoutMs?: number): Promise<void>;
40
+ /**
41
+ * Send an encrypted message to a recipient.
42
+ *
43
+ * If the recipient's public key is not yet available, this method will
44
+ * automatically wait up to `timeoutMs` for the key to arrive via the relay.
45
+ *
46
+ * @param recipientId - The recipient's user ID
47
+ * @param content - Message content (string or Uint8Array)
48
+ * @param options - Optional: { timeout: number, waitForRecipient: boolean }
49
+ * @throws StvorError with RECIPIENT_TIMEOUT if recipient key doesn't arrive in time
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * // Auto-waits for recipient (recommended)
54
+ * await alice.send('bob@example.com', 'Hello!');
55
+ *
56
+ * // Skip waiting (throws immediately if not available)
57
+ * await alice.send('bob@example.com', 'Hello!', { waitForRecipient: false });
58
+ * ```
59
+ */
60
+ send(recipientId: UserId, content: MessageContent, options?: {
61
+ timeout?: number;
62
+ waitForRecipient?: boolean;
63
+ }): Promise<void>;
64
+ /**
65
+ * Register a handler for incoming messages
66
+ */
14
67
  onMessage(handler: MessageHandler): () => void;
68
+ /**
69
+ * Register a handler that fires when a new user becomes available.
70
+ * This is triggered when we receive a user's public key announcement.
71
+ *
72
+ * **Edge-triggered**: Fires only ONCE per user, on first key discovery.
73
+ * Will NOT fire again if user reconnects with same identity.
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * client.onUserAvailable((userId) => {
78
+ * console.log(`${userId} is now available for messaging`);
79
+ * });
80
+ * ```
81
+ */
82
+ onUserAvailable(handler: UserAvailableHandler): () => void;
15
83
  }
16
84
  export declare class StvorApp {
17
85
  private readonly config;
18
86
  private clients;
19
87
  constructor(config: StvorAppConfig);
20
88
  connect(userId: UserId): Promise<StvorFacadeClient>;
89
+ /**
90
+ * Get a connected client by user ID
91
+ */
92
+ getClient(userId: UserId): StvorFacadeClient | undefined;
93
+ /**
94
+ * Check if a user is connected locally
95
+ */
96
+ isConnected(userId: UserId): boolean;
21
97
  disconnect(userId?: UserId): Promise<void>;
22
98
  }
23
99
  export declare function init(config: StvorAppConfig): Promise<StvorApp>;
@@ -1,12 +1,19 @@
1
1
  import { Errors, StvorError } from './errors.js';
2
2
  import { RelayClient } from './relay-client.js';
3
3
  import { CryptoSession } from './crypto.js';
4
+ /** Default timeout for waiting for recipient keys (ms) */
5
+ const DEFAULT_RECIPIENT_TIMEOUT = 10000;
6
+ /** Polling interval for key resolution (ms) */
7
+ const KEY_POLL_INTERVAL = 100;
4
8
  export class StvorFacadeClient {
5
- constructor(userId, relay) {
9
+ constructor(userId, relay, defaultTimeout = DEFAULT_RECIPIENT_TIMEOUT) {
6
10
  this.userId = userId;
7
11
  this.relay = relay;
12
+ this.defaultTimeout = defaultTimeout;
8
13
  this.handlers = [];
14
+ this.userAvailableHandlers = [];
9
15
  this.knownPubKeys = new Map();
16
+ this.pendingKeyResolvers = new Map();
10
17
  this.crypto = new CryptoSession();
11
18
  // listen relay messages
12
19
  this.relay.onMessage((m) => this.handleRelayMessage(m));
@@ -17,7 +24,23 @@ export class StvorFacadeClient {
17
24
  if (!m || typeof m !== 'object')
18
25
  return;
19
26
  if (m.type === 'announce' && m.user && m.pub) {
27
+ const wasKnown = this.knownPubKeys.has(m.user);
20
28
  this.knownPubKeys.set(m.user, m.pub);
29
+ // Notify pending resolvers
30
+ const resolvers = this.pendingKeyResolvers.get(m.user);
31
+ if (resolvers) {
32
+ resolvers.forEach(resolve => resolve());
33
+ this.pendingKeyResolvers.delete(m.user);
34
+ }
35
+ // Notify user available handlers (only for new users)
36
+ if (!wasKnown) {
37
+ for (const h of this.userAvailableHandlers) {
38
+ try {
39
+ h(m.user);
40
+ }
41
+ catch { }
42
+ }
43
+ }
21
44
  return;
22
45
  }
23
46
  if (m.type === 'message' && m.to === this.userId && m.payload) {
@@ -37,16 +60,109 @@ export class StvorFacadeClient {
37
60
  async internalInitialize() {
38
61
  // nothing for now; announce already sent in constructor
39
62
  }
40
- async send(recipientId, content) {
41
- const recipientPub = this.knownPubKeys.get(recipientId);
63
+ /**
64
+ * Check if a user's public key is available locally
65
+ */
66
+ isUserAvailable(userId) {
67
+ return this.knownPubKeys.has(userId);
68
+ }
69
+ /**
70
+ * Get list of all known users (whose public keys we have)
71
+ */
72
+ getAvailableUsers() {
73
+ return Array.from(this.knownPubKeys.keys()).filter(id => id !== this.userId);
74
+ }
75
+ /**
76
+ * Wait until a specific user's public key becomes available.
77
+ * This is the recommended way to ensure you can send messages.
78
+ *
79
+ * @param userId - The user to wait for
80
+ * @param timeoutMs - Maximum time to wait (default: 10000ms)
81
+ * @throws StvorError with RECIPIENT_TIMEOUT if timeout expires
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * await alice.waitForUser('bob@example.com');
86
+ * await alice.send('bob@example.com', 'Hello!');
87
+ * ```
88
+ */
89
+ async waitForUser(userId, timeoutMs = this.defaultTimeout) {
90
+ // Already available
91
+ if (this.knownPubKeys.has(userId)) {
92
+ return;
93
+ }
94
+ return new Promise((resolve, reject) => {
95
+ const timeout = setTimeout(() => {
96
+ // Remove from pending
97
+ const resolvers = this.pendingKeyResolvers.get(userId);
98
+ if (resolvers) {
99
+ const idx = resolvers.indexOf(resolveHandler);
100
+ if (idx >= 0)
101
+ resolvers.splice(idx, 1);
102
+ if (resolvers.length === 0)
103
+ this.pendingKeyResolvers.delete(userId);
104
+ }
105
+ reject(new StvorError(Errors.RECIPIENT_TIMEOUT, `Timed out waiting for user "${userId}" after ${timeoutMs}ms. ` +
106
+ `The user may not be connected to the relay. ` +
107
+ `Ensure both parties are online before sending messages.`, 'Verify the recipient is connected, or increase timeout', true));
108
+ }, timeoutMs);
109
+ const resolveHandler = () => {
110
+ clearTimeout(timeout);
111
+ resolve();
112
+ };
113
+ // Add to pending resolvers
114
+ if (!this.pendingKeyResolvers.has(userId)) {
115
+ this.pendingKeyResolvers.set(userId, []);
116
+ }
117
+ this.pendingKeyResolvers.get(userId).push(resolveHandler);
118
+ });
119
+ }
120
+ /**
121
+ * Send an encrypted message to a recipient.
122
+ *
123
+ * If the recipient's public key is not yet available, this method will
124
+ * automatically wait up to `timeoutMs` for the key to arrive via the relay.
125
+ *
126
+ * @param recipientId - The recipient's user ID
127
+ * @param content - Message content (string or Uint8Array)
128
+ * @param options - Optional: { timeout: number, waitForRecipient: boolean }
129
+ * @throws StvorError with RECIPIENT_TIMEOUT if recipient key doesn't arrive in time
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * // Auto-waits for recipient (recommended)
134
+ * await alice.send('bob@example.com', 'Hello!');
135
+ *
136
+ * // Skip waiting (throws immediately if not available)
137
+ * await alice.send('bob@example.com', 'Hello!', { waitForRecipient: false });
138
+ * ```
139
+ */
140
+ async send(recipientId, content, options) {
141
+ const { timeout = this.defaultTimeout, waitForRecipient = true } = options ?? {};
142
+ // Try to resolve recipient key
143
+ let recipientPub = this.knownPubKeys.get(recipientId);
42
144
  if (!recipientPub) {
43
- throw new StvorError(Errors.RECIPIENT_NOT_FOUND, 'Recipient public key unknown');
145
+ if (!waitForRecipient) {
146
+ throw new StvorError(Errors.RECIPIENT_NOT_FOUND, `Recipient "${recipientId}" is not available. ` +
147
+ `Their public key has not been announced to the relay. ` +
148
+ `Use waitForUser() or enable waitForRecipient option.`, 'Call waitForUser(recipientId) before sending, or ensure recipient is connected', false);
149
+ }
150
+ // Wait for recipient key with timeout
151
+ await this.waitForUser(recipientId, timeout);
152
+ recipientPub = this.knownPubKeys.get(recipientId);
153
+ if (!recipientPub) {
154
+ // Should not happen, but safety check
155
+ throw new StvorError(Errors.RECIPIENT_NOT_FOUND, `Recipient "${recipientId}" key resolution failed unexpectedly.`, 'This is an internal error, please report it', false);
156
+ }
44
157
  }
45
158
  const plain = typeof content === 'string' ? new TextEncoder().encode(content) : content;
46
159
  const payload = this.crypto.encrypt(plain, recipientPub);
47
160
  const msg = { type: 'message', to: recipientId, from: this.userId, payload };
48
161
  this.relay.send(msg);
49
162
  }
163
+ /**
164
+ * Register a handler for incoming messages
165
+ */
50
166
  onMessage(handler) {
51
167
  this.handlers.push(handler);
52
168
  return () => {
@@ -55,6 +171,28 @@ export class StvorFacadeClient {
55
171
  this.handlers.splice(i, 1);
56
172
  };
57
173
  }
174
+ /**
175
+ * Register a handler that fires when a new user becomes available.
176
+ * This is triggered when we receive a user's public key announcement.
177
+ *
178
+ * **Edge-triggered**: Fires only ONCE per user, on first key discovery.
179
+ * Will NOT fire again if user reconnects with same identity.
180
+ *
181
+ * @example
182
+ * ```typescript
183
+ * client.onUserAvailable((userId) => {
184
+ * console.log(`${userId} is now available for messaging`);
185
+ * });
186
+ * ```
187
+ */
188
+ onUserAvailable(handler) {
189
+ this.userAvailableHandlers.push(handler);
190
+ return () => {
191
+ const i = this.userAvailableHandlers.indexOf(handler);
192
+ if (i >= 0)
193
+ this.userAvailableHandlers.splice(i, 1);
194
+ };
195
+ }
58
196
  }
59
197
  export class StvorApp {
60
198
  constructor(config) {
@@ -66,11 +204,23 @@ export class StvorApp {
66
204
  if (existing)
67
205
  return existing;
68
206
  const relay = new RelayClient(this.config.relayUrl ?? 'wss://stvor.xyz/relay', this.config.appToken, this.config.timeout ?? 10000);
69
- const client = new StvorFacadeClient(userId, relay);
207
+ const client = new StvorFacadeClient(userId, relay, this.config.timeout ?? 10000);
70
208
  await client.internalInitialize();
71
209
  this.clients.set(userId, client);
72
210
  return client;
73
211
  }
212
+ /**
213
+ * Get a connected client by user ID
214
+ */
215
+ getClient(userId) {
216
+ return this.clients.get(userId);
217
+ }
218
+ /**
219
+ * Check if a user is connected locally
220
+ */
221
+ isConnected(userId) {
222
+ return this.clients.has(userId);
223
+ }
74
224
  async disconnect(userId) {
75
225
  if (userId) {
76
226
  this.clients.delete(userId);
@@ -3,9 +3,11 @@ export declare const Errors: {
3
3
  readonly RELAY_UNAVAILABLE: "RELAY_UNAVAILABLE";
4
4
  readonly DELIVERY_FAILED: "DELIVERY_FAILED";
5
5
  readonly RECIPIENT_NOT_FOUND: "RECIPIENT_NOT_FOUND";
6
+ readonly RECIPIENT_TIMEOUT: "RECIPIENT_TIMEOUT";
6
7
  readonly MESSAGE_INTEGRITY_FAILED: "MESSAGE_INTEGRITY_FAILED";
7
8
  readonly RECEIVE_TIMEOUT: "RECEIVE_TIMEOUT";
8
9
  readonly RECEIVE_IN_PROGRESS: "RECEIVE_IN_PROGRESS";
10
+ readonly NOT_CONNECTED: "NOT_CONNECTED";
9
11
  };
10
12
  export type ErrorCode = (typeof Errors)[keyof typeof Errors];
11
13
  export declare class StvorError extends Error {
@@ -3,9 +3,11 @@ export const Errors = {
3
3
  RELAY_UNAVAILABLE: 'RELAY_UNAVAILABLE',
4
4
  DELIVERY_FAILED: 'DELIVERY_FAILED',
5
5
  RECIPIENT_NOT_FOUND: 'RECIPIENT_NOT_FOUND',
6
+ RECIPIENT_TIMEOUT: 'RECIPIENT_TIMEOUT',
6
7
  MESSAGE_INTEGRITY_FAILED: 'MESSAGE_INTEGRITY_FAILED',
7
8
  RECEIVE_TIMEOUT: 'RECEIVE_TIMEOUT',
8
9
  RECEIVE_IN_PROGRESS: 'RECEIVE_IN_PROGRESS',
10
+ NOT_CONNECTED: 'NOT_CONNECTED',
9
11
  };
10
12
  export class StvorError extends Error {
11
13
  constructor(code, message, action, retryable) {
@@ -13,5 +15,6 @@ export class StvorError extends Error {
13
15
  this.code = code;
14
16
  this.action = action;
15
17
  this.retryable = retryable;
18
+ this.name = 'StvorError';
16
19
  }
17
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stvor/sdk",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
4
4
  "description": "Stvor DX Facade - Simple E2EE SDK for client-side encryption",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",