@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 +54 -0
- package/node/transports.ts +133 -0
- package/package.json +46 -0
- package/react-native/index.ts +38 -0
- package/react-native/transports.ts +216 -0
- package/src/client.ts +265 -0
- package/src/config.ts +84 -0
- package/src/errors.ts +54 -0
- package/src/http.ts +273 -0
- package/src/index.ts +70 -0
- package/src/logger.ts +34 -0
- package/src/modules/auth/auth.ts +548 -0
- package/src/modules/auth/jwt.ts +82 -0
- package/src/modules/auth/token-store.ts +138 -0
- package/src/modules/core/core.ts +202 -0
- package/src/modules/core/reports.ts +153 -0
- package/src/modules/runtime/audio-udp.ts +192 -0
- package/src/modules/runtime/camera.ts +192 -0
- package/src/modules/runtime/connection.ts +782 -0
- package/src/modules/runtime/emitter.ts +129 -0
- package/src/modules/runtime/maps.ts +92 -0
- package/src/modules/runtime/runtime.ts +557 -0
- package/src/modules/runtime/status.ts +12 -0
- package/src/modules/runtime/subscriptions.ts +136 -0
- package/src/modules/runtime/tts.ts +81 -0
- package/src/transports.ts +68 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The UDP audio path: encrypt each frame in the shared core, then
|
|
3
|
+
* hand the raw bytes to the injected UDP socket.
|
|
4
|
+
*
|
|
5
|
+
* Encryption lives here (not in native code) so it is byte-for-byte identical on
|
|
6
|
+
* a phone and a server and therefore testable from a Node/Bun harness. The
|
|
7
|
+
* injected `udp` transport only sends and receives bytes: it has no codec or
|
|
8
|
+
* crypto knowledge.
|
|
9
|
+
*
|
|
10
|
+
* Frame layout (big-endian header, see
|
|
11
|
+
* docs/issues/002-cloud-runtime/audio/wire.md "UDP audio frames"):
|
|
12
|
+
*
|
|
13
|
+
* offset 0 u32 sessionTag (routes the datagram to the right session)
|
|
14
|
+
* offset 4 u16 seq (per-session packet counter)
|
|
15
|
+
* offset 6 [24] nonce (fresh random per packet)
|
|
16
|
+
* offset 30 ... ciphertext (secretbox(payload): encrypted audio + 16-byte tag)
|
|
17
|
+
*
|
|
18
|
+
* The header is in the clear because the stateless ingress needs `sessionTag` to
|
|
19
|
+
* route the datagram before it can decrypt anything; only the audio payload is
|
|
20
|
+
* encrypted and authenticated (NaCl secretbox, XSalsa20-Poly1305).
|
|
21
|
+
*
|
|
22
|
+
* Security: the per-session key is never logged and never travels over UDP. The
|
|
23
|
+
* cloud delivers it once over the TLS WebSocket in `connection.ack.audio`.
|
|
24
|
+
*/
|
|
25
|
+
import nacl from "tweetnacl";
|
|
26
|
+
import type { UdpSocketLike } from "../../transports";
|
|
27
|
+
import { UDP_LIVENESS_PROBE_PREFIX, type ConnectionAck } from "@mentra/cloud-protocol";
|
|
28
|
+
|
|
29
|
+
/** The audio block of `connection.ack`, present only when UDP audio is offered. */
|
|
30
|
+
type AudioConfig = NonNullable<ConnectionAck["audio"]>;
|
|
31
|
+
|
|
32
|
+
/** Header byte offsets, named so the frame builder reads as the wire spec does. */
|
|
33
|
+
const SESSION_TAG_OFFSET = 0;
|
|
34
|
+
const SEQ_OFFSET = 4;
|
|
35
|
+
const NONCE_OFFSET = 6;
|
|
36
|
+
const AUDIO_PACKET_HEADER_SIZE = NONCE_OFFSET;
|
|
37
|
+
|
|
38
|
+
/** The seq field is a u16, so it wraps at 65536; the cloud expects that wrap. */
|
|
39
|
+
const SEQ_MODULO = 0x10000;
|
|
40
|
+
|
|
41
|
+
export interface UdpAudioDeps {
|
|
42
|
+
udp: () => UdpSocketLike;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RuntimeAudioTransport = "udp" | "ws" | "none";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Holds the live UDP socket plus the per-session crypto/routing material.
|
|
49
|
+
*
|
|
50
|
+
* Gathered into one object so a reconfigure (a fresh session brings a fresh key,
|
|
51
|
+
* tag, and host/port) replaces all of it atomically and an old socket cannot be
|
|
52
|
+
* sent on with a new key.
|
|
53
|
+
*/
|
|
54
|
+
interface UdpSession {
|
|
55
|
+
socket: UdpSocketLike;
|
|
56
|
+
sessionTag: number;
|
|
57
|
+
host: string;
|
|
58
|
+
port: number;
|
|
59
|
+
key: Uint8Array;
|
|
60
|
+
/** Per-session packet counter; wraps at the u16 boundary. */
|
|
61
|
+
seq: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class UdpAudio {
|
|
65
|
+
private readonly udpFactory: () => UdpSocketLike;
|
|
66
|
+
private session: UdpSession | null = null;
|
|
67
|
+
|
|
68
|
+
constructor(deps: UdpAudioDeps) {
|
|
69
|
+
this.udpFactory = deps.udp;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The audio transport currently configured for outbound frames. */
|
|
73
|
+
get transport(): RuntimeAudioTransport {
|
|
74
|
+
return this.session ? "udp" : "none";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get sessionTag(): number | null {
|
|
78
|
+
return this.session?.sessionTag ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Open the UDP socket and load the session's routing + encryption material
|
|
83
|
+
* from `connection.ack.audio`.
|
|
84
|
+
*
|
|
85
|
+
* Called once per (re)connect. If a previous session's socket is still open we
|
|
86
|
+
* close it first, so a reconnect does not leak the old socket and a stale key
|
|
87
|
+
* can never be used again. The base64 key is decoded once here, not per frame,
|
|
88
|
+
* to keep `sendFrame` allocation-light on the hot audio path.
|
|
89
|
+
*/
|
|
90
|
+
configure(audio: AudioConfig): void {
|
|
91
|
+
// Drop any prior session before swapping in the new one.
|
|
92
|
+
this.close();
|
|
93
|
+
|
|
94
|
+
this.session = {
|
|
95
|
+
socket: this.udpFactory(),
|
|
96
|
+
sessionTag: audio.sessionTag,
|
|
97
|
+
host: audio.udp.host,
|
|
98
|
+
port: audio.udp.port,
|
|
99
|
+
key: decodeBase64(audio.encryption.key),
|
|
100
|
+
seq: 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Encrypt one audio payload (LC3 frame) and send it as a UDP datagram.
|
|
106
|
+
*
|
|
107
|
+
* A fresh random 24-byte nonce is drawn per packet: secretbox is only secure
|
|
108
|
+
* when a (key, nonce) pair is never reused, and a random nonce makes a clash
|
|
109
|
+
* negligibly unlikely across a session. Dropped silently (no throw) when no
|
|
110
|
+
* session is configured, because audio frames arrive continuously and a single
|
|
111
|
+
* frame sent before/after a session is not worth crashing the caller over; the
|
|
112
|
+
* absence is observable through the missing session, not an exception per frame.
|
|
113
|
+
*/
|
|
114
|
+
sendFrame(payload: Uint8Array): boolean {
|
|
115
|
+
const session = this.session;
|
|
116
|
+
if (!session) return false;
|
|
117
|
+
|
|
118
|
+
session.socket.send(this.buildEncryptedPacket(session, payload), session.host, session.port);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
sendProbe(probeId: string): boolean {
|
|
123
|
+
return this.sendFrame(asciiBytes(`${UDP_LIVENESS_PROBE_PREFIX}${probeId}`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
buildPlainFrame(payload: Uint8Array): Uint8Array | null {
|
|
127
|
+
const session = this.session;
|
|
128
|
+
if (!session) return null;
|
|
129
|
+
return this.buildPacket(session, payload);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private buildEncryptedPacket(session: UdpSession, payload: Uint8Array): Uint8Array {
|
|
133
|
+
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
|
|
134
|
+
// secretbox returns ciphertext with the 16-byte Poly1305 tag appended.
|
|
135
|
+
const ciphertext = nacl.secretbox(payload, nonce, session.key);
|
|
136
|
+
const encrypted = new Uint8Array(nonce.byteLength + ciphertext.byteLength);
|
|
137
|
+
encrypted.set(nonce, 0);
|
|
138
|
+
encrypted.set(ciphertext, nonce.byteLength);
|
|
139
|
+
return this.buildPacket(session, encrypted);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private buildPacket(session: UdpSession, payload: Uint8Array): Uint8Array {
|
|
143
|
+
const frame = new Uint8Array(AUDIO_PACKET_HEADER_SIZE + payload.length);
|
|
144
|
+
const view = new DataView(frame.buffer);
|
|
145
|
+
|
|
146
|
+
// Header in the clear so the stateless ingress can route before decrypting.
|
|
147
|
+
view.setUint32(SESSION_TAG_OFFSET, session.sessionTag, /* littleEndian */ false);
|
|
148
|
+
view.setUint16(SEQ_OFFSET, session.seq, /* littleEndian */ false);
|
|
149
|
+
frame.set(payload, AUDIO_PACKET_HEADER_SIZE);
|
|
150
|
+
|
|
151
|
+
// Advance the per-session counter, wrapping at the u16 boundary the cloud
|
|
152
|
+
// expects, so a long session does not overflow the field.
|
|
153
|
+
session.seq = (session.seq + 1) % SEQ_MODULO;
|
|
154
|
+
return frame;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Close the UDP socket and forget the session.
|
|
159
|
+
*
|
|
160
|
+
* Clearing the session (including the key) on close means a `sendFrame` after
|
|
161
|
+
* close is a no-op rather than a send with stale material.
|
|
162
|
+
*/
|
|
163
|
+
close(): void {
|
|
164
|
+
if (!this.session) return;
|
|
165
|
+
this.session.socket.close();
|
|
166
|
+
this.session = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Decode a base64 string to bytes without assuming a platform-specific helper.
|
|
172
|
+
*
|
|
173
|
+
* `atob` exists on a modern phone (Hermes/JSC) and a modern server, so it is the
|
|
174
|
+
* one decoder that works on both without a Node `Buffer` import that would break
|
|
175
|
+
* the React Native bundle.
|
|
176
|
+
*/
|
|
177
|
+
function decodeBase64(b64: string): Uint8Array {
|
|
178
|
+
const binary = atob(b64);
|
|
179
|
+
const bytes = new Uint8Array(binary.length);
|
|
180
|
+
for (let i = 0; i < binary.length; i++) {
|
|
181
|
+
bytes[i] = binary.charCodeAt(i);
|
|
182
|
+
}
|
|
183
|
+
return bytes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function asciiBytes(text: string): Uint8Array {
|
|
187
|
+
const bytes = new Uint8Array(text.length);
|
|
188
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
189
|
+
bytes[i] = text.charCodeAt(i) & 0x7f;
|
|
190
|
+
}
|
|
191
|
+
return bytes;
|
|
192
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Camera: the managed-photo and managed-stream features, each a
|
|
3
|
+
* REST request followed by awaiting the matching WebSocket push.
|
|
4
|
+
*
|
|
5
|
+
* Both flows are client-initiated REST on the runtime domain (any pod serves
|
|
6
|
+
* them, no coupling to the audio session). The cloud is not in the image byte
|
|
7
|
+
* path: it brokers a presigned upload and notifies the phone over the WebSocket
|
|
8
|
+
* when capture completes. So each request here records a pending promise keyed by
|
|
9
|
+
* `requestId` and resolves it when the matching push arrives, or rejects it on an
|
|
10
|
+
* error push or a timeout.
|
|
11
|
+
*
|
|
12
|
+
* Only managed photo awaits a push (`photo.ready` / `photo.error`); managed
|
|
13
|
+
* stream is fully answered by the REST response, so it just returns the
|
|
14
|
+
* provisioned coordinates.
|
|
15
|
+
*
|
|
16
|
+
* See docs/issues/002-cloud-runtime/camera/spec.md and
|
|
17
|
+
* docs/issues/004-cloud-client/design.md ("Camera").
|
|
18
|
+
*/
|
|
19
|
+
import type { HttpClient } from "../../http";
|
|
20
|
+
import type {
|
|
21
|
+
CloudToClientMessage,
|
|
22
|
+
PhotoOptions,
|
|
23
|
+
StreamOptions,
|
|
24
|
+
ManagedStream,
|
|
25
|
+
StreamStatusResult,
|
|
26
|
+
} from "@mentra/cloud-protocol";
|
|
27
|
+
|
|
28
|
+
// The camera wire types are canonical in the protocol package (the cloud server
|
|
29
|
+
// uses the same ones); re-export them so a host gets them from this module.
|
|
30
|
+
export type {
|
|
31
|
+
PhotoOptions,
|
|
32
|
+
StreamOptions,
|
|
33
|
+
ManagedStream,
|
|
34
|
+
StreamStatusResult,
|
|
35
|
+
} from "@mentra/cloud-protocol";
|
|
36
|
+
|
|
37
|
+
const PHOTO_PATH = "/api/camera/photo";
|
|
38
|
+
const STREAM_PATH = "/api/camera/stream";
|
|
39
|
+
|
|
40
|
+
/** How long to wait for the completion push before failing a photo request. */
|
|
41
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
/** What `requestPhoto` resolves to once the cloud confirms the photo is ready. */
|
|
44
|
+
export interface PhotoResult {
|
|
45
|
+
requestId: string;
|
|
46
|
+
readUrl: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CameraDeps {
|
|
50
|
+
http: HttpClient;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A request that has been sent over REST and is waiting for its WebSocket push.
|
|
55
|
+
*
|
|
56
|
+
* The resolve/reject pair drives the promise the caller is awaiting; the timer is
|
|
57
|
+
* tracked so it can be cleared the moment the push lands (and so a settled
|
|
58
|
+
* request never fires a late timeout).
|
|
59
|
+
*/
|
|
60
|
+
interface Pending<T> {
|
|
61
|
+
resolve: (value: T) => void;
|
|
62
|
+
reject: (err: Error) => void;
|
|
63
|
+
timer: ReturnType<typeof setTimeout>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class Camera {
|
|
67
|
+
private readonly http: HttpClient;
|
|
68
|
+
|
|
69
|
+
/** In-flight photo requests, keyed by the `requestId` the cloud assigned. */
|
|
70
|
+
private readonly pendingPhotos = new Map<string, Pending<PhotoResult>>();
|
|
71
|
+
|
|
72
|
+
constructor(deps: CameraDeps) {
|
|
73
|
+
this.http = deps.http;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Request a managed photo and resolve once the cloud pushes `photo.ready`.
|
|
78
|
+
*
|
|
79
|
+
* The POST returns immediately with the `requestId` and the presigned
|
|
80
|
+
* `readUrl`; the actual capture happens out of band on the glasses, so we
|
|
81
|
+
* record a pending promise and wait for the `photo.ready` push (or reject on
|
|
82
|
+
* `photo.error` / timeout). We register the pending entry keyed by the returned
|
|
83
|
+
* `requestId` so a push that races ahead of our bookkeeping cannot be missed:
|
|
84
|
+
* the POST has already resolved by the time we await it, so the key exists
|
|
85
|
+
* before any push can be processed for it.
|
|
86
|
+
*/
|
|
87
|
+
async requestPhoto(opts: PhotoOptions): Promise<PhotoResult> {
|
|
88
|
+
const { requestId } = await this.startPhoto(opts);
|
|
89
|
+
return this.awaitPhotoReady(requestId);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Step 1 of the managed-photo flow: presign and return the coordinates.
|
|
94
|
+
* Hosts that act as the DEVICE side too (a phone driving glasses over BLE)
|
|
95
|
+
* need the uploadUrl to deliver the captured bytes themselves before the
|
|
96
|
+
* `photo.ready` push can fire; plain consumers can keep using
|
|
97
|
+
* {@link requestPhoto}, which composes both steps.
|
|
98
|
+
*/
|
|
99
|
+
async startPhoto(opts: PhotoOptions): Promise<{
|
|
100
|
+
requestId: string;
|
|
101
|
+
uploadUrl: string;
|
|
102
|
+
readUrl: string;
|
|
103
|
+
}> {
|
|
104
|
+
return await this.http.post<{
|
|
105
|
+
requestId: string;
|
|
106
|
+
uploadUrl: string;
|
|
107
|
+
readUrl: string;
|
|
108
|
+
}>(PHOTO_PATH, opts);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Step 2: resolve when the cloud pushes `photo.ready` for the request. */
|
|
112
|
+
awaitPhotoReady(requestId: string): Promise<PhotoResult> {
|
|
113
|
+
return new Promise<PhotoResult>((resolve, reject) => {
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
// Drop the entry first so the rejection cannot race a late push.
|
|
116
|
+
this.pendingPhotos.delete(requestId);
|
|
117
|
+
reject(new Error(`Managed photo ${requestId} timed out`));
|
|
118
|
+
}, REQUEST_TIMEOUT_MS);
|
|
119
|
+
|
|
120
|
+
this.pendingPhotos.set(requestId, { resolve, reject, timer });
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Provision a managed stream over REST.
|
|
126
|
+
*
|
|
127
|
+
* Unlike a photo, provisioning is fully answered by the REST response (ingest +
|
|
128
|
+
* playback coordinates), so there is no push to await here. The client owns the
|
|
129
|
+
* lifecycle from this point and polls/stops over REST.
|
|
130
|
+
*/
|
|
131
|
+
async startStream(opts: StreamOptions): Promise<ManagedStream> {
|
|
132
|
+
return await this.http.post<ManagedStream>(STREAM_PATH, opts);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The provider's view of a managed stream's ingest: is the device's push
|
|
137
|
+
* arriving? Clients poll this to surface what the far end of the pipe sees.
|
|
138
|
+
*/
|
|
139
|
+
async streamStatus(streamId: string): Promise<StreamStatusResult> {
|
|
140
|
+
return await this.http.get<StreamStatusResult>(
|
|
141
|
+
`${STREAM_PATH}/${encodeURIComponent(streamId)}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Stop a managed stream by id. `DELETE /api/camera/stream/:id` is the
|
|
147
|
+
* lifecycle end; the cloud tears down the provider stream.
|
|
148
|
+
*/
|
|
149
|
+
async stopStream(streamId: string): Promise<void> {
|
|
150
|
+
await this.http.delete<void>(`${STREAM_PATH}/${encodeURIComponent(streamId)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Route an inbound WebSocket push to the pending request it completes.
|
|
155
|
+
*
|
|
156
|
+
* Called for every cloud-to-client message; it only acts on photo pushes and
|
|
157
|
+
* ignores everything else, so the runtime can hand it the whole message stream
|
|
158
|
+
* without pre-filtering. A push whose `requestId` has no pending entry (a late
|
|
159
|
+
* duplicate after a timeout, say) is dropped harmlessly.
|
|
160
|
+
*/
|
|
161
|
+
handlePush(msg: CloudToClientMessage): void {
|
|
162
|
+
// photo.ready / photo.error are in the validated message union, so the
|
|
163
|
+
// discriminant narrows `msg.payload` to the typed camera payloads with no
|
|
164
|
+
// cast. Any other message type is ignored here.
|
|
165
|
+
if (msg.type === "photo.ready") {
|
|
166
|
+
const { requestId, readUrl } = msg.payload;
|
|
167
|
+
const pending = this.takePending(requestId);
|
|
168
|
+
pending?.resolve({ requestId, readUrl });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (msg.type === "photo.error") {
|
|
173
|
+
const { requestId, reason } = msg.payload;
|
|
174
|
+
const pending = this.takePending(requestId);
|
|
175
|
+
pending?.reject(new Error(`Managed photo ${requestId} failed: ${reason}`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Remove and return the pending entry for `requestId`, clearing its timeout.
|
|
181
|
+
*
|
|
182
|
+
* Pulling the entry out before settling guarantees a request settles exactly
|
|
183
|
+
* once: a duplicate push for the same id finds nothing left to settle.
|
|
184
|
+
*/
|
|
185
|
+
private takePending(requestId: string): Pending<PhotoResult> | undefined {
|
|
186
|
+
const pending = this.pendingPhotos.get(requestId);
|
|
187
|
+
if (!pending) return undefined;
|
|
188
|
+
clearTimeout(pending.timer);
|
|
189
|
+
this.pendingPhotos.delete(requestId);
|
|
190
|
+
return pending;
|
|
191
|
+
}
|
|
192
|
+
}
|