@mentra/cloud-client 0.1.0-dev.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.
package/node/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @fileoverview The `@mentra/cloud-client/node` build: a server-wired CloudClient.
3
+ *
4
+ * This is the import the backend test harness and the dev stack use. It is a
5
+ * thin wrapper over the shared `CloudClient`: it pre-wires the Node transports
6
+ * (the `ws` package, `dgram`, and an in-memory store) so a caller passes only
7
+ * `{ endpoints, auth, ... }` and never has to hand-build sockets. The shared
8
+ * logic lives entirely in the root build; nothing platform-specific leaks past
9
+ * this file.
10
+ *
11
+ * See docs/issues/004-cloud-client/design.md ("node/index.ts").
12
+ */
13
+ import { CloudClient as Base } from "@mentra/cloud-client";
14
+ import type { CloudClientConfig } from "@mentra/cloud-client";
15
+ import { nodeTransports } from "./transports";
16
+
17
+ /**
18
+ * A `CloudClient` with the Node transports already supplied.
19
+ *
20
+ * The constructor takes the full config minus `transports`: the platform build
21
+ * owns that piece, so a host cannot accidentally pass server sockets to the
22
+ * device build or vice versa. We forward everything else through unchanged and
23
+ * inject a fresh transport bundle on construction.
24
+ */
25
+ export class CloudClient extends Base {
26
+ constructor(config: Omit<CloudClientConfig, "transports">) {
27
+ super({ ...config, transports: nodeTransports() });
28
+ }
29
+ }
30
+
31
+ // Re-export the public surface so a host can import the client and the types it
32
+ // needs from the single `@mentra/cloud-client/node` entry, matching the device
33
+ // build's ergonomics.
34
+ export type {
35
+ CloudClientConfig,
36
+ AuthConfig,
37
+ SubjectTokenType,
38
+ CloudClientTransports,
39
+ WebSocketLike,
40
+ UdpSocketLike,
41
+ KeyValueStore,
42
+ Logger,
43
+ RuntimeAudioTransport,
44
+ RuntimeSnapshot,
45
+ RuntimeStatus,
46
+ RuntimeTtsSpeakOptions,
47
+ RuntimeTtsSpeechSource,
48
+ } from "@mentra/cloud-client";
49
+ export {
50
+ CloudClientError,
51
+ HttpError,
52
+ AuthExpiredError,
53
+ noopLogger,
54
+ } from "@mentra/cloud-client";
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @fileoverview Node/Bun implementations of the three platform transports.
3
+ *
4
+ * This is one of the two platform-specific files for a server (the test harness
5
+ * and the dev stack import it). It maps the abstract transport interfaces the
6
+ * shared core depends on onto concrete server primitives: the `ws` package for
7
+ * the WebSocket, Node's `dgram` for UDP audio, and an in-memory Map for token
8
+ * storage. Nothing here knows about the protocol or the modules above; it only
9
+ * adapts socket and storage shapes.
10
+ *
11
+ * See docs/issues/004-cloud-client/design.md ("node/transports.ts" and "The
12
+ * pieces passed in per platform").
13
+ */
14
+ import WebSocket from "ws";
15
+ import { createSocket } from "node:dgram";
16
+ import type {
17
+ CloudClientTransports,
18
+ WebSocketLike,
19
+ UdpSocketLike,
20
+ KeyValueStore,
21
+ } from "@mentra/cloud-client";
22
+
23
+ /**
24
+ * Adapt the `ws` package to `WebSocketLike`.
25
+ *
26
+ * The shared core expects callback registration (onOpen/onMessage/...), not the
27
+ * EventEmitter that `ws` exposes, so each registrar forwards to a `ws` event.
28
+ * We force every inbound frame to a string because the runtime protocol is text
29
+ * JSON: `ws` hands binary frames as Buffer/ArrayBuffer, so we normalise to a
30
+ * UTF-8 string rather than leaking a Buffer up into the protocol layer.
31
+ */
32
+ function nodeWebSocket(url: string): WebSocketLike {
33
+ const socket = new WebSocket(url);
34
+
35
+ return {
36
+ send(data: string): void {
37
+ socket.send(data);
38
+ },
39
+ sendBinary(data: Uint8Array): void {
40
+ socket.send(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
41
+ },
42
+ close(): void {
43
+ socket.close();
44
+ },
45
+ onOpen(cb: () => void): void {
46
+ socket.on("open", cb);
47
+ },
48
+ onMessage(cb: (data: string) => void): void {
49
+ socket.on("message", (data: WebSocket.RawData, isBinary: boolean) => {
50
+ // The protocol is always text JSON. `ws` still surfaces frames as
51
+ // Buffer; decode to a UTF-8 string so the runtime never sees raw bytes.
52
+ cb(isBinary ? data.toString("utf8") : data.toString());
53
+ });
54
+ },
55
+ onClose(cb: (info: { code: number; reason: string }) => void): void {
56
+ socket.on("close", (code: number, reason: Buffer) => {
57
+ cb({ code, reason: reason.toString("utf8") });
58
+ });
59
+ },
60
+ onError(cb: (err: unknown) => void): void {
61
+ socket.on("error", (err: unknown) => {
62
+ cb(err);
63
+ });
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Adapt Node `dgram` to `UdpSocketLike` for the audio path.
70
+ *
71
+ * The encrypted frame is built in the shared core, so this adapter only moves
72
+ * bytes: `send` targets the per-session host/port from `connection.ack`, and
73
+ * inbound datagrams are handed up as raw bytes. We open an IPv4 UDP socket
74
+ * because the audio host/port the cloud assigns is an IPv4 endpoint.
75
+ */
76
+ function nodeUdpSocket(): UdpSocketLike {
77
+ const socket = createSocket("udp4");
78
+
79
+ return {
80
+ send(bytes: Uint8Array, host: string, port: number): void {
81
+ socket.send(bytes, port, host);
82
+ },
83
+ onMessage(cb: (bytes: Uint8Array) => void): void {
84
+ socket.on("message", (msg: Buffer) => {
85
+ cb(msg);
86
+ });
87
+ },
88
+ close(): void {
89
+ socket.close();
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * An in-memory `KeyValueStore`.
96
+ *
97
+ * A server has no OS secure store and usually does not need credentials to
98
+ * survive a process restart (the test harness re-exchanges on each run), so a
99
+ * plain Map is enough. Each call returns a resolved promise to match the async
100
+ * interface the device's secure store requires.
101
+ */
102
+ function memoryStorage(): KeyValueStore {
103
+ const map = new Map<string, string>();
104
+
105
+ return {
106
+ get(key: string): Promise<string | null> {
107
+ return Promise.resolve(map.has(key) ? (map.get(key) as string) : null);
108
+ },
109
+ set(key: string, value: string): Promise<void> {
110
+ map.set(key, value);
111
+ return Promise.resolve();
112
+ },
113
+ delete(key: string): Promise<void> {
114
+ map.delete(key);
115
+ return Promise.resolve();
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Build the Node transport bundle handed to the root `CloudClient`.
122
+ *
123
+ * `ws` and `udp` are factories because the runtime opens a fresh socket on every
124
+ * (re)connect and a fresh UDP socket per session; `storage` is a single instance
125
+ * because the token state is shared for the client's whole lifetime.
126
+ */
127
+ export function nodeTransports(): CloudClientTransports {
128
+ return {
129
+ ws: nodeWebSocket,
130
+ udp: nodeUdpSocket,
131
+ storage: memoryStorage(),
132
+ };
133
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@mentra/cloud-client",
3
+ "version": "0.1.0-dev.0",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./react-native": "./react-native/index.ts",
9
+ "./node": "./node/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test"
13
+ },
14
+ "dependencies": {
15
+ "@mentra/cloud-protocol": "^0.1.0-dev.0",
16
+ "tweetnacl": "^1.0.3"
17
+ },
18
+ "devDependencies": {
19
+ "ws": "^8.18.0",
20
+ "@types/ws": "^8.5.12"
21
+ },
22
+ "peerDependencies": {
23
+ "ws": "^8.18.0"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "ws": {
27
+ "optional": true
28
+ }
29
+ },
30
+ "files": [
31
+ "src",
32
+ "react-native",
33
+ "node",
34
+ "!src/**/*.test.ts",
35
+ "!react-native/**/*.test.ts",
36
+ "!node/**/*.test.ts"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Mentra-Community/MentraOS.git",
44
+ "directory": "cloud-v2/packages/cloud-client"
45
+ }
46
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @fileoverview The React Native build: `@mentra/cloud-client/react-native`.
3
+ *
4
+ * This is what the mobile app imports. It is a thin wrapper over the shared
5
+ * `CloudClient` that supplies the phone's transports (WebSocket, native UDP,
6
+ * secure storage), so a host only passes `{ endpoints, auth }` and never has to
7
+ * hand-wire sockets.
8
+ *
9
+ * The UDP and secure-storage transports are host-injected (React Native ships
10
+ * neither). The host wires them once at startup via `setNativeUdp` and
11
+ * `setSecureStorage`, which are re-exported here for convenience.
12
+ *
13
+ * See docs/issues/004-cloud-client/design.md ("react-native/index.ts").
14
+ */
15
+ import { CloudClient as Base } from "@mentra/cloud-client";
16
+ import type { CloudClientConfig } from "@mentra/cloud-client";
17
+ import { reactNativeTransports } from "./transports";
18
+
19
+ /**
20
+ * A `CloudClient` pre-wired with the React Native transports.
21
+ *
22
+ * It takes the config minus `transports` (this build fills those in), so on the
23
+ * phone construction is just `new CloudClient({ endpoints, auth })`.
24
+ */
25
+ export class CloudClient extends Base {
26
+ constructor(config: Omit<CloudClientConfig, "transports">) {
27
+ super({ ...config, transports: reactNativeTransports() });
28
+ }
29
+ }
30
+
31
+ // The host wires the phone's native UDP and secure store through these before
32
+ // constructing a client that uses UDP audio or token persistence.
33
+ export { setNativeUdp, setSecureStorage } from "./transports";
34
+ export type { NativeUdpFactory } from "./transports";
35
+
36
+ // Re-export the public surface so a host can import everything (types, errors,
37
+ // the logger hook) from the platform build without also reaching for the root.
38
+ export * from "@mentra/cloud-client";
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @fileoverview The React Native transports: WebSocket, native UDP, secure store.
3
+ *
4
+ * This is the phone-side wiring for the three platform pieces the shared core
5
+ * takes as inputs (see src/transports.ts). It deliberately imports NOTHING from
6
+ * "react-native" or any native module, so the package still typechecks in CI
7
+ * (which runs on Node/Bun, where those modules do not exist). The phone-specific
8
+ * pieces are reached two ways:
9
+ *
10
+ * - WebSocket: through the RN global `WebSocket`, which React Native provides
11
+ * on `globalThis` (same shape as the browser one). We feature-detect it and
12
+ * adapt it to WebSocketLike, so no import is needed.
13
+ * - UDP and secure storage: through host-injected adapters. React Native has no
14
+ * built-in UDP socket or secure key/value store, so the host app supplies the
15
+ * real implementations (for example react-native-udp and expo-secure-store)
16
+ * and wires them in via the setters below at startup. Until the host wires
17
+ * them, calling these throws a clear error instead of failing silently.
18
+ *
19
+ * Security: tokens and keys flow through `storage` and the UDP frames, so the
20
+ * adapters here never log their values.
21
+ *
22
+ * See docs/issues/004-cloud-client/design.md ("react-native/transports.ts").
23
+ */
24
+ import type {
25
+ CloudClientTransports,
26
+ WebSocketLike,
27
+ UdpSocketLike,
28
+ KeyValueStore,
29
+ } from "../src/transports";
30
+
31
+ /**
32
+ * The minimal shape of the global `WebSocket` we rely on, declared locally.
33
+ *
34
+ * We cannot use the DOM lib's `WebSocket` type because this package compiles
35
+ * with `lib: ["ES2022"]` (no DOM), so we describe just the members we touch.
36
+ * React Native's built-in `WebSocket` and the browser's both satisfy this.
37
+ */
38
+ interface GlobalWebSocket {
39
+ send(data: string | ArrayBuffer): void;
40
+ close(): void;
41
+ onopen: ((ev: unknown) => void) | null;
42
+ onmessage: ((ev: { data: unknown }) => void) | null;
43
+ onclose: ((ev: { code: number; reason: string }) => void) | null;
44
+ onerror: ((ev: unknown) => void) | null;
45
+ }
46
+ interface GlobalWebSocketCtor {
47
+ new (url: string): GlobalWebSocket;
48
+ }
49
+
50
+ /**
51
+ * Wrap RN's global `WebSocket` (a browser-style event-property socket) into the
52
+ * callback-based WebSocketLike the runtime expects.
53
+ *
54
+ * The runtime registers each callback exactly once and opens a fresh socket per
55
+ * (re)connect, so a plain property assignment per event is enough; there is no
56
+ * need for an add/removeEventListener registry here.
57
+ */
58
+ function adaptWebSocket(raw: GlobalWebSocket): WebSocketLike {
59
+ return {
60
+ send(data: string): void {
61
+ raw.send(data);
62
+ },
63
+ sendBinary(data: Uint8Array): void {
64
+ const copy = new Uint8Array(data.byteLength);
65
+ copy.set(data);
66
+ raw.send(copy.buffer);
67
+ },
68
+ close(): void {
69
+ raw.close();
70
+ },
71
+ onOpen(cb: () => void): void {
72
+ raw.onopen = () => cb();
73
+ },
74
+ onMessage(cb: (data: string) => void): void {
75
+ // RN delivers text frames as a string in `ev.data`; we only use text
76
+ // frames (the wire protocol is JSON), so coerce defensively to string.
77
+ raw.onmessage = (ev: { data: unknown }) => cb(String(ev.data));
78
+ },
79
+ onClose(cb: (info: { code: number; reason: string }) => void): void {
80
+ raw.onclose = (ev: { code: number; reason: string }) =>
81
+ cb({ code: ev.code, reason: ev.reason });
82
+ },
83
+ onError(cb: (err: unknown) => void): void {
84
+ raw.onerror = (ev: unknown) => cb(ev);
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Build the WebSocket factory from the RN global, failing clearly if absent.
91
+ *
92
+ * We read the constructor off `globalThis` at call time (not module load) so a
93
+ * missing global only matters once a socket is actually opened, and the package
94
+ * still imports cleanly on a server where no global `WebSocket` exists.
95
+ */
96
+ function makeWsFactory(): (url: string) => WebSocketLike {
97
+ return (url: string): WebSocketLike => {
98
+ const Ctor = (globalThis as { WebSocket?: GlobalWebSocketCtor }).WebSocket;
99
+ if (!Ctor) {
100
+ throw new Error(
101
+ "reactNativeTransports: global WebSocket is not available. " +
102
+ "Use @mentra/cloud-client/node off-device, or run on React Native.",
103
+ );
104
+ }
105
+ return adaptWebSocket(new Ctor(url));
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The UDP socket the host wires in. React Native ships no UDP, so the host app
111
+ * provides one (for example backed by react-native-udp) via `setNativeUdp`.
112
+ */
113
+ export type NativeUdpFactory = () => UdpSocketLike;
114
+
115
+ /**
116
+ * The host-supplied UDP factory, or null until `setNativeUdp` is called.
117
+ *
118
+ * Module-level state is intentional: there is one phone process and one native
119
+ * UDP capability, so a single injected factory shared by every CloudClient in
120
+ * the process is the right granularity.
121
+ */
122
+ let nativeUdpFactory: NativeUdpFactory | null = null;
123
+
124
+ /**
125
+ * Wire in the platform's native UDP socket factory.
126
+ *
127
+ * The host calls this once during app startup, before constructing a CloudClient
128
+ * that uses UDP audio. Passing the factory (not a socket) matches UdpSocketLike's
129
+ * contract that a fresh socket is created per session.
130
+ */
131
+ export function setNativeUdp(factory: NativeUdpFactory): void {
132
+ nativeUdpFactory = factory;
133
+ }
134
+
135
+ /**
136
+ * The secure key/value store the host wires in. React Native has no built-in
137
+ * persistent store, so the host provides one (for example expo-secure-store or
138
+ * react-native-keychain) via `setSecureStorage`.
139
+ */
140
+ let secureStorage: KeyValueStore | null = null;
141
+
142
+ /**
143
+ * Wire in the platform's secure storage.
144
+ *
145
+ * The host calls this once at startup so the refresh token survives relaunches.
146
+ * It must be a secure store (Keychain / Keystore-backed), not plain storage,
147
+ * because it holds the long-lived refresh token.
148
+ */
149
+ export function setSecureStorage(store: KeyValueStore): void {
150
+ secureStorage = store;
151
+ }
152
+
153
+ /**
154
+ * A UDP factory that defers to whatever the host injected via `setNativeUdp`.
155
+ *
156
+ * It resolves the injected factory lazily, on each `udp()` call, so the host can
157
+ * wire UDP in any time before the first audio session, not strictly before the
158
+ * CloudClient is constructed.
159
+ */
160
+ function makeUdpFactory(): () => UdpSocketLike {
161
+ return (): UdpSocketLike => {
162
+ if (!nativeUdpFactory) {
163
+ throw new Error(
164
+ "reactNativeTransports: native UDP is not wired. " +
165
+ "Call setNativeUdp(factory) at app startup before using UDP audio.",
166
+ );
167
+ }
168
+ return nativeUdpFactory();
169
+ };
170
+ }
171
+
172
+ /**
173
+ * A KeyValueStore that defers every call to the host-injected secure store.
174
+ *
175
+ * We return a stable wrapper (rather than the injected store directly) so the
176
+ * host may call `setSecureStorage` after `reactNativeTransports()` has already
177
+ * been handed to a CloudClient; each operation resolves the store at call time.
178
+ */
179
+ function makeSecureStorage(): KeyValueStore {
180
+ function require(): KeyValueStore {
181
+ if (!secureStorage) {
182
+ throw new Error(
183
+ "reactNativeTransports: secure storage is not wired. " +
184
+ "Call setSecureStorage(store) at app startup (for example expo-secure-store).",
185
+ );
186
+ }
187
+ return secureStorage;
188
+ }
189
+ return {
190
+ get(key: string): Promise<string | null> {
191
+ return require().get(key);
192
+ },
193
+ set(key: string, value: string): Promise<void> {
194
+ return require().set(key, value);
195
+ },
196
+ delete(key: string): Promise<void> {
197
+ return require().delete(key);
198
+ },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Build the React Native transport bundle for `CloudClient`.
204
+ *
205
+ * WebSocket comes from the RN global; UDP and secure storage come from the
206
+ * host-injected adapters (`setNativeUdp` / `setSecureStorage`). The returned
207
+ * adapters resolve those injections lazily, so the host can wire them at startup
208
+ * independently of when this bundle is constructed.
209
+ */
210
+ export function reactNativeTransports(): CloudClientTransports {
211
+ return {
212
+ ws: makeWsFactory(),
213
+ udp: makeUdpFactory(),
214
+ storage: makeSecureStorage(),
215
+ };
216
+ }