@mentra/cloud-client 0.1.0-dev.0 → 3.1.0-dev.10

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 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": "0.1.0-dev.0",
3
+ "version": "3.1.0-dev.10",
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": "^0.1.0-dev.0",
15
+ "@mentra/cloud-protocol": "3.1.0-dev.10",
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
  }
@@ -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
- ? proxy
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
- // The form-encoded `/exchange` and `/refresh` calls go through `fetch`
172
- // directly (not the JSON `HttpClient`), so Auth needs the core base URL.
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 = coreUrl && config.auth.core
180
- ? createHttpClient({
181
- baseUrl: coreUrl,
182
- getToken: getCoreToken,
183
- logger,
184
- })
185
- : null;
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 delay(backoff);
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 fetch(url, { method, headers, body });
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";
@@ -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 go
137
- * through `fetch` directly rather than the JSON-only injected `HttpClient`. In
138
- * runtime-only mode this is absent by design: Core identity, miniapp token
139
- * minting, and miniapp auto-auth are Core-backed features.
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 `fetch` directly (not the injected JSON `HttpClient`) because these
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 fetch(url, {
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(),
@@ -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: ReturnType<typeof setTimeout>;
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: ReturnType<typeof setTimeout> | null = null;
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: ReturnType<typeof setInterval> | null = null;
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: ReturnType<typeof setInterval> | null = null;
215
- private pongTimer: ReturnType<typeof setTimeout> | null = null;
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: ReturnType<typeof setTimeout>;
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: ReturnType<typeof setInterval> | null = null;
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: "socket closed" });
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
  }