@mentra/cloud-client 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @fileoverview The top-level `CloudClient`: wiring only, no behavior.
3
+ *
4
+ * `new CloudClient(config)` resolves the server addresses (proxy-aware), builds
5
+ * the shared REST helpers, and constructs the three modules in dependency order
6
+ * (auth first, since runtime and core both pull their Bearer through it). All the
7
+ * actual logic lives in the modules under `./modules/**`; this file just hands
8
+ * each one its dependencies and exposes the three modules as readonly fields.
9
+ *
10
+ * It also owns the two cross-cutting concerns the design says belong in one
11
+ * place: the logger (so a host has a single hook for every module's logs) and the
12
+ * reconnect/backoff settings (so the live socket's timing is tuned here, not
13
+ * scattered across the runtime internals).
14
+ *
15
+ * See docs/issues/004-cloud-client/design.md ("The top-level CloudClient").
16
+ */
17
+ import { noopLogger } from "./logger";
18
+ import type { Logger } from "./logger";
19
+ import type { CloudClientConfig } from "./config";
20
+ import { createHttpClient } from "./http";
21
+ import { CloudClientError } from "./errors";
22
+ import type { ConnectionInit } from "@mentra/cloud-protocol";
23
+
24
+ // The module implementations. Each is owned by another agent under ./modules/**;
25
+ // this file only constructs them, matching the constructor signatures fixed in
26
+ // design.md exactly.
27
+ import { Auth } from "./modules/auth/auth";
28
+ import { TokenStore } from "./modules/auth/token-store";
29
+ import { Runtime } from "./modules/runtime/runtime";
30
+ import { Connection } from "./modules/runtime/connection";
31
+ import { RuntimeEmitter } from "./modules/runtime/emitter";
32
+ import { Subscriptions } from "./modules/runtime/subscriptions";
33
+ import { Camera } from "./modules/runtime/camera";
34
+ import { Maps } from "./modules/runtime/maps";
35
+ import { Tts } from "./modules/runtime/tts";
36
+ import { UdpAudio } from "./modules/runtime/audio-udp";
37
+ import { Core } from "./modules/core/core";
38
+
39
+ /**
40
+ * Default reconnect/backoff for the live socket when a host supplies none.
41
+ *
42
+ * Half-second base, capped at five seconds, with jitter on. The small cap is
43
+ * deliberate: the socket should recover within a few seconds of the cloud
44
+ * coming back (a routine runtime redeploy is a ~30-60s blip), not sit on a long
45
+ * backoff. Full jitter still keeps a fleet of phones from reconnecting in
46
+ * lockstep after a shared blip. A host can override any of these through
47
+ * `config.reconnect`.
48
+ */
49
+ const DEFAULT_RECONNECT = { baseMs: 500, maxMs: 5_000, jitter: true };
50
+
51
+ /**
52
+ * The default audio codec the client announces in the handshake.
53
+ *
54
+ * LC3 at 16 kHz matches the glasses' on-device codec, so the cloud transcribes
55
+ * the same bytes the device captures. A future config knob can override this; for
56
+ * now the handshake announces the device default so audio that starts immediately
57
+ * after connect is decoded correctly.
58
+ */
59
+ const DEFAULT_AUDIO_CODEC = "pcm" as const;
60
+ const DEFAULT_AUDIO_SAMPLE_RATE = 16_000;
61
+
62
+ /**
63
+ * The protocol semver this client build speaks, announced in `connection.init`.
64
+ *
65
+ * Hardcoded to the 2.x line this package targets; bumped here when the client
66
+ * starts speaking a newer protocol build, so there is one place to change it.
67
+ */
68
+ const PROTOCOL_VERSION = "2.0.0";
69
+
70
+ /**
71
+ * Rewrite a base URL to route through a proxy host while preserving its path.
72
+ *
73
+ * When a host sets `endpoints.proxy`, both the core and runtime addresses go
74
+ * through that one host (for example a dev-stack tunnel or a debugging relay). We
75
+ * swap only the origin (scheme + host + port) and keep the original path, so a
76
+ * core/runtime address that carries a path prefix is not lost when proxied.
77
+ */
78
+ function rewriteThroughProxy(target: string, proxy: string): string {
79
+ const proxyUrl = new URL(proxy);
80
+ const targetUrl = new URL(target);
81
+ // Keep the target's path/query, take the proxy's origin.
82
+ targetUrl.protocol = proxyUrl.protocol;
83
+ targetUrl.host = proxyUrl.host;
84
+ return targetUrl.toString();
85
+ }
86
+
87
+ /**
88
+ * Derive the runtime WebSocket URL from its HTTP base.
89
+ *
90
+ * `endpoints.runtime` is the HTTP origin the REST calls use; the live session
91
+ * rides a WebSocket at the runtime's `/ws/session` path. So we swap the scheme
92
+ * (http -> ws, https -> wss) and append that path. Keeping this here (not in the
93
+ * Connection) means the Connection stays transport-URL-agnostic and the one
94
+ * place that knows the runtime's HTTP shape also derives its socket URL.
95
+ */
96
+ function toRuntimeWsUrl(httpBase: string): string {
97
+ const u = new URL(httpBase);
98
+ u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
99
+ u.pathname = `${u.pathname.replace(/\/$/, "")}/ws/session`;
100
+ return u.toString();
101
+ }
102
+
103
+ export class CloudClient {
104
+ // Typed as the concrete module classes rather than separate `AuthModule` /
105
+ // `RuntimeModule` / `CoreModule` interfaces: each class IS the implementation
106
+ // of its public contract (per design.md), so a host gets the full, typed
107
+ // surface (`cloud.auth.getRuntimeToken()`, etc.) straight off these fields with
108
+ // no parallel interface to keep in sync.
109
+ readonly auth: Auth;
110
+ readonly runtime: Runtime;
111
+ readonly core?: Core;
112
+
113
+ constructor(config: CloudClientConfig) {
114
+ // One logger for the whole client, so a host routes every module's logs in
115
+ // one place. Default to the silent no-op so we never print uninvited.
116
+ const logger: Logger = config.logger ?? noopLogger;
117
+
118
+ // Reconnect/backoff lives here so the socket's timing is tuned in one spot.
119
+ const reconnect = config.reconnect ?? DEFAULT_RECONNECT;
120
+
121
+ // Resolve the two base addresses. With a proxy set, both route through it;
122
+ // without one, each module talks to its own service directly.
123
+ const { core: coreBase, runtime: runtimeBase, proxy } = config.endpoints;
124
+ const coreUrl = coreBase ? (proxy ? rewriteThroughProxy(coreBase, proxy) : coreBase) : undefined;
125
+ const runtimeUrl = proxy ? rewriteThroughProxy(runtimeBase, proxy) : runtimeBase;
126
+
127
+ if (config.auth.core && !coreUrl) {
128
+ throw new CloudClientError("auth.core requires endpoints.core");
129
+ }
130
+
131
+ // Runtime auth is the mandatory half (Core is optional). Guard it before the
132
+ // `in` check below so a caller passing the pre-split flat `auth` shape
133
+ // (`{ subjectToken, subjectTokenType }`, no `runtime`) gets a clear
134
+ // configuration error instead of an opaque `TypeError` from `"source" in undefined`.
135
+ if (!config.auth.runtime) {
136
+ throw new CloudClientError("auth.runtime is required (got a pre-split/flat auth config?)");
137
+ }
138
+
139
+ const runtimeUsesCore = "source" in config.auth.runtime && config.auth.runtime.source === "core";
140
+ if (runtimeUsesCore && (!coreUrl || !config.auth.core)) {
141
+ throw new CloudClientError("auth.runtime.source='core' requires endpoints.core and auth.core");
142
+ }
143
+
144
+ // Build auth FIRST: runtime and core both source their Bearer from it, so it
145
+ // has to exist before their HTTP helpers can reference token providers.
146
+ //
147
+ // Auth's own HTTP helper has no default token source: its `/exchange` and
148
+ // `/refresh` calls present the subject and refresh tokens via `opts.bearer`,
149
+ // before any access token exists. It is deliberately Core-only: runtime-only
150
+ // clients never get a fallback that points Core/Auth calls at Runtime.
151
+ const authHttp = coreUrl
152
+ ? createHttpClient({ baseUrl: coreUrl, logger, fetch: config.transports.http })
153
+ : undefined;
154
+ const store = new TokenStore({ storage: config.transports.storage });
155
+ const auth = new Auth({
156
+ http: authHttp,
157
+ store,
158
+ config: config.auth,
159
+ logger,
160
+ // Form-encoded `/exchange` and `/refresh` calls are not JSON HttpClient
161
+ // requests, but still use the host's injected HTTP transport.
162
+ baseUrl: coreUrl,
163
+ fetch: config.transports.http,
164
+ });
165
+
166
+ const getRuntimeToken = (): Promise<string> => auth.getRuntimeToken();
167
+ const getCoreToken = (): Promise<string> => auth.getCoreToken();
168
+
169
+ const coreHttp =
170
+ coreUrl && config.auth.core
171
+ ? createHttpClient({
172
+ baseUrl: coreUrl,
173
+ getToken: getCoreToken,
174
+ logger,
175
+ fetch: config.transports.http,
176
+ })
177
+ : null;
178
+ const runtimeHttp = createHttpClient({
179
+ baseUrl: runtimeUrl,
180
+ getToken: getRuntimeToken,
181
+ logger,
182
+ fetch: config.transports.http,
183
+ });
184
+
185
+ const emitter = new RuntimeEmitter();
186
+ const subscriptions = new Subscriptions({ http: runtimeHttp });
187
+
188
+ // The handshake payload the connection sends on every (re)open. It is a
189
+ // factory (not a fixed value) so each reconnect re-reads the current defaults
190
+ // rather than reusing a stale snapshot. The token is omitted here: the
191
+ // connection attaches the live access token itself via `getToken`, so the
192
+ // payload never carries a credential that could go stale between reopens.
193
+ //
194
+ // `initialSubscriptions` carries the LIVE subscription set on every reopen so
195
+ // the cloud seeds the new session's subscription key non-empty at handshake.
196
+ // Without this a reconnect's new (stateless) cloud session starts with an
197
+ // empty set and depends entirely on the follow-up REST resend's control-stream
198
+ // nudge — which the new owner pod's just-created `$`-positioned consumer group
199
+ // can miss, leaving the session with audio but no transcription provider. By
200
+ // riding the set in `connection.init`, the seed + the cloud's in-process
201
+ // post-seed reconcile (which reads the key directly, not the stream) brings
202
+ // providers up atomically with the session. On the very first connect the set
203
+ // is empty (no `set()` has run yet) and the first `setSubscriptions` REST call
204
+ // applies it; on every reconnect it is the live set.
205
+ const initPayload = (): ConnectionInit => ({
206
+ protocolVersion: PROTOCOL_VERSION,
207
+ audio: {
208
+ codec: config.audio?.codec ?? DEFAULT_AUDIO_CODEC,
209
+ sampleRate: config.audio?.sampleRate ?? DEFAULT_AUDIO_SAMPLE_RATE,
210
+ // Only LC3 carries a frame size; the config type forces LC3 hosts to
211
+ // state theirs explicitly (decoder is sized from this — no safe guess).
212
+ ...(config.audio?.codec === "lc3" ? { frameSizeBytes: config.audio.frameSizeBytes } : {}),
213
+ initialSubscriptions: subscriptions.currentSet(),
214
+ },
215
+ });
216
+
217
+ // Build the remaining runtime pieces, then the runtime that orchestrates them.
218
+ const connection = new Connection({
219
+ ws: config.transports.ws,
220
+ url: toRuntimeWsUrl(runtimeUrl),
221
+ getToken: getRuntimeToken,
222
+ initPayload,
223
+ reconnect,
224
+ onAuthRejected: async () => {
225
+ await auth.getRuntimeToken({ forceRefresh: true });
226
+ },
227
+ logger,
228
+ });
229
+ const camera = new Camera({ http: runtimeHttp });
230
+ const tts = new Tts({ http: runtimeHttp });
231
+ const maps = new Maps({ http: runtimeHttp });
232
+ const audio = new UdpAudio({ udp: config.transports.udp });
233
+
234
+ const runtime = new Runtime({
235
+ connection,
236
+ emitter,
237
+ subscriptions,
238
+ camera,
239
+ tts,
240
+ maps,
241
+ audio,
242
+ logger,
243
+ // On a fatal AUTH_EXPIRED at handshake, runtime forces auth to drop its
244
+ // cached access token and refresh; the connection then re-reads the fresh
245
+ // token via getRuntimeToken on the reopen.
246
+ forceRefreshToken: () => auth.getRuntimeToken({ forceRefresh: true }),
247
+ });
248
+
249
+ // Core is last: stateless REST on the core service, Bearer from auth.
250
+ const core = coreHttp ? new Core({ http: coreHttp }) : undefined;
251
+
252
+ this.auth = auth;
253
+ this.runtime = runtime;
254
+ this.core = core;
255
+ }
256
+ }
package/src/config.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @fileoverview The config you pass to `new CloudClient(...)`.
3
+ *
4
+ * This is the public construction contract. The platform-specific wrappers
5
+ * (`react-native`, `node`) supply `transports` for you, so a host using one of
6
+ * those imports passes everything here except `transports`.
7
+ *
8
+ * See docs/issues/004-cloud-client/spec.md ("Construction") and design.md.
9
+ */
10
+ import type { Logger } from "./logger";
11
+ import type { CloudClientTransports } from "./transports";
12
+
13
+ /**
14
+ * The full shape passed to the root `CloudClient`.
15
+ *
16
+ * `endpoints.proxy`, when set, rewrites BOTH the core and runtime addresses to
17
+ * route through one host. We keep it as a single optional field (rather than two
18
+ * pre-rewritten URLs) so a host configures the proxy in one place and cannot get
19
+ * the two halves out of sync.
20
+ */
21
+ export interface CloudClientConfig {
22
+ // `core` is optional only for runtime-only deployments. If `auth.core` is set,
23
+ // or if `auth.runtime.source` is `"core"`, this must be present; Core/Auth
24
+ // calls are never routed to Runtime.
25
+ endpoints: { core?: string; runtime: string; proxy?: string };
26
+ auth: AuthConfig;
27
+ transports: CloudClientTransports;
28
+ logger?: Logger;
29
+ // backoff tuning for the live socket; one place so a host can match its fleet
30
+ reconnect?: { baseMs: number; maxMs: number; jitter: boolean };
31
+ /**
32
+ * Audio format announced in `connection.init`. Defaults to PCM at 16 kHz when
33
+ * omitted. An LC3 host MUST pass the frame size its encoder emits — the
34
+ * runtime sizes its decoder from this field, and phone builds legitimately
35
+ * differ (20/40/60); there is no safe default, so the type requires it.
36
+ */
37
+ audio?:
38
+ | { codec: "pcm"; sampleRate?: number }
39
+ | { codec: "lc3"; sampleRate?: number; frameSizeBytes: 20 | 40 | 60 };
40
+ }
41
+
42
+ /**
43
+ * Which kind of subject token the host is exchanging for a Mentra access token.
44
+ *
45
+ * The cloud's `/exchange` endpoint needs to know how to verify the incoming
46
+ * token, so the type travels alongside the token itself.
47
+ */
48
+ export type SubjectTokenType = "oem-jwt" | "mentra-core" | "supabase";
49
+
50
+ /**
51
+ * The three ways a host can give the client its credentials.
52
+ *
53
+ * The variants exist so a host hands over only what it has: a raw subject token
54
+ * to exchange once, a callback that fetches one on demand (for tokens that
55
+ * themselves expire), or an already-exchanged access/refresh pair (for example
56
+ * restored from secure storage on relaunch).
57
+ */
58
+ export type CoreAuthConfig =
59
+ // exchanged once on first use
60
+ | { subjectToken: string; subjectTokenType: SubjectTokenType }
61
+ // fetched on demand, for subject tokens that expire before exchange
62
+ | { getSubjectToken: () => Promise<{ token: string; type: SubjectTokenType }> }
63
+ // already exchanged, skip straight to refresh
64
+ | { accessToken: string; refreshToken: string };
65
+
66
+ export type RuntimeAuthConfig =
67
+ | {
68
+ /**
69
+ * Ask Cloud Core/Auth to mint a short-lived `cloud-runtime` token. This is
70
+ * explicit hosted-Core mode, not an implicit Core-token fallback.
71
+ */
72
+ source: "core";
73
+ }
74
+ | {
75
+ /** Host/OEM/local-dev supplied runtime-token provider. */
76
+ getToken(opts?: { forceRefresh?: boolean }): Promise<string>;
77
+ };
78
+
79
+ export interface AuthConfig {
80
+ // Core-backed auth owns identity, Core token exchange/refresh, miniapp token
81
+ // minting, and miniapp auto-auth. Omit only for true runtime-only deployments.
82
+ core?: CoreAuthConfig;
83
+ runtime: RuntimeAuthConfig;
84
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @fileoverview The client-side error types every module throws.
3
+ *
4
+ * These are distinct from the protocol's `ProtocolError` (which is a wire
5
+ * payload from the cloud). These are JS errors thrown locally so a host can
6
+ * branch with `instanceof` instead of string-matching messages.
7
+ *
8
+ * See docs/issues/004-cloud-client/design.md.
9
+ */
10
+
11
+ /** Base class so a host can catch every cloud-client error with one check. */
12
+ export class CloudClientError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ // Without this, `instanceof` checks fail once the code is transpiled down to
16
+ // ES5-class semantics, since the prototype chain gets reset by `super`.
17
+ this.name = "CloudClientError";
18
+ Object.setPrototypeOf(this, new.target.prototype);
19
+ }
20
+ }
21
+
22
+ /**
23
+ * A non-2xx HTTP response from a REST call.
24
+ *
25
+ * `status` is the HTTP status so a caller can branch (for example a 401 triggers
26
+ * one refresh-and-retry). `code` is the optional machine-readable error code the
27
+ * cloud puts in the JSON body, when present, for finer branching than status
28
+ * alone allows.
29
+ */
30
+ export class HttpError extends CloudClientError {
31
+ status!: number;
32
+ code?: string;
33
+
34
+ constructor(message: string, status: number, code?: string) {
35
+ super(message);
36
+ this.name = "HttpError";
37
+ this.status = status;
38
+ this.code = code;
39
+ Object.setPrototypeOf(this, new.target.prototype);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Thrown when a token refresh fails and the host must send the user back through
45
+ * login. Separate from `HttpError` so a host can catch the "credentials are
46
+ * truly dead" case on its own without inspecting status codes.
47
+ */
48
+ export class AuthExpiredError extends CloudClientError {
49
+ constructor(message = "Authentication expired; re-auth required") {
50
+ super(message);
51
+ this.name = "AuthExpiredError";
52
+ Object.setPrototypeOf(this, new.target.prototype);
53
+ }
54
+ }
package/src/http.ts ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * @fileoverview The one REST helper every module uses.
3
+ *
4
+ * Centralizing REST here keeps behavior consistent across auth, runtime, and
5
+ * core: one place builds the URL, attaches the Bearer header, parses JSON, maps
6
+ * a non-2xx response to a typed `HttpError`, and retries only safe (idempotent)
7
+ * calls on a transient failure. Modules never call `fetch` directly, so none of
8
+ * them can drift on error handling or auth headers.
9
+ *
10
+ * Uses the global `fetch`, which exists on both a modern phone and a modern
11
+ * server, so REST needs no platform input (unlike sockets and storage).
12
+ *
13
+ * See docs/issues/004-cloud-client/design.md ("The shared HTTP helper").
14
+ */
15
+ import { HttpError } from "./errors";
16
+ import type { Logger } from "./logger";
17
+ import type { HttpTransport } from "./transports";
18
+
19
+ /**
20
+ * Per-request options.
21
+ *
22
+ * `bearer` overrides the default token source for this one call: the `/exchange`
23
+ * call presents the subject token instead of an access token, and `/refresh`
24
+ * presents the refresh token, so they pass `bearer` explicitly rather than going
25
+ * through `cloud.auth`.
26
+ *
27
+ * `idempotent` marks a call as safe to retry on a transient network error. GET
28
+ * is always treated as idempotent; the full-replace PUT opts in via this flag.
29
+ * POST is never retried by default because it may not be safe to repeat.
30
+ */
31
+ export interface ReqOpts {
32
+ bearer?: string;
33
+ idempotent?: boolean;
34
+ }
35
+
36
+ /** The REST surface the modules consume. */
37
+ export interface HttpClient {
38
+ get<T>(path: string, opts?: ReqOpts): Promise<T>;
39
+ head(path: string, opts?: ReqOpts): Promise<Response>;
40
+ post<T>(path: string, body?: unknown, opts?: ReqOpts): Promise<T>;
41
+ postForm<T>(path: string, form: FormData, opts?: ReqOpts): Promise<T>;
42
+ put<T>(path: string, body: unknown, opts?: ReqOpts): Promise<T>;
43
+ delete<T>(path: string, opts?: ReqOpts): Promise<T>;
44
+ url(path: string): string;
45
+ }
46
+
47
+ /**
48
+ * Dependencies for the helper.
49
+ *
50
+ * `getToken` is the default Bearer source (for example
51
+ * `cloud.auth.getRuntimeToken` or `cloud.auth.getCoreToken`).
52
+ * It is optional because the auth module's own `/exchange` and `/refresh` calls
53
+ * run before any access token exists; those calls pass `opts.bearer` directly.
54
+ */
55
+ export interface CreateHttpClientDeps {
56
+ baseUrl: string;
57
+ // default Bearer source, usually cloud.auth.getRuntimeToken/getCoreToken
58
+ getToken?: () => Promise<string>;
59
+ logger: Logger;
60
+ fetch?: HttpTransport;
61
+ }
62
+
63
+ /** How many times to retry a transient failure on an idempotent call. */
64
+ const MAX_RETRIES = 2;
65
+ /** Base backoff in milliseconds; doubles per attempt (250, 500, ...). */
66
+ const RETRY_BASE_MS = 250;
67
+
68
+ /** Resolve after `ms`, used for retry backoff. */
69
+ function delay(ms: number): Promise<void> {
70
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
+ }
72
+
73
+ /**
74
+ * Join a base URL and a path without producing a double slash or dropping one.
75
+ *
76
+ * Done by hand (not `new URL`) so a `baseUrl` that already carries a path prefix
77
+ * (for example a proxy mount point) is preserved rather than discarded.
78
+ */
79
+ function joinUrl(baseUrl: string, path: string): string {
80
+ const base = baseUrl.replace(/\/+$/, "");
81
+ const suffix = path.replace(/^\/+/, "");
82
+ return `${base}/${suffix}`;
83
+ }
84
+
85
+ export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
86
+ const { baseUrl, getToken, logger } = deps;
87
+ const executeFetch = deps.fetch ?? globalThis.fetch;
88
+
89
+ /**
90
+ * Resolve the Bearer to attach: a per-call override wins, otherwise the
91
+ * default token source. We never log the resolved token: tokens stay out of
92
+ * logs everywhere in this library.
93
+ */
94
+ async function resolveBearer(opts?: ReqOpts): Promise<string | undefined> {
95
+ if (opts?.bearer) return opts.bearer;
96
+ if (getToken) return await getToken();
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * The one retry loop behind every request shape (JSON, form, bodyless).
102
+ *
103
+ * A network-level failure (DNS, reset, timeout) is transient and worth a
104
+ * retry on an idempotent call. A non-2xx response is NOT transient here: it is
105
+ * a definite answer from the server, mapped to an `HttpError` for the caller
106
+ * to branch on (auth handles its own 401 refresh-and-retry a layer up).
107
+ * The thrown exhaustion error keeps the network-error detail out of its
108
+ * message to avoid leaking anything host-specific into a string a host might
109
+ * surface to a user.
110
+ */
111
+ async function fetchWithRetry(args: {
112
+ method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
113
+ path: string;
114
+ headers: Record<string, string>;
115
+ body: string | FormData | undefined;
116
+ idempotent: boolean;
117
+ }): Promise<Response> {
118
+ const { method, path, headers, body, idempotent } = args;
119
+ const url = joinUrl(baseUrl, path);
120
+
121
+ for (let attempt = 0; attempt <= (idempotent ? MAX_RETRIES : 0); attempt++) {
122
+ if (attempt > 0) {
123
+ const backoff = RETRY_BASE_MS * 2 ** (attempt - 1);
124
+ logger.debug("http retrying request", { method, path, attempt });
125
+ await delay(backoff);
126
+ }
127
+
128
+ let res: Response;
129
+ try {
130
+ res = await executeFetch(url, { method, headers, body });
131
+ } catch {
132
+ // Transient network failure: let the loop retry.
133
+ logger.warn("http network error", { method, path, attempt });
134
+ continue;
135
+ }
136
+
137
+ if (!res.ok) {
138
+ // Definite answer from the server: map to a typed error, no retry.
139
+ throw await toHttpError(res, method, path);
140
+ }
141
+
142
+ return res;
143
+ }
144
+
145
+ // Exhausted retries on a transient failure.
146
+ throw new HttpError(`Network request failed: ${method} ${path}`, 0, "NETWORK_ERROR");
147
+ }
148
+
149
+ async function requestRaw(
150
+ method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD",
151
+ path: string,
152
+ body: unknown,
153
+ opts?: ReqOpts,
154
+ ): Promise<Response> {
155
+ const bearer = await resolveBearer(opts);
156
+
157
+ const headers: Record<string, string> = {};
158
+ if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
159
+
160
+ let payload: string | undefined;
161
+ if (body !== undefined) {
162
+ headers["Content-Type"] = "application/json";
163
+ payload = JSON.stringify(body);
164
+ }
165
+
166
+ // GET and DELETE are idempotent by HTTP semantics, so safe to retry; other
167
+ // verbs opt in via the flag.
168
+ const idempotent = opts?.idempotent ?? (method === "GET" || method === "DELETE" || method === "HEAD");
169
+
170
+ return await fetchWithRetry({ method, path, headers, body: payload, idempotent });
171
+ }
172
+
173
+ async function request<T>(
174
+ method: "GET" | "POST" | "PUT" | "DELETE",
175
+ path: string,
176
+ body: unknown,
177
+ opts?: ReqOpts,
178
+ ): Promise<T> {
179
+ const res = await requestRaw(method, path, body, opts);
180
+ return await parseJson<T>(res);
181
+ }
182
+
183
+ async function requestForm<T>(path: string, form: FormData, opts?: ReqOpts): Promise<T> {
184
+ const bearer = await resolveBearer(opts);
185
+
186
+ // No Content-Type here: fetch/FormData must generate the multipart
187
+ // boundary. POST is not idempotent, so retries stay opt-in.
188
+ const headers: Record<string, string> = {};
189
+ if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
190
+
191
+ const res = await fetchWithRetry({
192
+ method: "POST",
193
+ path,
194
+ headers,
195
+ body: form,
196
+ idempotent: opts?.idempotent ?? false,
197
+ });
198
+ return await parseJson<T>(res);
199
+ }
200
+
201
+ /**
202
+ * Turn a non-2xx response into an `HttpError`, reading a machine-readable
203
+ * `code` from the JSON body when the server provides one. We swallow any body
204
+ * parse failure here because the status is the load-bearing signal and we do
205
+ * not want a malformed error body to mask the real status.
206
+ */
207
+ async function toHttpError(res: Response, method: string, path: string): Promise<HttpError> {
208
+ let code: string | undefined;
209
+ let detail = "";
210
+ try {
211
+ const data = (await res.json()) as {
212
+ code?: string;
213
+ message?: string;
214
+ error?: string;
215
+ error_description?: string;
216
+ };
217
+ code = data?.code ?? data?.error;
218
+ const message = data?.message ?? data?.error_description;
219
+ detail = message ? `: ${message}` : "";
220
+ } catch {
221
+ // No JSON body, or unparseable: fall back to status alone.
222
+ }
223
+ return new HttpError(`HTTP ${res.status} on ${method} ${path}${detail}`, res.status, code);
224
+ }
225
+
226
+ /**
227
+ * Parse a successful response as JSON, tolerating an empty body (a 204 or an
228
+ * endpoint that returns nothing) by resolving to `undefined`.
229
+ */
230
+ async function parseJson<T>(res: Response): Promise<T> {
231
+ const text = await res.text();
232
+ if (text.length === 0) return undefined as T;
233
+ return JSON.parse(text) as T;
234
+ }
235
+
236
+ return {
237
+ get<T>(path: string, opts?: ReqOpts): Promise<T> {
238
+ return request<T>("GET", path, undefined, opts);
239
+ },
240
+ head(path: string, opts?: ReqOpts): Promise<Response> {
241
+ return requestRaw("HEAD", path, undefined, opts);
242
+ },
243
+ post<T>(path: string, body?: unknown, opts?: ReqOpts): Promise<T> {
244
+ return request<T>("POST", path, body, opts);
245
+ },
246
+ postForm<T>(path: string, form: FormData, opts?: ReqOpts): Promise<T> {
247
+ return requestForm<T>(path, form, opts);
248
+ },
249
+ put<T>(path: string, body: unknown, opts?: ReqOpts): Promise<T> {
250
+ return request<T>("PUT", path, body, opts);
251
+ },
252
+ delete<T>(path: string, opts?: ReqOpts): Promise<T> {
253
+ return request<T>("DELETE", path, undefined, opts);
254
+ },
255
+ url(path: string): string {
256
+ return joinUrl(baseUrl, path);
257
+ },
258
+ };
259
+ }