@mentra/cloud-client 0.1.0-dev.0 → 3.1.0-dev.2
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/README.md +35 -0
- package/package.json +14 -2
- package/react-native/index.ts +1 -1
- package/react-native/transports.ts +17 -2
- package/src/client.ts +28 -31
- package/src/config.ts +8 -0
- package/src/http.ts +13 -29
- package/src/index.ts +4 -0
- package/src/modules/auth/auth.ts +11 -6
- package/src/modules/core/core.ts +17 -0
- package/src/modules/core/support-profile.ts +45 -0
- package/src/modules/runtime/camera.ts +7 -3
- package/src/modules/runtime/connection.ts +64 -20
- package/src/modules/runtime/runtime.ts +19 -6
- package/src/modules/runtime/subscriptions.ts +5 -1
- package/src/timers.ts +23 -0
- package/src/transports.ts +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @mentra/cloud-client
|
|
2
|
+
|
|
3
|
+
The MentraOS cloud client: connects a device (or a test harness) to the
|
|
4
|
+
MentraOS cloud over the wire protocol defined in
|
|
5
|
+
[`@mentra/cloud-protocol`](https://www.npmjs.com/package/@mentra/cloud-protocol),
|
|
6
|
+
handling the handshake, message envelopes, and per-module message flows
|
|
7
|
+
(camera, audio, maps, reports, …).
|
|
8
|
+
|
|
9
|
+
Its main consumer is [`@mentra/engine`](https://www.npmjs.com/package/@mentra/engine),
|
|
10
|
+
which lists it as a peer dependency — an app embedding the engine installs
|
|
11
|
+
this package alongside it.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install @mentra/cloud-client@dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
> Currently published on the `dev` dist-tag (prerelease channel).
|
|
20
|
+
|
|
21
|
+
## Entry points
|
|
22
|
+
|
|
23
|
+
| Import | Use |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `@mentra/cloud-client` | platform-neutral core: `CloudClient` plus the public config, transport, and error types (wire-protocol types are deliberately *not* re-exported — import those from `@mentra/cloud-protocol`) |
|
|
26
|
+
| `@mentra/cloud-client/react-native` | React Native transport bindings (used by the engine) |
|
|
27
|
+
| `@mentra/cloud-client/node` | Node transport bindings — requires the optional [`ws`](https://www.npmjs.com/package/ws) peer (`npm i ws`) |
|
|
28
|
+
|
|
29
|
+
The package ships TypeScript source and targets consumers that compile TS
|
|
30
|
+
themselves (Metro / bundlers / tsc).
|
|
31
|
+
|
|
32
|
+
## Part of MentraOS
|
|
33
|
+
|
|
34
|
+
Source lives in the [MentraOS monorepo](https://github.com/Mentra-Community/MentraOS)
|
|
35
|
+
under `cloud-v2/packages/cloud-client`. Issues and contributions welcome there.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mentra/cloud-client",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0-dev.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"test": "bun test"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@mentra/cloud-protocol": "
|
|
15
|
+
"@mentra/cloud-protocol": "3.1.0-dev.2",
|
|
16
16
|
"tweetnacl": "^1.0.3"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
@@ -42,5 +42,17 @@
|
|
|
42
42
|
"type": "git",
|
|
43
43
|
"url": "git+https://github.com/Mentra-Community/MentraOS.git",
|
|
44
44
|
"directory": "cloud-v2/packages/cloud-client"
|
|
45
|
+
},
|
|
46
|
+
"description": "MentraOS cloud client — handshake, envelopes and module message flows over the cloud wire protocol",
|
|
47
|
+
"keywords": [
|
|
48
|
+
"mentra",
|
|
49
|
+
"mentraos",
|
|
50
|
+
"cloud",
|
|
51
|
+
"client",
|
|
52
|
+
"websocket"
|
|
53
|
+
],
|
|
54
|
+
"homepage": "https://github.com/Mentra-Community/MentraOS/tree/dev/cloud-v2/packages/cloud-client#readme",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/Mentra-Community/MentraOS/issues"
|
|
45
57
|
}
|
|
46
58
|
}
|
package/react-native/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ export class CloudClient extends Base {
|
|
|
30
30
|
|
|
31
31
|
// The host wires the phone's native UDP and secure store through these before
|
|
32
32
|
// constructing a client that uses UDP audio or token persistence.
|
|
33
|
-
export { setNativeUdp, setSecureStorage } from "./transports";
|
|
33
|
+
export { setNativeHttp, setNativeUdp, setSecureStorage } from "./transports";
|
|
34
34
|
export type { NativeUdpFactory } from "./transports";
|
|
35
35
|
|
|
36
36
|
// Re-export the public surface so a host can import everything (types, errors,
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
WebSocketLike,
|
|
27
27
|
UdpSocketLike,
|
|
28
28
|
KeyValueStore,
|
|
29
|
+
HttpTransport,
|
|
29
30
|
} from "../src/transports";
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -77,8 +78,7 @@ function adaptWebSocket(raw: GlobalWebSocket): WebSocketLike {
|
|
|
77
78
|
raw.onmessage = (ev: { data: unknown }) => cb(String(ev.data));
|
|
78
79
|
},
|
|
79
80
|
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 });
|
|
81
|
+
raw.onclose = (ev: { code: number; reason: string }) => cb({ code: ev.code, reason: ev.reason });
|
|
82
82
|
},
|
|
83
83
|
onError(cb: (err: unknown) => void): void {
|
|
84
84
|
raw.onerror = (ev: unknown) => cb(ev);
|
|
@@ -139,6 +139,20 @@ export function setNativeUdp(factory: NativeUdpFactory): void {
|
|
|
139
139
|
*/
|
|
140
140
|
let secureStorage: KeyValueStore | null = null;
|
|
141
141
|
|
|
142
|
+
let nativeHttp: HttpTransport | null = null;
|
|
143
|
+
|
|
144
|
+
/** Wire a background-capable native HTTP executor into the phone client. */
|
|
145
|
+
export function setNativeHttp(http: HttpTransport): void {
|
|
146
|
+
nativeHttp = http;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function makeHttpTransport(): HttpTransport {
|
|
150
|
+
return (input, init) => {
|
|
151
|
+
const execute = nativeHttp ?? globalThis.fetch;
|
|
152
|
+
return execute(input, init);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
142
156
|
/**
|
|
143
157
|
* Wire in the platform's secure storage.
|
|
144
158
|
*
|
|
@@ -212,5 +226,6 @@ export function reactNativeTransports(): CloudClientTransports {
|
|
|
212
226
|
ws: makeWsFactory(),
|
|
213
227
|
udp: makeUdpFactory(),
|
|
214
228
|
storage: makeSecureStorage(),
|
|
229
|
+
http: makeHttpTransport(),
|
|
215
230
|
};
|
|
216
231
|
}
|
package/src/client.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { CloudClientConfig } from "./config";
|
|
|
20
20
|
import { createHttpClient } from "./http";
|
|
21
21
|
import { CloudClientError } from "./errors";
|
|
22
22
|
import type { ConnectionInit } from "@mentra/cloud-protocol";
|
|
23
|
+
import { systemTimers } from "./timers";
|
|
23
24
|
|
|
24
25
|
// The module implementations. Each is owned by another agent under ./modules/**;
|
|
25
26
|
// this file only constructs them, matching the constructor signatures fixed in
|
|
@@ -117,18 +118,13 @@ export class CloudClient {
|
|
|
117
118
|
|
|
118
119
|
// Reconnect/backoff lives here so the socket's timing is tuned in one spot.
|
|
119
120
|
const reconnect = config.reconnect ?? DEFAULT_RECONNECT;
|
|
121
|
+
const timers = config.timers ?? systemTimers;
|
|
120
122
|
|
|
121
123
|
// Resolve the two base addresses. With a proxy set, both route through it;
|
|
122
124
|
// without one, each module talks to its own service directly.
|
|
123
125
|
const { core: coreBase, runtime: runtimeBase, proxy } = config.endpoints;
|
|
124
|
-
const coreUrl = coreBase
|
|
125
|
-
|
|
126
|
-
? rewriteThroughProxy(coreBase, proxy)
|
|
127
|
-
: coreBase
|
|
128
|
-
: undefined;
|
|
129
|
-
const runtimeUrl = proxy
|
|
130
|
-
? rewriteThroughProxy(runtimeBase, proxy)
|
|
131
|
-
: runtimeBase;
|
|
126
|
+
const coreUrl = coreBase ? (proxy ? rewriteThroughProxy(coreBase, proxy) : coreBase) : undefined;
|
|
127
|
+
const runtimeUrl = proxy ? rewriteThroughProxy(runtimeBase, proxy) : runtimeBase;
|
|
132
128
|
|
|
133
129
|
if (config.auth.core && !coreUrl) {
|
|
134
130
|
throw new CloudClientError("auth.core requires endpoints.core");
|
|
@@ -139,17 +135,12 @@ export class CloudClient {
|
|
|
139
135
|
// (`{ subjectToken, subjectTokenType }`, no `runtime`) gets a clear
|
|
140
136
|
// configuration error instead of an opaque `TypeError` from `"source" in undefined`.
|
|
141
137
|
if (!config.auth.runtime) {
|
|
142
|
-
throw new CloudClientError(
|
|
143
|
-
"auth.runtime is required (got a pre-split/flat auth config?)",
|
|
144
|
-
);
|
|
138
|
+
throw new CloudClientError("auth.runtime is required (got a pre-split/flat auth config?)");
|
|
145
139
|
}
|
|
146
140
|
|
|
147
|
-
const runtimeUsesCore =
|
|
148
|
-
"source" in config.auth.runtime && config.auth.runtime.source === "core";
|
|
141
|
+
const runtimeUsesCore = "source" in config.auth.runtime && config.auth.runtime.source === "core";
|
|
149
142
|
if (runtimeUsesCore && (!coreUrl || !config.auth.core)) {
|
|
150
|
-
throw new CloudClientError(
|
|
151
|
-
"auth.runtime.source='core' requires endpoints.core and auth.core",
|
|
152
|
-
);
|
|
143
|
+
throw new CloudClientError("auth.runtime.source='core' requires endpoints.core and auth.core");
|
|
153
144
|
}
|
|
154
145
|
|
|
155
146
|
// Build auth FIRST: runtime and core both source their Bearer from it, so it
|
|
@@ -160,7 +151,7 @@ export class CloudClient {
|
|
|
160
151
|
// before any access token exists. It is deliberately Core-only: runtime-only
|
|
161
152
|
// clients never get a fallback that points Core/Auth calls at Runtime.
|
|
162
153
|
const authHttp = coreUrl
|
|
163
|
-
? createHttpClient({ baseUrl: coreUrl, logger })
|
|
154
|
+
? createHttpClient({ baseUrl: coreUrl, logger, fetch: config.transports.http, timers })
|
|
164
155
|
: undefined;
|
|
165
156
|
const store = new TokenStore({ storage: config.transports.storage });
|
|
166
157
|
const auth = new Auth({
|
|
@@ -168,29 +159,35 @@ export class CloudClient {
|
|
|
168
159
|
store,
|
|
169
160
|
config: config.auth,
|
|
170
161
|
logger,
|
|
171
|
-
//
|
|
172
|
-
//
|
|
162
|
+
// Form-encoded `/exchange` and `/refresh` calls are not JSON HttpClient
|
|
163
|
+
// requests, but still use the host's injected HTTP transport.
|
|
173
164
|
baseUrl: coreUrl,
|
|
165
|
+
fetch: config.transports.http,
|
|
174
166
|
});
|
|
175
167
|
|
|
176
168
|
const getRuntimeToken = (): Promise<string> => auth.getRuntimeToken();
|
|
177
169
|
const getCoreToken = (): Promise<string> => auth.getCoreToken();
|
|
178
170
|
|
|
179
|
-
const coreHttp =
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
171
|
+
const coreHttp =
|
|
172
|
+
coreUrl && config.auth.core
|
|
173
|
+
? createHttpClient({
|
|
174
|
+
baseUrl: coreUrl,
|
|
175
|
+
getToken: getCoreToken,
|
|
176
|
+
logger,
|
|
177
|
+
fetch: config.transports.http,
|
|
178
|
+
timers,
|
|
179
|
+
})
|
|
180
|
+
: null;
|
|
186
181
|
const runtimeHttp = createHttpClient({
|
|
187
182
|
baseUrl: runtimeUrl,
|
|
188
183
|
getToken: getRuntimeToken,
|
|
189
184
|
logger,
|
|
185
|
+
fetch: config.transports.http,
|
|
186
|
+
timers,
|
|
190
187
|
});
|
|
191
188
|
|
|
192
189
|
const emitter = new RuntimeEmitter();
|
|
193
|
-
const subscriptions = new Subscriptions({ http: runtimeHttp });
|
|
190
|
+
const subscriptions = new Subscriptions({ http: runtimeHttp, timers });
|
|
194
191
|
|
|
195
192
|
// The handshake payload the connection sends on every (re)open. It is a
|
|
196
193
|
// factory (not a fixed value) so each reconnect re-reads the current defaults
|
|
@@ -216,9 +213,7 @@ export class CloudClient {
|
|
|
216
213
|
sampleRate: config.audio?.sampleRate ?? DEFAULT_AUDIO_SAMPLE_RATE,
|
|
217
214
|
// Only LC3 carries a frame size; the config type forces LC3 hosts to
|
|
218
215
|
// state theirs explicitly (decoder is sized from this — no safe guess).
|
|
219
|
-
...(config.audio?.codec === "lc3"
|
|
220
|
-
? { frameSizeBytes: config.audio.frameSizeBytes }
|
|
221
|
-
: {}),
|
|
216
|
+
...(config.audio?.codec === "lc3" ? { frameSizeBytes: config.audio.frameSizeBytes } : {}),
|
|
222
217
|
initialSubscriptions: subscriptions.currentSet(),
|
|
223
218
|
},
|
|
224
219
|
});
|
|
@@ -230,12 +225,13 @@ export class CloudClient {
|
|
|
230
225
|
getToken: getRuntimeToken,
|
|
231
226
|
initPayload,
|
|
232
227
|
reconnect,
|
|
228
|
+
timers,
|
|
233
229
|
onAuthRejected: async () => {
|
|
234
230
|
await auth.getRuntimeToken({ forceRefresh: true });
|
|
235
231
|
},
|
|
236
232
|
logger,
|
|
237
233
|
});
|
|
238
|
-
const camera = new Camera({ http: runtimeHttp });
|
|
234
|
+
const camera = new Camera({ http: runtimeHttp, timers });
|
|
239
235
|
const tts = new Tts({ http: runtimeHttp });
|
|
240
236
|
const maps = new Maps({ http: runtimeHttp });
|
|
241
237
|
const audio = new UdpAudio({ udp: config.transports.udp });
|
|
@@ -248,6 +244,7 @@ export class CloudClient {
|
|
|
248
244
|
tts,
|
|
249
245
|
maps,
|
|
250
246
|
audio,
|
|
247
|
+
timers,
|
|
251
248
|
logger,
|
|
252
249
|
// On a fatal AUTH_EXPIRED at handshake, runtime forces auth to drop its
|
|
253
250
|
// cached access token and refresh; the connection then re-reads the fresh
|
package/src/config.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { Logger } from "./logger";
|
|
11
11
|
import type { CloudClientTransports } from "./transports";
|
|
12
|
+
import type { CloudClientTimers } from "./timers";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The full shape passed to the root `CloudClient`.
|
|
@@ -25,6 +26,13 @@ export interface CloudClientConfig {
|
|
|
25
26
|
endpoints: { core?: string; runtime: string; proxy?: string };
|
|
26
27
|
auth: AuthConfig;
|
|
27
28
|
transports: CloudClientTransports;
|
|
29
|
+
/**
|
|
30
|
+
* Scheduler for all delayed client work, including connection/audio
|
|
31
|
+
* liveness, retries, and request timeouts. React Native hosts must provide
|
|
32
|
+
* native background timers because ordinary JS timers pause when Android
|
|
33
|
+
* backgrounds the app.
|
|
34
|
+
*/
|
|
35
|
+
timers?: CloudClientTimers;
|
|
28
36
|
logger?: Logger;
|
|
29
37
|
// backoff tuning for the live socket; one place so a host can match its fleet
|
|
30
38
|
reconnect?: { baseMs: number; maxMs: number; jitter: boolean };
|
package/src/http.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { HttpError } from "./errors";
|
|
16
16
|
import type { Logger } from "./logger";
|
|
17
|
+
import { systemTimers, type CloudClientTimers } from "./timers";
|
|
18
|
+
import type { HttpTransport } from "./transports";
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Per-request options.
|
|
@@ -56,6 +58,8 @@ export interface CreateHttpClientDeps {
|
|
|
56
58
|
// default Bearer source, usually cloud.auth.getRuntimeToken/getCoreToken
|
|
57
59
|
getToken?: () => Promise<string>;
|
|
58
60
|
logger: Logger;
|
|
61
|
+
fetch?: HttpTransport;
|
|
62
|
+
timers?: CloudClientTimers;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
/** How many times to retry a transient failure on an idempotent call. */
|
|
@@ -63,11 +67,6 @@ const MAX_RETRIES = 2;
|
|
|
63
67
|
/** Base backoff in milliseconds; doubles per attempt (250, 500, ...). */
|
|
64
68
|
const RETRY_BASE_MS = 250;
|
|
65
69
|
|
|
66
|
-
/** Resolve after `ms`, used for retry backoff. */
|
|
67
|
-
function delay(ms: number): Promise<void> {
|
|
68
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
70
|
/**
|
|
72
71
|
* Join a base URL and a path without producing a double slash or dropping one.
|
|
73
72
|
*
|
|
@@ -82,6 +81,8 @@ function joinUrl(baseUrl: string, path: string): string {
|
|
|
82
81
|
|
|
83
82
|
export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
84
83
|
const { baseUrl, getToken, logger } = deps;
|
|
84
|
+
const executeFetch = deps.fetch ?? globalThis.fetch;
|
|
85
|
+
const timers = deps.timers ?? systemTimers;
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
88
|
* Resolve the Bearer to attach: a per-call override wins, otherwise the
|
|
@@ -119,12 +120,12 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
|
119
120
|
if (attempt > 0) {
|
|
120
121
|
const backoff = RETRY_BASE_MS * 2 ** (attempt - 1);
|
|
121
122
|
logger.debug("http retrying request", { method, path, attempt });
|
|
122
|
-
await
|
|
123
|
+
await new Promise<void>((resolve) => timers.setTimeout(resolve, backoff));
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
let res: Response;
|
|
126
127
|
try {
|
|
127
|
-
res = await
|
|
128
|
+
res = await executeFetch(url, { method, headers, body });
|
|
128
129
|
} catch {
|
|
129
130
|
// Transient network failure: let the loop retry.
|
|
130
131
|
logger.warn("http network error", { method, path, attempt });
|
|
@@ -140,11 +141,7 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
// Exhausted retries on a transient failure.
|
|
143
|
-
throw new HttpError(
|
|
144
|
-
`Network request failed: ${method} ${path}`,
|
|
145
|
-
0,
|
|
146
|
-
"NETWORK_ERROR",
|
|
147
|
-
);
|
|
144
|
+
throw new HttpError(`Network request failed: ${method} ${path}`, 0, "NETWORK_ERROR");
|
|
148
145
|
}
|
|
149
146
|
|
|
150
147
|
async function requestRaw(
|
|
@@ -166,8 +163,7 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
|
166
163
|
|
|
167
164
|
// GET and DELETE are idempotent by HTTP semantics, so safe to retry; other
|
|
168
165
|
// verbs opt in via the flag.
|
|
169
|
-
const idempotent =
|
|
170
|
-
opts?.idempotent ?? (method === "GET" || method === "DELETE" || method === "HEAD");
|
|
166
|
+
const idempotent = opts?.idempotent ?? (method === "GET" || method === "DELETE" || method === "HEAD");
|
|
171
167
|
|
|
172
168
|
return await fetchWithRetry({ method, path, headers, body: payload, idempotent });
|
|
173
169
|
}
|
|
@@ -182,11 +178,7 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
|
182
178
|
return await parseJson<T>(res);
|
|
183
179
|
}
|
|
184
180
|
|
|
185
|
-
async function requestForm<T>(
|
|
186
|
-
path: string,
|
|
187
|
-
form: FormData,
|
|
188
|
-
opts?: ReqOpts,
|
|
189
|
-
): Promise<T> {
|
|
181
|
+
async function requestForm<T>(path: string, form: FormData, opts?: ReqOpts): Promise<T> {
|
|
190
182
|
const bearer = await resolveBearer(opts);
|
|
191
183
|
|
|
192
184
|
// No Content-Type here: fetch/FormData must generate the multipart
|
|
@@ -210,11 +202,7 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
|
210
202
|
* parse failure here because the status is the load-bearing signal and we do
|
|
211
203
|
* not want a malformed error body to mask the real status.
|
|
212
204
|
*/
|
|
213
|
-
async function toHttpError(
|
|
214
|
-
res: Response,
|
|
215
|
-
method: string,
|
|
216
|
-
path: string,
|
|
217
|
-
): Promise<HttpError> {
|
|
205
|
+
async function toHttpError(res: Response, method: string, path: string): Promise<HttpError> {
|
|
218
206
|
let code: string | undefined;
|
|
219
207
|
let detail = "";
|
|
220
208
|
try {
|
|
@@ -230,11 +218,7 @@ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
|
230
218
|
} catch {
|
|
231
219
|
// No JSON body, or unparseable: fall back to status alone.
|
|
232
220
|
}
|
|
233
|
-
return new HttpError(
|
|
234
|
-
`HTTP ${res.status} on ${method} ${path}${detail}`,
|
|
235
|
-
res.status,
|
|
236
|
-
code,
|
|
237
|
-
);
|
|
221
|
+
return new HttpError(`HTTP ${res.status} on ${method} ${path}${detail}`, res.status, code);
|
|
238
222
|
}
|
|
239
223
|
|
|
240
224
|
/**
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type {
|
|
|
32
32
|
UdpSocketLike,
|
|
33
33
|
KeyValueStore,
|
|
34
34
|
} from "./transports";
|
|
35
|
+
export type { CloudClientTimers } from "./timers";
|
|
35
36
|
|
|
36
37
|
// Local error types a host can branch on with `instanceof`.
|
|
37
38
|
export { CloudClientError, HttpError, AuthExpiredError } from "./errors";
|
|
@@ -67,4 +68,7 @@ export type {
|
|
|
67
68
|
ReportTrigger,
|
|
68
69
|
SubmitReportInput,
|
|
69
70
|
SubmitReportResult,
|
|
71
|
+
SupportConnectionState,
|
|
72
|
+
SupportProfileUpdateResult,
|
|
73
|
+
SupportStateInput,
|
|
70
74
|
} from "./modules/core/core";
|
package/src/modules/auth/auth.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
} from "../../config";
|
|
34
34
|
import type { HttpClient } from "../../http";
|
|
35
35
|
import type { Logger } from "../../logger";
|
|
36
|
+
import type { HttpTransport } from "../../transports";
|
|
36
37
|
import { AuthExpiredError } from "../../errors";
|
|
37
38
|
import { decodeClaims } from "./jwt";
|
|
38
39
|
import { TokenStore } from "./token-store";
|
|
@@ -133,12 +134,14 @@ export class Auth implements AuthModule {
|
|
|
133
134
|
* Core base URL for the form-encoded `/exchange` and `/refresh` calls.
|
|
134
135
|
*
|
|
135
136
|
* These two endpoints take `application/x-www-form-urlencoded` bodies and
|
|
136
|
-
* present the subject/refresh token in the body (not as a Bearer), so they
|
|
137
|
-
*
|
|
138
|
-
* runtime-only mode this is absent by
|
|
139
|
-
* minting, and miniapp auto-auth are
|
|
137
|
+
* present the subject/refresh token in the body (not as a Bearer), so they do
|
|
138
|
+
* not use the JSON-only `HttpClient`. They still use the host's injected HTTP
|
|
139
|
+
* transport when one is available. In runtime-only mode this is absent by
|
|
140
|
+
* design: Core identity, miniapp token minting, and miniapp auto-auth are
|
|
141
|
+
* Core-backed features.
|
|
140
142
|
*/
|
|
141
143
|
private readonly baseUrl?: string;
|
|
144
|
+
private readonly httpTransport: HttpTransport;
|
|
142
145
|
|
|
143
146
|
/** Miniapp tokens cached per packageName until near expiry. */
|
|
144
147
|
private readonly miniappCache = new Map<string, MiniappTokenEntry>();
|
|
@@ -160,6 +163,7 @@ export class Auth implements AuthModule {
|
|
|
160
163
|
config: AuthConfig;
|
|
161
164
|
logger: Logger;
|
|
162
165
|
baseUrl?: string;
|
|
166
|
+
fetch?: HttpTransport;
|
|
163
167
|
}) {
|
|
164
168
|
this.http = deps.http;
|
|
165
169
|
this.store = deps.store;
|
|
@@ -167,6 +171,7 @@ export class Auth implements AuthModule {
|
|
|
167
171
|
this.runtimeConfig = deps.config.runtime;
|
|
168
172
|
this.logger = deps.logger;
|
|
169
173
|
this.baseUrl = deps.baseUrl;
|
|
174
|
+
this.httpTransport = deps.fetch ?? globalThis.fetch;
|
|
170
175
|
}
|
|
171
176
|
|
|
172
177
|
/**
|
|
@@ -481,7 +486,7 @@ export class Auth implements AuthModule {
|
|
|
481
486
|
* POST a form-encoded body to a token endpoint and parse the RFC token
|
|
482
487
|
* response.
|
|
483
488
|
*
|
|
484
|
-
* Uses
|
|
489
|
+
* Uses the injected HTTP transport (not the JSON `HttpClient`) because these
|
|
485
490
|
* endpoints require `application/x-www-form-urlencoded` and present the
|
|
486
491
|
* subject/refresh token in the body, not as a Bearer header. We never log the
|
|
487
492
|
* body: it carries a token.
|
|
@@ -492,7 +497,7 @@ export class Auth implements AuthModule {
|
|
|
492
497
|
label: string,
|
|
493
498
|
): Promise<TokenResponse> {
|
|
494
499
|
const url = this.joinUrl(path);
|
|
495
|
-
const res = await
|
|
500
|
+
const res = await this.httpTransport(url, {
|
|
496
501
|
method: "POST",
|
|
497
502
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
498
503
|
body: body.toString(),
|
package/src/modules/core/core.ts
CHANGED
|
@@ -26,6 +26,11 @@ import {
|
|
|
26
26
|
type SubmitReportInput,
|
|
27
27
|
type SubmitReportResult,
|
|
28
28
|
} from "./reports";
|
|
29
|
+
import {
|
|
30
|
+
SupportProfiles,
|
|
31
|
+
type SupportProfileUpdateResult,
|
|
32
|
+
type SupportStateInput,
|
|
33
|
+
} from "./support-profile";
|
|
29
34
|
|
|
30
35
|
export type {
|
|
31
36
|
AddReportArtifactsResult,
|
|
@@ -40,6 +45,11 @@ export type {
|
|
|
40
45
|
SubmitReportInput,
|
|
41
46
|
SubmitReportResult,
|
|
42
47
|
} from "./reports";
|
|
48
|
+
export type {
|
|
49
|
+
SupportConnectionState,
|
|
50
|
+
SupportProfileUpdateResult,
|
|
51
|
+
SupportStateInput,
|
|
52
|
+
} from "./support-profile";
|
|
43
53
|
|
|
44
54
|
/**
|
|
45
55
|
* A single miniapp entry as returned by the listing.
|
|
@@ -145,10 +155,14 @@ export class Core {
|
|
|
145
155
|
): Promise<AddReportArtifactsResult>;
|
|
146
156
|
complete(reportId: string): Promise<{ status: ReportStatus }>;
|
|
147
157
|
};
|
|
158
|
+
readonly supportProfile: {
|
|
159
|
+
update(input: SupportStateInput): Promise<SupportProfileUpdateResult>;
|
|
160
|
+
};
|
|
148
161
|
|
|
149
162
|
constructor(deps: CoreDeps) {
|
|
150
163
|
const { http } = deps;
|
|
151
164
|
const reports = new Reports({ http });
|
|
165
|
+
const supportProfiles = new SupportProfiles(http);
|
|
152
166
|
|
|
153
167
|
this.miniapps = {
|
|
154
168
|
/**
|
|
@@ -198,5 +212,8 @@ export class Core {
|
|
|
198
212
|
addScreenshots: reports.addScreenshots.bind(reports),
|
|
199
213
|
complete: reports.complete.bind(reports),
|
|
200
214
|
};
|
|
215
|
+
this.supportProfile = {
|
|
216
|
+
update: supportProfiles.update.bind(supportProfiles),
|
|
217
|
+
};
|
|
201
218
|
}
|
|
202
219
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type {HttpClient} from "../../http"
|
|
2
|
+
|
|
3
|
+
export type SupportConnectionState = "disconnected" | "scanning" | "connecting" | "bonding" | "connected"
|
|
4
|
+
|
|
5
|
+
export interface SupportStateInput {
|
|
6
|
+
observedAt: string
|
|
7
|
+
host: {
|
|
8
|
+
appVersion?: string
|
|
9
|
+
appBuild?: string
|
|
10
|
+
engineVersion?: string
|
|
11
|
+
bluetoothSdkVersion?: string
|
|
12
|
+
phonePlatform: "ios" | "android" | "unknown"
|
|
13
|
+
phoneModel?: string
|
|
14
|
+
phoneOsVersion?: string
|
|
15
|
+
connectionState: SupportConnectionState
|
|
16
|
+
failureCode?: string
|
|
17
|
+
failureStage?: string
|
|
18
|
+
}
|
|
19
|
+
device?: {
|
|
20
|
+
/** Sent over TLS for server-side HMAC derivation, then immediately discarded. */
|
|
21
|
+
hardwareId?: string
|
|
22
|
+
model?: string
|
|
23
|
+
androidVersion?: string
|
|
24
|
+
firmwareVersion?: string
|
|
25
|
+
mtkFirmwareVersion?: string
|
|
26
|
+
besFirmwareVersion?: string
|
|
27
|
+
appVersion?: string
|
|
28
|
+
buildNumber?: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SupportProfileUpdateResult {
|
|
33
|
+
status: "accepted" | "deduplicated" | "stale"
|
|
34
|
+
observedAt: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class SupportProfiles {
|
|
38
|
+
constructor(private readonly http: HttpClient) {}
|
|
39
|
+
|
|
40
|
+
update(input: SupportStateInput): Promise<SupportProfileUpdateResult> {
|
|
41
|
+
return this.http.put<SupportProfileUpdateResult>("/api/client/support-profile", input, {
|
|
42
|
+
idempotent: true,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* docs/issues/004-cloud-client/design.md ("Camera").
|
|
18
18
|
*/
|
|
19
19
|
import type { HttpClient } from "../../http";
|
|
20
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
20
21
|
import type {
|
|
21
22
|
CloudToClientMessage,
|
|
22
23
|
PhotoOptions,
|
|
@@ -48,6 +49,7 @@ export interface PhotoResult {
|
|
|
48
49
|
|
|
49
50
|
export interface CameraDeps {
|
|
50
51
|
http: HttpClient;
|
|
52
|
+
timers?: CloudClientTimers;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/**
|
|
@@ -60,17 +62,19 @@ export interface CameraDeps {
|
|
|
60
62
|
interface Pending<T> {
|
|
61
63
|
resolve: (value: T) => void;
|
|
62
64
|
reject: (err: Error) => void;
|
|
63
|
-
timer:
|
|
65
|
+
timer: unknown;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
export class Camera {
|
|
67
69
|
private readonly http: HttpClient;
|
|
70
|
+
private readonly timers: CloudClientTimers;
|
|
68
71
|
|
|
69
72
|
/** In-flight photo requests, keyed by the `requestId` the cloud assigned. */
|
|
70
73
|
private readonly pendingPhotos = new Map<string, Pending<PhotoResult>>();
|
|
71
74
|
|
|
72
75
|
constructor(deps: CameraDeps) {
|
|
73
76
|
this.http = deps.http;
|
|
77
|
+
this.timers = deps.timers ?? systemTimers;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
@@ -111,7 +115,7 @@ export class Camera {
|
|
|
111
115
|
/** Step 2: resolve when the cloud pushes `photo.ready` for the request. */
|
|
112
116
|
awaitPhotoReady(requestId: string): Promise<PhotoResult> {
|
|
113
117
|
return new Promise<PhotoResult>((resolve, reject) => {
|
|
114
|
-
const timer = setTimeout(() => {
|
|
118
|
+
const timer = this.timers.setTimeout(() => {
|
|
115
119
|
// Drop the entry first so the rejection cannot race a late push.
|
|
116
120
|
this.pendingPhotos.delete(requestId);
|
|
117
121
|
reject(new Error(`Managed photo ${requestId} timed out`));
|
|
@@ -185,7 +189,7 @@ export class Camera {
|
|
|
185
189
|
private takePending(requestId: string): Pending<PhotoResult> | undefined {
|
|
186
190
|
const pending = this.pendingPhotos.get(requestId);
|
|
187
191
|
if (!pending) return undefined;
|
|
188
|
-
clearTimeout(pending.timer);
|
|
192
|
+
this.timers.clearTimeout(pending.timer);
|
|
189
193
|
this.pendingPhotos.delete(requestId);
|
|
190
194
|
return pending;
|
|
191
195
|
}
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
} from "@mentra/cloud-protocol";
|
|
51
51
|
import type { WebSocketLike } from "../../transports";
|
|
52
52
|
import type { Logger } from "../../logger";
|
|
53
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
53
54
|
|
|
54
55
|
/** Connection lifecycle, surfaced to the rest of runtime via `onState`. */
|
|
55
56
|
export type ConnectionState = "connecting" | "open" | "closed";
|
|
@@ -117,6 +118,7 @@ export interface ConnectionDeps {
|
|
|
117
118
|
// what rides in the handshake beyond the token it stamps in.
|
|
118
119
|
initPayload: () => ConnectionInit;
|
|
119
120
|
reconnect: { baseMs: number; maxMs: number; jitter: boolean };
|
|
121
|
+
timers?: CloudClientTimers;
|
|
120
122
|
// Called when the WebSocket upgrade itself is rejected as unauthorized. That
|
|
121
123
|
// happens before a protocol `error` frame can exist, so runtime's normal
|
|
122
124
|
// AUTH_EXPIRED handshake retry cannot see it. The host uses this to invalidate
|
|
@@ -164,8 +166,13 @@ function isUnauthorizedUpgrade(reason: string): boolean {
|
|
|
164
166
|
return /\b401\b|unauthorized/i.test(reason);
|
|
165
167
|
}
|
|
166
168
|
|
|
169
|
+
function isSupersededByNewerSession(reason: string): boolean {
|
|
170
|
+
return /superseded by newer session/i.test(reason);
|
|
171
|
+
}
|
|
172
|
+
|
|
167
173
|
export class Connection {
|
|
168
174
|
private readonly deps: ConnectionDeps;
|
|
175
|
+
private readonly timers: CloudClientTimers;
|
|
169
176
|
|
|
170
177
|
// The current socket. Null between connect attempts and after close, so every
|
|
171
178
|
// access guards on it rather than assuming a live socket.
|
|
@@ -187,6 +194,11 @@ export class Connection {
|
|
|
187
194
|
// stop" from "the network dropped".
|
|
188
195
|
private closedByHost = false;
|
|
189
196
|
|
|
197
|
+
// True when the cloud closed us with 1012 "superseded by newer session".
|
|
198
|
+
// Another live socket for this user won; reconnecting would just kill it
|
|
199
|
+
// and start a two-client fight. Stays down until the host calls open().
|
|
200
|
+
private replacedByNewerSession = false;
|
|
201
|
+
|
|
190
202
|
// How many consecutive failed (re)connect attempts, used to grow the backoff.
|
|
191
203
|
// Reset to zero on a successful handshake.
|
|
192
204
|
private reconnectAttempt = 0;
|
|
@@ -196,12 +208,12 @@ export class Connection {
|
|
|
196
208
|
// already armed is a no-op, so the (intentional) double call from `onClose`
|
|
197
209
|
// and from a failed attempt's catch-reschedule can never stack two timers and
|
|
198
210
|
// double the reconnect rate.
|
|
199
|
-
private reconnectTimer:
|
|
211
|
+
private reconnectTimer: unknown | null = null;
|
|
200
212
|
|
|
201
213
|
// The watchdog interval (started in open(), cleared in close()). It is the
|
|
202
214
|
// backstop that revives the reconnect loop if it ever stalls: see
|
|
203
215
|
// `tickWatchdog` and the file header for why a stall is possible at all.
|
|
204
|
-
private watchdogTimer:
|
|
216
|
+
private watchdogTimer: unknown | null = null;
|
|
205
217
|
|
|
206
218
|
// Mirror of the last state pushed through `setState`, so the watchdog can ask
|
|
207
219
|
// "are we currently closed?" without a separate subscription. The state
|
|
@@ -211,8 +223,8 @@ export class Connection {
|
|
|
211
223
|
|
|
212
224
|
// Liveness timers. The interval drives the periodic ping; the pong-wait timer
|
|
213
225
|
// is armed when a ping goes out and disarmed when its pong arrives.
|
|
214
|
-
private pingTimer:
|
|
215
|
-
private pongTimer:
|
|
226
|
+
private pingTimer: unknown | null = null;
|
|
227
|
+
private pongTimer: unknown | null = null;
|
|
216
228
|
|
|
217
229
|
// While `open()` is awaiting `connection.ack`, these settle that promise. They
|
|
218
230
|
// are cleared the moment the handshake resolves, rejects, or times out, so a
|
|
@@ -220,11 +232,12 @@ export class Connection {
|
|
|
220
232
|
private pendingAck: {
|
|
221
233
|
resolve: (ack: ConnectionAck) => void;
|
|
222
234
|
reject: (err: Error) => void;
|
|
223
|
-
timer:
|
|
235
|
+
timer: unknown;
|
|
224
236
|
} | null = null;
|
|
225
237
|
|
|
226
238
|
constructor(deps: ConnectionDeps) {
|
|
227
239
|
this.deps = deps;
|
|
240
|
+
this.timers = deps.timers ?? systemTimers;
|
|
228
241
|
}
|
|
229
242
|
|
|
230
243
|
/**
|
|
@@ -237,6 +250,11 @@ export class Connection {
|
|
|
237
250
|
return this.currentAck;
|
|
238
251
|
}
|
|
239
252
|
|
|
253
|
+
/** True after the cloud closed us as the losing socket for this user. */
|
|
254
|
+
get isReplacedByNewerSession(): boolean {
|
|
255
|
+
return this.replacedByNewerSession;
|
|
256
|
+
}
|
|
257
|
+
|
|
240
258
|
/**
|
|
241
259
|
* Open the socket, run the handshake, and resolve with `connection.ack`.
|
|
242
260
|
*
|
|
@@ -248,8 +266,9 @@ export class Connection {
|
|
|
248
266
|
*/
|
|
249
267
|
async open(): Promise<ConnectionAck> {
|
|
250
268
|
// A fresh open() means the host wants the socket up; clear any prior
|
|
251
|
-
// host-close intent so a later drop reconnects normally.
|
|
269
|
+
// host-close / superseded intent so a later drop reconnects normally.
|
|
252
270
|
this.closedByHost = false;
|
|
271
|
+
this.replacedByNewerSession = false;
|
|
253
272
|
|
|
254
273
|
// A fresh open() is a clean slate: cancel any stale reconnect left over from
|
|
255
274
|
// a previous session, and reset the backoff so we do not start this connect
|
|
@@ -277,6 +296,7 @@ export class Connection {
|
|
|
277
296
|
*/
|
|
278
297
|
close(): void {
|
|
279
298
|
this.closedByHost = true;
|
|
299
|
+
this.replacedByNewerSession = false;
|
|
280
300
|
this.stopLiveness();
|
|
281
301
|
// Cancel any queued reconnect and stop the watchdog: the host wants us down,
|
|
282
302
|
// so neither the loop nor its backstop should bring the socket back up.
|
|
@@ -370,7 +390,7 @@ export class Connection {
|
|
|
370
390
|
// The promise the handshake settles. Wired before the socket can fire any
|
|
371
391
|
// callback so an immediate open/message cannot race ahead of the listener.
|
|
372
392
|
const acked = new Promise<ConnectionAck>((resolve, reject) => {
|
|
373
|
-
const timer = setTimeout(() => {
|
|
393
|
+
const timer = this.timers.setTimeout(() => {
|
|
374
394
|
this.pendingAck = null;
|
|
375
395
|
// Close the half-done socket too: a handshake that times out client-
|
|
376
396
|
// side may still COMPLETE server-side moments later, leaving a ghost
|
|
@@ -501,7 +521,7 @@ export class Connection {
|
|
|
501
521
|
this.reconnectAttempt = 0;
|
|
502
522
|
|
|
503
523
|
if (this.pendingAck) {
|
|
504
|
-
clearTimeout(this.pendingAck.timer);
|
|
524
|
+
this.timers.clearTimeout(this.pendingAck.timer);
|
|
505
525
|
const { resolve } = this.pendingAck;
|
|
506
526
|
this.pendingAck = null;
|
|
507
527
|
resolve(ack);
|
|
@@ -509,6 +529,9 @@ export class Connection {
|
|
|
509
529
|
|
|
510
530
|
this.setState("open");
|
|
511
531
|
this.startLiveness();
|
|
532
|
+
this.deps.logger.debug("ws-session-debug handshake ok", {
|
|
533
|
+
sessionId: ack.sessionId,
|
|
534
|
+
});
|
|
512
535
|
}
|
|
513
536
|
|
|
514
537
|
/**
|
|
@@ -539,6 +562,20 @@ export class Connection {
|
|
|
539
562
|
this.setState("closed");
|
|
540
563
|
|
|
541
564
|
const reason = info.reason || `code ${info.code}`;
|
|
565
|
+
if (isSupersededByNewerSession(reason)) {
|
|
566
|
+
// Newest-wins on the server. Coming back up here would supersede the
|
|
567
|
+
// winner, which then reconnects, forever. Stay down until open().
|
|
568
|
+
this.replacedByNewerSession = true;
|
|
569
|
+
this.clearReconnectTimer();
|
|
570
|
+
this.stopWatchdog();
|
|
571
|
+
this.deps.logger.info("ws-session-debug staying down after supersede", {
|
|
572
|
+
code: info.code,
|
|
573
|
+
reason,
|
|
574
|
+
sessionId: this.currentAck?.sessionId ?? null,
|
|
575
|
+
reconnectAttempt: this.reconnectAttempt,
|
|
576
|
+
});
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
542
579
|
if (isUnauthorizedUpgrade(reason)) {
|
|
543
580
|
void Promise.resolve(this.deps.onAuthRejected?.())
|
|
544
581
|
.catch((err) => {
|
|
@@ -572,6 +609,12 @@ export class Connection {
|
|
|
572
609
|
// Already a retry queued -> nothing to do. This is the guard that makes the
|
|
573
610
|
// double call from onClose + catch-reschedule (and the watchdog) harmless.
|
|
574
611
|
if (this.reconnectTimer !== null) return;
|
|
612
|
+
if (this.replacedByNewerSession) {
|
|
613
|
+
this.deps.logger.debug("ws-session-debug skip reconnect; replaced by newer session", {
|
|
614
|
+
reason,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
575
618
|
|
|
576
619
|
const delay = backoffDelay(this.reconnectAttempt, this.deps.reconnect);
|
|
577
620
|
this.reconnectAttempt += 1;
|
|
@@ -581,7 +624,7 @@ export class Connection {
|
|
|
581
624
|
reason,
|
|
582
625
|
});
|
|
583
626
|
|
|
584
|
-
this.reconnectTimer = setTimeout(() => {
|
|
627
|
+
this.reconnectTimer = this.timers.setTimeout(() => {
|
|
585
628
|
// Null the timer first so this slot is free again: a connect that fails
|
|
586
629
|
// below must be able to schedule the next retry, and the watchdog must see
|
|
587
630
|
// "no reconnect pending" while the attempt is in flight.
|
|
@@ -608,7 +651,7 @@ export class Connection {
|
|
|
608
651
|
/** Cancel a queued reconnect, if any. Safe to call when none is pending. */
|
|
609
652
|
private clearReconnectTimer(): void {
|
|
610
653
|
if (this.reconnectTimer !== null) {
|
|
611
|
-
clearTimeout(this.reconnectTimer);
|
|
654
|
+
this.timers.clearTimeout(this.reconnectTimer);
|
|
612
655
|
this.reconnectTimer = null;
|
|
613
656
|
}
|
|
614
657
|
}
|
|
@@ -620,7 +663,7 @@ export class Connection {
|
|
|
620
663
|
*/
|
|
621
664
|
private startWatchdog(): void {
|
|
622
665
|
this.stopWatchdog();
|
|
623
|
-
this.watchdogTimer = setInterval(
|
|
666
|
+
this.watchdogTimer = this.timers.setInterval(
|
|
624
667
|
() => this.tickWatchdog(),
|
|
625
668
|
RECONNECT_WATCHDOG_MS,
|
|
626
669
|
);
|
|
@@ -629,7 +672,7 @@ export class Connection {
|
|
|
629
672
|
/** Stop the reconnect watchdog (on host close). */
|
|
630
673
|
private stopWatchdog(): void {
|
|
631
674
|
if (this.watchdogTimer !== null) {
|
|
632
|
-
clearInterval(this.watchdogTimer);
|
|
675
|
+
this.timers.clearInterval(this.watchdogTimer);
|
|
633
676
|
this.watchdogTimer = null;
|
|
634
677
|
}
|
|
635
678
|
}
|
|
@@ -649,6 +692,7 @@ export class Connection {
|
|
|
649
692
|
if (
|
|
650
693
|
this.currentState === "closed" &&
|
|
651
694
|
!this.closedByHost &&
|
|
695
|
+
!this.replacedByNewerSession &&
|
|
652
696
|
this.reconnectTimer === null
|
|
653
697
|
) {
|
|
654
698
|
this.scheduleReconnect("watchdog: no reconnect pending");
|
|
@@ -665,7 +709,7 @@ export class Connection {
|
|
|
665
709
|
*/
|
|
666
710
|
private startLiveness(): void {
|
|
667
711
|
this.stopLiveness();
|
|
668
|
-
this.pingTimer = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
712
|
+
this.pingTimer = this.timers.setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
669
713
|
}
|
|
670
714
|
|
|
671
715
|
/** Send one liveness ping and arm the pong-wait timer. */
|
|
@@ -676,8 +720,8 @@ export class Connection {
|
|
|
676
720
|
// If a pong-wait timer is already armed, leave it: a single outstanding
|
|
677
721
|
// timeout is enough to catch a dead socket, and re-arming would push the
|
|
678
722
|
// deadline back on every interval.
|
|
679
|
-
if (this.pongTimer) return;
|
|
680
|
-
this.pongTimer = setTimeout(() => {
|
|
723
|
+
if (this.pongTimer !== null) return;
|
|
724
|
+
this.pongTimer = this.timers.setTimeout(() => {
|
|
681
725
|
// No pong in time. The socket is dead; close it so the close handler runs
|
|
682
726
|
// the reconnect path, rather than reconnecting from here and racing the
|
|
683
727
|
// existing socket's eventual close.
|
|
@@ -703,8 +747,8 @@ export class Connection {
|
|
|
703
747
|
|
|
704
748
|
/** Stop both liveness timers (on close, reconnect, or teardown). */
|
|
705
749
|
private stopLiveness(): void {
|
|
706
|
-
if (this.pingTimer) {
|
|
707
|
-
clearInterval(this.pingTimer);
|
|
750
|
+
if (this.pingTimer !== null) {
|
|
751
|
+
this.timers.clearInterval(this.pingTimer);
|
|
708
752
|
this.pingTimer = null;
|
|
709
753
|
}
|
|
710
754
|
this.clearPongTimer();
|
|
@@ -712,8 +756,8 @@ export class Connection {
|
|
|
712
756
|
|
|
713
757
|
/** Disarm the pong-wait timer (a pong arrived, or liveness is stopping). */
|
|
714
758
|
private clearPongTimer(): void {
|
|
715
|
-
if (this.pongTimer) {
|
|
716
|
-
clearTimeout(this.pongTimer);
|
|
759
|
+
if (this.pongTimer !== null) {
|
|
760
|
+
this.timers.clearTimeout(this.pongTimer);
|
|
717
761
|
this.pongTimer = null;
|
|
718
762
|
}
|
|
719
763
|
}
|
|
@@ -724,7 +768,7 @@ export class Connection {
|
|
|
724
768
|
*/
|
|
725
769
|
private failPendingAck(err: Error): void {
|
|
726
770
|
if (!this.pendingAck) return;
|
|
727
|
-
clearTimeout(this.pendingAck.timer);
|
|
771
|
+
this.timers.clearTimeout(this.pendingAck.timer);
|
|
728
772
|
const { reject } = this.pendingAck;
|
|
729
773
|
this.pendingAck = null;
|
|
730
774
|
reject(err);
|
|
@@ -34,6 +34,7 @@ import type { Maps, DirectionsRequest, DirectionsResult, LatLng, ReverseGeocodeR
|
|
|
34
34
|
import type { Tts, RuntimeTtsSpeakOptions, RuntimeTtsSpeechSource } from "./tts";
|
|
35
35
|
import type { UdpAudio } from "./audio-udp";
|
|
36
36
|
import type { RuntimeSnapshot } from "./status";
|
|
37
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
37
38
|
|
|
38
39
|
const UDP_PROBE_INTERVAL_MS = 1_000;
|
|
39
40
|
const UDP_LIVENESS_TIMEOUT_MS = 3_000;
|
|
@@ -117,6 +118,7 @@ export interface RuntimeDeps {
|
|
|
117
118
|
tts: Tts;
|
|
118
119
|
maps: Maps;
|
|
119
120
|
audio: UdpAudio;
|
|
121
|
+
timers?: CloudClientTimers;
|
|
120
122
|
logger: Logger;
|
|
121
123
|
/**
|
|
122
124
|
* Force `cloud.auth` to drop its cached access token and refresh now.
|
|
@@ -142,13 +144,14 @@ export class Runtime implements RuntimeModule {
|
|
|
142
144
|
public readonly maps: Maps;
|
|
143
145
|
private readonly audio: UdpAudio;
|
|
144
146
|
private readonly logger: Logger;
|
|
147
|
+
private readonly timers: CloudClientTimers;
|
|
145
148
|
private readonly forceRefreshToken: () => Promise<string>;
|
|
146
149
|
private status: RuntimeSnapshot = {
|
|
147
150
|
status: "disconnected",
|
|
148
151
|
audioTransport: "none",
|
|
149
152
|
};
|
|
150
153
|
private hostClosed = true;
|
|
151
|
-
private udpProbeTimer:
|
|
154
|
+
private udpProbeTimer: unknown | null = null;
|
|
152
155
|
private udpProbeStartedAt = 0;
|
|
153
156
|
private lastUdpAckAt = 0;
|
|
154
157
|
|
|
@@ -179,6 +182,7 @@ export class Runtime implements RuntimeModule {
|
|
|
179
182
|
this.tts = deps.tts;
|
|
180
183
|
this.maps = deps.maps;
|
|
181
184
|
this.audio = deps.audio;
|
|
185
|
+
this.timers = deps.timers ?? systemTimers;
|
|
182
186
|
this.logger = deps.logger;
|
|
183
187
|
this.forceRefreshToken = deps.forceRefreshToken;
|
|
184
188
|
}
|
|
@@ -302,16 +306,25 @@ export class Runtime implements RuntimeModule {
|
|
|
302
306
|
// current set (at the current version) to restore live transcription.
|
|
303
307
|
void this.handleReopen();
|
|
304
308
|
} else if (state === "closed") {
|
|
309
|
+
const superseded = this.connection.isReplacedByNewerSession;
|
|
310
|
+
const reason = superseded
|
|
311
|
+
? "superseded by newer session"
|
|
312
|
+
: "socket closed";
|
|
313
|
+
this.logger.debug("ws-session-debug runtime closed", {
|
|
314
|
+
superseded,
|
|
315
|
+
hostClosed: this.hostClosed,
|
|
316
|
+
reason,
|
|
317
|
+
});
|
|
305
318
|
this.stopUdpLiveness();
|
|
306
319
|
this.updateStatus({
|
|
307
|
-
status: this.hostClosed
|
|
320
|
+
status: this.hostClosed || superseded
|
|
308
321
|
? "disconnected"
|
|
309
322
|
: this.opened
|
|
310
323
|
? "reconnecting"
|
|
311
324
|
: "connecting",
|
|
312
325
|
audioTransport: "none",
|
|
313
326
|
});
|
|
314
|
-
this.emitter.emit("disconnected", { reason
|
|
327
|
+
this.emitter.emit("disconnected", { reason });
|
|
315
328
|
}
|
|
316
329
|
});
|
|
317
330
|
}
|
|
@@ -513,15 +526,15 @@ export class Runtime implements RuntimeModule {
|
|
|
513
526
|
this.udpProbeStartedAt = Date.now();
|
|
514
527
|
this.lastUdpAckAt = 0;
|
|
515
528
|
this.sendUdpProbe();
|
|
516
|
-
this.udpProbeTimer = setInterval(() => {
|
|
529
|
+
this.udpProbeTimer = this.timers.setInterval(() => {
|
|
517
530
|
this.sendUdpProbe();
|
|
518
531
|
this.checkUdpLiveness();
|
|
519
532
|
}, UDP_PROBE_INTERVAL_MS);
|
|
520
533
|
}
|
|
521
534
|
|
|
522
535
|
private stopUdpLiveness(): void {
|
|
523
|
-
if (this.udpProbeTimer) {
|
|
524
|
-
clearInterval(this.udpProbeTimer);
|
|
536
|
+
if (this.udpProbeTimer !== null) {
|
|
537
|
+
this.timers.clearInterval(this.udpProbeTimer);
|
|
525
538
|
this.udpProbeTimer = null;
|
|
526
539
|
}
|
|
527
540
|
this.udpProbeStartedAt = 0;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* and docs/issues/004-cloud-client/design.md ("Subscriptions").
|
|
18
18
|
*/
|
|
19
19
|
import type { HttpClient } from "../../http";
|
|
20
|
+
import { systemTimers, type CloudClientTimers } from "../../timers";
|
|
20
21
|
import type { AudioSubscription } from "@mentra/cloud-protocol";
|
|
21
22
|
|
|
22
23
|
/** The REST path the cloud exposes for the full-replace subscription write. */
|
|
@@ -24,10 +25,12 @@ const SUBSCRIPTIONS_PATH = "/api/audio/subscriptions";
|
|
|
24
25
|
|
|
25
26
|
export interface SubscriptionsDeps {
|
|
26
27
|
http: HttpClient;
|
|
28
|
+
timers?: CloudClientTimers;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export class Subscriptions {
|
|
30
32
|
private readonly http: HttpClient;
|
|
33
|
+
private readonly timers: CloudClientTimers;
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* The last set we sent. Kept so a reconnect can re-send the exact same set at
|
|
@@ -43,6 +46,7 @@ export class Subscriptions {
|
|
|
43
46
|
|
|
44
47
|
constructor(deps: SubscriptionsDeps) {
|
|
45
48
|
this.http = deps.http;
|
|
49
|
+
this.timers = deps.timers ?? systemTimers;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -130,7 +134,7 @@ export class Subscriptions {
|
|
|
130
134
|
|
|
131
135
|
const delay = RETRY_DELAYS_MS[attempt];
|
|
132
136
|
if (res.reason !== "stale-session" || delay === undefined) return;
|
|
133
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
137
|
+
await new Promise<void>((resolve) => this.timers.setTimeout(resolve, delay));
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
140
|
}
|
package/src/timers.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Timer surface used by every delayed operation in the cloud client. */
|
|
2
|
+
export interface CloudClientTimers {
|
|
3
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
4
|
+
clearTimeout(handle: unknown): void;
|
|
5
|
+
setInterval(callback: () => void, intervalMs: number): unknown;
|
|
6
|
+
clearInterval(handle: unknown): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Default scheduler for Node, Bun, and foreground browser hosts. */
|
|
10
|
+
export const systemTimers: CloudClientTimers = {
|
|
11
|
+
setTimeout(callback, delayMs) {
|
|
12
|
+
return setTimeout(callback, delayMs);
|
|
13
|
+
},
|
|
14
|
+
clearTimeout(handle) {
|
|
15
|
+
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
|
16
|
+
},
|
|
17
|
+
setInterval(callback, intervalMs) {
|
|
18
|
+
return setInterval(callback, intervalMs);
|
|
19
|
+
},
|
|
20
|
+
clearInterval(handle) {
|
|
21
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
22
|
+
},
|
|
23
|
+
};
|
package/src/transports.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface KeyValueStore {
|
|
|
54
54
|
delete(key: string): Promise<void>;
|
|
55
55
|
}
|
|
56
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
|
+
|
|
57
64
|
/**
|
|
58
65
|
* The bundle of platform pieces handed to the root `CloudClient`.
|
|
59
66
|
*
|
|
@@ -65,4 +72,6 @@ export interface CloudClientTransports {
|
|
|
65
72
|
ws: (url: string) => WebSocketLike;
|
|
66
73
|
udp: () => UdpSocketLike;
|
|
67
74
|
storage: KeyValueStore;
|
|
75
|
+
/** Falls back to globalThis.fetch when omitted. */
|
|
76
|
+
http?: HttpTransport;
|
|
68
77
|
}
|