@layers/amba-web 4.0.2 → 4.0.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.
package/dist/index.js CHANGED
@@ -99,8 +99,173 @@ function toAmbaApiError(err) {
99
99
  return new AmbaApiError("UNKNOWN_ERROR", String(err));
100
100
  }
101
101
 
102
+ //#endregion
103
+ //#region src/realtime.ts
104
+ const INITIAL_BACKOFF_MS = 500;
105
+ const MAX_BACKOFF_MS = 15e3;
106
+ /**
107
+ * Builder returned by `Amba.collection(name)`. Chain `.where(...)` to
108
+ * narrow, then `.subscribe(cb)` to start streaming.
109
+ */
110
+ var CollectionSubscription = class {
111
+ filters = {};
112
+ constructor(ctx, name) {
113
+ this.ctx = ctx;
114
+ this.name = name;
115
+ }
116
+ /** Add an equality filter. Chainable. All filters must match (AND). */
117
+ where(column, value) {
118
+ this.filters[column] = value;
119
+ return this;
120
+ }
121
+ /**
122
+ * Start listening. `cb` fires once per matching row change. Returns an
123
+ * unsubscribe handle — call it to close the connection.
124
+ */
125
+ subscribe(cb, options = {}) {
126
+ if (!this.ctx.authHeaders()) throw new Error("Amba.collection().subscribe() requires an authenticated session — call Amba.auth.signIn*/verify* first.");
127
+ const qs = Object.keys(this.filters).length > 0 ? `?where=${encodeURIComponent(JSON.stringify(this.filters))}` : "";
128
+ return openStream({
129
+ url: `${this.ctx.baseUrl}/v1/client/realtime/collections/${encodeURIComponent(this.name)}${qs}`,
130
+ authHeaders: this.ctx.authHeaders,
131
+ onChange: cb,
132
+ options
133
+ });
134
+ }
135
+ };
136
+ /**
137
+ * Open a streamed GET to a realtime endpoint and dispatch row-change events
138
+ * to `onChange`. Reconnects with exponential backoff until the returned
139
+ * unsubscribe handle is invoked. Shared by both collection and conversation
140
+ * subscriptions — the only thing that differs between them is the URL.
141
+ *
142
+ * Uses `fetch` + a streamed response body (rather than the browser's
143
+ * native `EventSource`) because `EventSource` cannot set request headers —
144
+ * which would force the session token into the URL query string, where it
145
+ * would leak into access logs. Streaming `fetch` keeps auth in headers.
146
+ */
147
+ function openStream(args) {
148
+ let stopped = false;
149
+ let controller = null;
150
+ let backoff = INITIAL_BACKOFF_MS;
151
+ const run = async () => {
152
+ while (!stopped) {
153
+ controller = new AbortController();
154
+ try {
155
+ const headers = args.authHeaders();
156
+ if (!headers) throw new Error("no active session");
157
+ const resp = await fetch(args.url, {
158
+ method: "GET",
159
+ headers: {
160
+ ...headers,
161
+ Accept: "text/event-stream"
162
+ },
163
+ signal: controller.signal
164
+ });
165
+ if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
166
+ await consume(resp.body, args, () => {
167
+ backoff = INITIAL_BACKOFF_MS;
168
+ args.options.onOpen?.();
169
+ });
170
+ } catch (err) {
171
+ if (stopped) break;
172
+ args.options.onError?.(err);
173
+ }
174
+ if (stopped) break;
175
+ await delay(backoff);
176
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
177
+ }
178
+ };
179
+ run();
180
+ return () => {
181
+ stopped = true;
182
+ controller?.abort();
183
+ };
184
+ }
185
+ async function consume(body, args, onFirstFrame) {
186
+ const reader = body.getReader();
187
+ const decoder = new TextDecoder();
188
+ let buffer = "";
189
+ let sawFrame = false;
190
+ for (;;) {
191
+ const { done, value } = await reader.read();
192
+ if (done) return;
193
+ buffer += decoder.decode(value, { stream: true });
194
+ let idx;
195
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
196
+ const frame = buffer.slice(0, idx);
197
+ buffer = buffer.slice(idx + 2);
198
+ if (!sawFrame) {
199
+ sawFrame = true;
200
+ onFirstFrame();
201
+ }
202
+ dispatchFrame(frame, args.onChange);
203
+ }
204
+ }
205
+ }
206
+ function dispatchFrame(frame, onChange) {
207
+ let event = "";
208
+ const dataLines = [];
209
+ for (const line of frame.split("\n")) {
210
+ if (line.startsWith(":")) continue;
211
+ if (line.startsWith("event:")) event = line.slice(6).trim();
212
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
213
+ }
214
+ if (event !== "change") return;
215
+ try {
216
+ const payload = JSON.parse(dataLines.join("\n"));
217
+ onChange({
218
+ op: payload.op,
219
+ row: payload.row,
220
+ truncated: payload.truncated
221
+ });
222
+ } catch {}
223
+ }
224
+ function delay(ms) {
225
+ return new Promise((resolve) => setTimeout(resolve, ms));
226
+ }
227
+ /**
228
+ * Builder returned by `Amba.messaging.conversation(id)`. Call `.subscribe(cb)`
229
+ * to receive new messages live instead of polling.
230
+ *
231
+ * const unsub = Amba.messaging
232
+ * .conversation(conversationId)
233
+ * .subscribe((change) => {
234
+ * // change.op === "INSERT"; change.row is the new message
235
+ * });
236
+ * unsub(); // stop listening
237
+ *
238
+ * Delivery is scoped to conversation participants — you receive messages for
239
+ * a conversation only if your session is a member of it. Reconnects with the
240
+ * same exponential backoff as collection subscriptions.
241
+ */
242
+ var ConversationSubscription = class {
243
+ constructor(ctx, conversationId) {
244
+ this.ctx = ctx;
245
+ this.conversationId = conversationId;
246
+ }
247
+ subscribe(cb, options = {}) {
248
+ if (!this.ctx.authHeaders()) throw new Error("Amba.messaging.conversation().subscribe() requires an authenticated session — call Amba.auth.signIn*/verify* first.");
249
+ return openStream({
250
+ url: `${this.ctx.baseUrl}/v1/client/realtime/conversations/${encodeURIComponent(this.conversationId)}`,
251
+ authHeaders: this.ctx.authHeaders,
252
+ onChange: cb,
253
+ options
254
+ });
255
+ }
256
+ };
257
+
102
258
  //#endregion
103
259
  //#region src/index.ts
260
+ const DEFAULT_API_URL = "https://api.amba.dev";
261
+ /**
262
+ * Default host that serves deployed functions. `Amba.functions.invoke(name)`
263
+ * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
264
+ * `functionsBaseUrl` override is configured. The `<projectId>` label is the
265
+ * server-resolved id (from `diagnostics.ping`), which the function router
266
+ * accepts directly.
267
+ */
268
+ const DEFAULT_FUNCTIONS_HOST = "fn.amba.host";
104
269
  /**
105
270
  * Internal state — one instance per page. Created lazily by `Amba.configure()`.
106
271
  */
@@ -111,6 +276,26 @@ let coreInstance = null;
111
276
  * core finishes initializing.
112
277
  */
113
278
  let _configuredProjectId = null;
279
+ /**
280
+ * Realtime (`Amba.collection(name).subscribe`) needs the API base URL,
281
+ * client key, and current session token in the TS layer to open the
282
+ * streamed connection — the engine doesn't expose them back. We capture
283
+ * the base URL + client key at configure-time and the session token from
284
+ * every auth result (the engine rotates it internally on refresh; the
285
+ * subscribe connection picks up the latest at connect time).
286
+ */
287
+ let _configuredApiUrl = DEFAULT_API_URL;
288
+ let _configuredClientKey = null;
289
+ let _currentSessionToken = null;
290
+ /**
291
+ * Functions invocation state. `_configuredFunctionsBaseUrl` is the optional
292
+ * override from `Amba.configure({ functionsBaseUrl })`. When it's null, the
293
+ * default-host path resolves the project id once via `diagnostics.ping` and
294
+ * caches it in `_resolvedFunctionsProjectId` so subsequent invokes skip the
295
+ * round-trip. Both reset on every `configure()`.
296
+ */
297
+ let _configuredFunctionsBaseUrl = null;
298
+ let _resolvedFunctionsProjectId = null;
114
299
  function requireCore() {
115
300
  if (!coreInstance) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) at app start before any other API.");
116
301
  return coreInstance;
@@ -148,9 +333,40 @@ function notifyAuthSubscribers(session) {
148
333
  }
149
334
  }
150
335
  async function notifyFromAuthResult(result) {
336
+ if (result?.session_token) _currentSessionToken = result.session_token;
151
337
  notifyAuthSubscribers(snapshotSession(result.user));
152
338
  }
153
339
  /**
340
+ * Build the auth headers for a realtime subscribe connection, or `null`
341
+ * when there's no live session. Read lazily at connect-time so reconnects
342
+ * pick up a rotated session token.
343
+ */
344
+ function realtimeAuthHeaders() {
345
+ if (!_configuredClientKey || !_currentSessionToken) return null;
346
+ return {
347
+ "X-Api-Key": _configuredClientKey,
348
+ Authorization: `Bearer ${_currentSessionToken}`
349
+ };
350
+ }
351
+ /**
352
+ * Resolve the full invocation URL for a deployed function.
353
+ *
354
+ * With `functionsBaseUrl` configured we POST to `<base>/<name>` verbatim
355
+ * (custom domains / non-prod). Otherwise we resolve the server-side project
356
+ * id once (via `diagnostics.ping`, cached) and target
357
+ * `https://<projectId>.fn.amba.host/<name>` — the function router accepts the
358
+ * project id as the host label directly.
359
+ */
360
+ async function resolveFunctionUrl(name) {
361
+ if (_configuredFunctionsBaseUrl) return `${_configuredFunctionsBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
362
+ if (!_resolvedFunctionsProjectId) {
363
+ const ping = await Amba.diagnostics.ping();
364
+ if (!ping.ok || !ping.server_project_id) throw new AmbaApiError("FUNCTIONS_HOST_UNRESOLVED", "Could not resolve the functions host for this project. Pass `functionsBaseUrl` to Amba.configure() to invoke functions explicitly.", ping);
365
+ _resolvedFunctionsProjectId = ping.server_project_id;
366
+ }
367
+ return `https://${_resolvedFunctionsProjectId}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
368
+ }
369
+ /**
154
370
  * Top-level SDK namespace.
155
371
  *
156
372
  * Stateless from the JS side — all state lives in the SDK runtime. Calling
@@ -187,6 +403,10 @@ const Amba = {
187
403
  throw toAmbaApiError(err);
188
404
  }
189
405
  _configuredProjectId = config.projectId;
406
+ _configuredApiUrl = config.apiUrl ?? DEFAULT_API_URL;
407
+ _configuredClientKey = config.clientKey;
408
+ _configuredFunctionsBaseUrl = config.functionsBaseUrl ?? null;
409
+ _resolvedFunctionsProjectId = null;
190
410
  },
191
411
  get projectId() {
192
412
  if (!_configuredProjectId) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) at app start before any other API.");
@@ -268,6 +488,9 @@ const Amba = {
268
488
  linkEmailOtp: async (email, code) => {
269
489
  return await coreCall((c) => c.linkEmailOtp(email, code));
270
490
  },
491
+ linkEmailPassword: async (email, password) => {
492
+ return await coreCall((c) => c.linkEmailPassword(email, password));
493
+ },
271
494
  unlinkSmsOtp: async () => {
272
495
  return await coreCall((c) => c.unlinkSmsOtp());
273
496
  },
@@ -285,6 +508,7 @@ const Amba = {
285
508
  },
286
509
  signOut: async (rotateAnonymousId = false) => {
287
510
  await coreCall((c) => c.signOut(rotateAnonymousId));
511
+ _currentSessionToken = null;
288
512
  notifyAuthSubscribers(null);
289
513
  },
290
514
  refresh: async () => {
@@ -328,6 +552,10 @@ const Amba = {
328
552
  await coreCall((c) => c.sessionsRevoke(sessionId));
329
553
  }
330
554
  },
555
+ collection: (name) => new CollectionSubscription({
556
+ baseUrl: _configuredApiUrl,
557
+ authHeaders: realtimeAuthHeaders
558
+ }, name),
331
559
  collections: {
332
560
  find: async (name, options = {}) => {
333
561
  return await coreCall((c) => c.collectionsFind(name, encodeJson(options)));
@@ -485,6 +713,45 @@ const Amba = {
485
713
  ai: { anthropic: { messages: { create: async (request) => {
486
714
  return await coreCall((c) => c.aiAnthropicMessages(encodeJson(request)));
487
715
  } } } },
716
+ functions: { invoke: async (name, body, options) => {
717
+ requireCore();
718
+ if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before Amba.functions.invoke().");
719
+ if (!name || name.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.functions.invoke: a function name is required.");
720
+ const url = await resolveFunctionUrl(name);
721
+ const method = options?.method ?? "POST";
722
+ const headers = {
723
+ "X-Api-Key": _configuredClientKey,
724
+ ...options?.headers ?? {}
725
+ };
726
+ if (_currentSessionToken) headers["Authorization"] = `Bearer ${_currentSessionToken}`;
727
+ let requestBody;
728
+ if (body !== void 0 && method !== "GET") {
729
+ headers["content-type"] = "application/json";
730
+ requestBody = JSON.stringify(body);
731
+ }
732
+ let resp;
733
+ try {
734
+ resp = await fetch(url, {
735
+ method,
736
+ headers,
737
+ body: requestBody
738
+ });
739
+ } catch (err) {
740
+ throw new AmbaApiError("NETWORK_ERROR", `Amba.functions.invoke("${name}") network error: ${String(err)}`);
741
+ }
742
+ const text = await resp.text();
743
+ let parsed = void 0;
744
+ if (text.length > 0) try {
745
+ parsed = JSON.parse(text);
746
+ } catch {
747
+ parsed = text;
748
+ }
749
+ if (!resp.ok) {
750
+ const envelope = parsed && typeof parsed === "object" ? parsed : void 0;
751
+ throw new AmbaApiError(typeof envelope?.error?.code === "string" ? envelope.error.code : `HTTP_${resp.status}`, typeof envelope?.error?.message === "string" ? envelope.error.message : typeof parsed === "string" && parsed.length > 0 ? parsed : `Amba.functions.invoke("${name}") failed with status ${resp.status}`, parsed);
752
+ }
753
+ return parsed;
754
+ } },
488
755
  config: { fetch: async () => {
489
756
  return await coreCall((c) => c.configFetch());
490
757
  } },
@@ -591,7 +858,12 @@ const Amba = {
591
858
  getMessage: async (conversationId, messageId) => {
592
859
  return await coreCall((c) => c.messagingGetMessage(conversationId, messageId));
593
860
  },
594
- markRead: async (conversationId) => await coreCall((c) => c.messagingMarkRead(conversationId))
861
+ addParticipant: async (conversationId, userId) => await coreCall((c) => c.messagingAddParticipant(conversationId, userId)),
862
+ markRead: async (conversationId) => await coreCall((c) => c.messagingMarkRead(conversationId)),
863
+ conversation: (conversationId) => new ConversationSubscription({
864
+ baseUrl: _configuredApiUrl,
865
+ authHeaders: realtimeAuthHeaders
866
+ }, conversationId)
595
867
  },
596
868
  moderation: {
597
869
  reportUser: async (request) => await coreCall((c) => c.moderationReportUser(encodeJson(request))),
@@ -649,8 +921,8 @@ function getCoreWasm() {
649
921
  * `verify-sdk-version-coherence` GH action gates every publish on the
650
922
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
651
923
  */
652
- const SDK_VERSION = "4.0.2";
924
+ const SDK_VERSION = "4.0.4";
653
925
 
654
926
  //#endregion
655
- export { Amba, AmbaApiError, SDK_VERSION, getCoreWasm };
927
+ export { Amba, AmbaApiError, CollectionSubscription, ConversationSubscription, SDK_VERSION, getCoreWasm };
656
928
  //# sourceMappingURL=index.js.map