@mentra/cloud-client 0.1.0-beta.0

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @fileoverview The audio subscription REST writer, with the version counter.
3
+ *
4
+ * Subscriptions move over REST (not the WebSocket) so the client gets
5
+ * request/response and the WebSocket stays a downstream push channel. This class
6
+ * owns the full-replace semantics: every change ships the entire desired set,
7
+ * not a diff, so the cloud never has to reconcile partial updates.
8
+ *
9
+ * Two fields exist purely to survive the legacy scars the cloud guards against:
10
+ * - `version`: a monotonic counter the client bumps on every change, so the
11
+ * cloud can discard an out-of-order arrival (a retried or reordered write
12
+ * cannot clobber a newer set).
13
+ * - `sessionId`: tied to the current session from `connection.ack`, so a write
14
+ * from a stale session is ignored by the cloud.
15
+ *
16
+ * See docs/issues/002-cloud-runtime/audio/wire.md ("Subscription REST endpoint")
17
+ * and docs/issues/004-cloud-client/design.md ("Subscriptions").
18
+ */
19
+ import type { HttpClient } from "../../http";
20
+ import type { AudioSubscription } from "@mentra/cloud-protocol";
21
+
22
+ /** The REST path the cloud exposes for the full-replace subscription write. */
23
+ const SUBSCRIPTIONS_PATH = "/api/audio/subscriptions";
24
+
25
+ export interface SubscriptionsDeps {
26
+ http: HttpClient;
27
+ }
28
+
29
+ export class Subscriptions {
30
+ private readonly http: HttpClient;
31
+
32
+ /**
33
+ * The last set we sent. Kept so a reconnect can re-send the exact same set at
34
+ * the current version without the caller having to remember it.
35
+ */
36
+ private current: AudioSubscription[] = [];
37
+
38
+ /**
39
+ * The monotonic version of the set above. Starts at 0 so the first real
40
+ * `set()` ships version 1; the cloud treats higher as newer.
41
+ */
42
+ private version = 0;
43
+
44
+ constructor(deps: SubscriptionsDeps) {
45
+ this.http = deps.http;
46
+ }
47
+
48
+ /**
49
+ * The current desired subscription set (the last one `set()` recorded).
50
+ *
51
+ * Read by the connection's `initPayload` so a (re)connect's `connection.init`
52
+ * carries the live set as `audio.initialSubscriptions`. That makes the cloud
53
+ * seed the new session's subscription key NON-EMPTY at handshake, so the
54
+ * reconnected session transcribes atomically — without depending on the
55
+ * follow-up REST resend landing (whose control-stream nudge can be missed by a
56
+ * just-created `$`-positioned consumer group on the new owner pod). Returns a
57
+ * copy so a caller cannot mutate our cached set.
58
+ */
59
+ currentSet(): AudioSubscription[] {
60
+ return [...this.current];
61
+ }
62
+
63
+ /**
64
+ * Replace the cloud's subscription set with `subs` for this session.
65
+ *
66
+ * Bumps the version on every call (even when the set is unchanged) so the
67
+ * cloud always sees a strictly increasing version and applies the latest. The
68
+ * full set is sent each time because the contract is full-replace, not a diff:
69
+ * the cloud holds the authoritative set and we overwrite it wholesale.
70
+ */
71
+ async set(subs: AudioSubscription[], sessionId: string): Promise<void> {
72
+ // Snapshot the set so a later in-place mutation by the caller cannot change
73
+ // what we believe we sent (and would re-send on reconnect).
74
+ this.current = [...subs];
75
+ this.version += 1;
76
+ await this.send(sessionId);
77
+ }
78
+
79
+ /**
80
+ * Re-send the current set at the current version for a (possibly new) session.
81
+ *
82
+ * Used on reconnect: the cloud may have a fresh session whose subscription set
83
+ * is empty, so without this re-send a live transcription would silently stop.
84
+ * We do NOT bump the version here, because this is a replay of the same
85
+ * logical set rather than a new change; the new `sessionId` is what makes the
86
+ * write land against the reconnected session.
87
+ */
88
+ async resend(sessionId: string): Promise<void> {
89
+ await this.send(sessionId);
90
+ }
91
+
92
+ /**
93
+ * Ship the current set, retrying REJECTED writes (not just transport
94
+ * failures). A "stale-session" rejection means a half-open ghost session
95
+ * still holds the cloud's subscription record — typically the app's previous
96
+ * process after a force-stop/crash, whose socket died without a close frame.
97
+ * The cloud refuses to hand the record to us while the ghost still looks
98
+ * alive, but its liveness window lapses within ~45s and the cloud then
99
+ * accepts a takeover write — so a patient retry self-heals where a
100
+ * fire-and-forget write would leave transcription silently dead until the
101
+ * next reconnect. "stale-version" adopts the cloud's authoritative version
102
+ * and retries once above it.
103
+ */
104
+ private async send(sessionId: string): Promise<void> {
105
+ const RETRY_DELAYS_MS = [5_000, 15_000, 30_000, 45_000];
106
+ for (let attempt = 0; ; attempt++) {
107
+ // The full-replace PUT is idempotent: replaying it lands the same state,
108
+ // so the HTTP helper may retry it on transient network failure too.
109
+ const res = await this.http.put<{
110
+ applied: boolean;
111
+ version: number;
112
+ reason: string | null;
113
+ }>(
114
+ SUBSCRIPTIONS_PATH,
115
+ {
116
+ subscriptions: this.current,
117
+ sessionId,
118
+ version: this.version,
119
+ },
120
+ { idempotent: true },
121
+ );
122
+ if (res?.applied !== false) return;
123
+
124
+ if (res.reason === "stale-version") {
125
+ // The cloud holds a newer version for this session (e.g. a replayed
126
+ // older write). Adopt its counter and go strictly above it.
127
+ this.version = Math.max(this.version, res.version) + 1;
128
+ continue;
129
+ }
130
+
131
+ const delay = RETRY_DELAYS_MS[attempt];
132
+ if (res.reason !== "stale-session" || delay === undefined) return;
133
+ await new Promise((resolve) => setTimeout(resolve, delay));
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @fileoverview Runtime TTS API.
3
+ *
4
+ * The host owns the actual playback device (phone speaker / glasses audio
5
+ * route), but the cloud-client owns the runtime endpoint, auth, and fast
6
+ * validation. Callers receive a playable audio source only after the runtime
7
+ * confirms the route exists and returns audio.
8
+ */
9
+ import type { HttpClient } from "../../http";
10
+
11
+ const TTS_PATH = "/api/tts/speak";
12
+
13
+ export interface RuntimeTtsSpeakOptions {
14
+ voiceId?: string;
15
+ voice_id?: string;
16
+ modelId?: string;
17
+ model_id?: string;
18
+ voiceSettings?: Record<string, unknown>;
19
+ voice_settings?: Record<string, unknown>;
20
+ }
21
+
22
+ export interface RuntimeTtsSpeechSource {
23
+ audioUrl: string;
24
+ contentType: string;
25
+ source: "cloud";
26
+ }
27
+
28
+ export interface TtsDeps {
29
+ http: HttpClient;
30
+ }
31
+
32
+ export class Tts {
33
+ private readonly http: HttpClient;
34
+
35
+ constructor(deps: TtsDeps) {
36
+ this.http = deps.http;
37
+ }
38
+
39
+ /**
40
+ * Prepare cloud TTS for host playback.
41
+ *
42
+ * This intentionally returns an audio source rather than playing audio itself:
43
+ * React Native and Node hosts have different playback devices. The important
44
+ * boundary is that callers do not construct runtime URLs or discover 404s via
45
+ * a media player timeout.
46
+ */
47
+ async speak(text: string, options: RuntimeTtsSpeakOptions = {}): Promise<RuntimeTtsSpeechSource> {
48
+ if (!text.trim()) {
49
+ throw new Error("runtime.tts.speak requires text");
50
+ }
51
+
52
+ const path = this.buildTtsPath(text, options);
53
+ const res = await this.http.head(path, { idempotent: true });
54
+ const contentType = res.headers.get("content-type") ?? "";
55
+ if (!contentType.toLowerCase().startsWith("audio/")) {
56
+ throw new Error(`Runtime TTS returned non-audio content-type: ${contentType || "missing"}`);
57
+ }
58
+
59
+ return {
60
+ audioUrl: this.http.url(path),
61
+ contentType,
62
+ source: "cloud",
63
+ };
64
+ }
65
+
66
+ private buildTtsPath(text: string, options: RuntimeTtsSpeakOptions): string {
67
+ const query = new URLSearchParams();
68
+ query.set("text", text);
69
+
70
+ const voiceId = options.voiceId ?? options.voice_id;
71
+ if (voiceId) query.set("voice_id", voiceId);
72
+
73
+ const modelId = options.modelId ?? options.model_id;
74
+ if (modelId) query.set("model_id", modelId);
75
+
76
+ const voiceSettings = options.voiceSettings ?? options.voice_settings;
77
+ if (voiceSettings) query.set("voice_settings", JSON.stringify(voiceSettings));
78
+
79
+ return `${TTS_PATH}?${query.toString()}`;
80
+ }
81
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @fileoverview The three platform pieces the shared core takes as inputs.
3
+ *
4
+ * A phone and a server differ in exactly three places: how they open a
5
+ * WebSocket, how they send UDP, and where they store tokens. The shared logic
6
+ * only ever touches these interfaces, never a real socket, so the same core code
7
+ * runs on the device and in a Node/Bun test harness. The `react-native` and
8
+ * `node` wrappers each supply concrete implementations.
9
+ *
10
+ * See docs/issues/004-cloud-client/spec.md ("Construction") and design.md.
11
+ */
12
+
13
+ /**
14
+ * A text WebSocket, reduced to the surface the runtime needs.
15
+ *
16
+ * Callbacks (not an EventEmitter) so the interface is identical across the RN
17
+ * built-in socket, nitro-websockets, and the Node `ws` package, none of which
18
+ * share an event API.
19
+ */
20
+ export interface WebSocketLike {
21
+ send(data: string): void;
22
+ sendBinary(data: Uint8Array): void;
23
+ close(): void;
24
+ onOpen(cb: () => void): void;
25
+ onMessage(cb: (data: string) => void): void;
26
+ onClose(cb: (info: { code: number; reason: string }) => void): void;
27
+ onError(cb: (err: unknown) => void): void;
28
+ }
29
+
30
+ /**
31
+ * A UDP socket for the audio path.
32
+ *
33
+ * The encrypted audio bytes are built in the shared core and handed here as raw
34
+ * bytes, so this interface stays a thin send/receive with no codec or crypto
35
+ * knowledge. `host`/`port` come from `connection.ack`, not from construction,
36
+ * because they are assigned per session.
37
+ */
38
+ export interface UdpSocketLike {
39
+ send(bytes: Uint8Array, host: string, port: number): void;
40
+ onMessage(cb: (bytes: Uint8Array) => void): void;
41
+ close(): void;
42
+ }
43
+
44
+ /**
45
+ * A tiny key/value store for credentials.
46
+ *
47
+ * Only the refresh token needs to survive a relaunch, so the surface stays at
48
+ * get/set/delete. On the device this is the OS secure store; in tests it is
49
+ * memory or a temp file. Async because the secure store is async.
50
+ */
51
+ export interface KeyValueStore {
52
+ get(key: string): Promise<string | null>;
53
+ set(key: string, value: string): Promise<void>;
54
+ delete(key: string): Promise<void>;
55
+ }
56
+
57
+ /** Fetch-compatible HTTP executor supplied by hosts that need networking to
58
+ * outlive their JavaScript UI lifecycle (for example Android foreground services). */
59
+ export type HttpTransport = (
60
+ input: Parameters<typeof fetch>[0],
61
+ init?: Parameters<typeof fetch>[1],
62
+ ) => ReturnType<typeof fetch>;
63
+
64
+ /**
65
+ * The bundle of platform pieces handed to the root `CloudClient`.
66
+ *
67
+ * `ws` and `udp` are factories (not instances) because the runtime opens a fresh
68
+ * socket on every (re)connect and a fresh UDP socket per session, rather than
69
+ * reusing one for the client's whole lifetime.
70
+ */
71
+ export interface CloudClientTransports {
72
+ ws: (url: string) => WebSocketLike;
73
+ udp: () => UdpSocketLike;
74
+ storage: KeyValueStore;
75
+ /** Falls back to globalThis.fetch when omitted. */
76
+ http?: HttpTransport;
77
+ }