@mentra/cloud-client 0.1.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node/index.ts +54 -0
- package/node/transports.ts +133 -0
- package/package.json +46 -0
- package/react-native/index.ts +38 -0
- package/react-native/transports.ts +216 -0
- package/src/client.ts +265 -0
- package/src/config.ts +84 -0
- package/src/errors.ts +54 -0
- package/src/http.ts +273 -0
- package/src/index.ts +70 -0
- package/src/logger.ts +34 -0
- package/src/modules/auth/auth.ts +548 -0
- package/src/modules/auth/jwt.ts +82 -0
- package/src/modules/auth/token-store.ts +138 -0
- package/src/modules/core/core.ts +202 -0
- package/src/modules/core/reports.ts +153 -0
- package/src/modules/runtime/audio-udp.ts +192 -0
- package/src/modules/runtime/camera.ts +192 -0
- package/src/modules/runtime/connection.ts +782 -0
- package/src/modules/runtime/emitter.ts +129 -0
- package/src/modules/runtime/maps.ts +92 -0
- package/src/modules/runtime/runtime.ts +557 -0
- package/src/modules/runtime/status.ts +12 -0
- package/src/modules/runtime/subscriptions.ts +136 -0
- package/src/modules/runtime/tts.ts +81 -0
- package/src/transports.ts +68 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
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
|
|
125
|
+
? proxy
|
|
126
|
+
? rewriteThroughProxy(coreBase, proxy)
|
|
127
|
+
: coreBase
|
|
128
|
+
: undefined;
|
|
129
|
+
const runtimeUrl = proxy
|
|
130
|
+
? rewriteThroughProxy(runtimeBase, proxy)
|
|
131
|
+
: runtimeBase;
|
|
132
|
+
|
|
133
|
+
if (config.auth.core && !coreUrl) {
|
|
134
|
+
throw new CloudClientError("auth.core requires endpoints.core");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Runtime auth is the mandatory half (Core is optional). Guard it before the
|
|
138
|
+
// `in` check below so a caller passing the pre-split flat `auth` shape
|
|
139
|
+
// (`{ subjectToken, subjectTokenType }`, no `runtime`) gets a clear
|
|
140
|
+
// configuration error instead of an opaque `TypeError` from `"source" in undefined`.
|
|
141
|
+
if (!config.auth.runtime) {
|
|
142
|
+
throw new CloudClientError(
|
|
143
|
+
"auth.runtime is required (got a pre-split/flat auth config?)",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const runtimeUsesCore =
|
|
148
|
+
"source" in config.auth.runtime && config.auth.runtime.source === "core";
|
|
149
|
+
if (runtimeUsesCore && (!coreUrl || !config.auth.core)) {
|
|
150
|
+
throw new CloudClientError(
|
|
151
|
+
"auth.runtime.source='core' requires endpoints.core and auth.core",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build auth FIRST: runtime and core both source their Bearer from it, so it
|
|
156
|
+
// has to exist before their HTTP helpers can reference token providers.
|
|
157
|
+
//
|
|
158
|
+
// Auth's own HTTP helper has no default token source: its `/exchange` and
|
|
159
|
+
// `/refresh` calls present the subject and refresh tokens via `opts.bearer`,
|
|
160
|
+
// before any access token exists. It is deliberately Core-only: runtime-only
|
|
161
|
+
// clients never get a fallback that points Core/Auth calls at Runtime.
|
|
162
|
+
const authHttp = coreUrl
|
|
163
|
+
? createHttpClient({ baseUrl: coreUrl, logger })
|
|
164
|
+
: undefined;
|
|
165
|
+
const store = new TokenStore({ storage: config.transports.storage });
|
|
166
|
+
const auth = new Auth({
|
|
167
|
+
http: authHttp,
|
|
168
|
+
store,
|
|
169
|
+
config: config.auth,
|
|
170
|
+
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.
|
|
173
|
+
baseUrl: coreUrl,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const getRuntimeToken = (): Promise<string> => auth.getRuntimeToken();
|
|
177
|
+
const getCoreToken = (): Promise<string> => auth.getCoreToken();
|
|
178
|
+
|
|
179
|
+
const coreHttp = coreUrl && config.auth.core
|
|
180
|
+
? createHttpClient({
|
|
181
|
+
baseUrl: coreUrl,
|
|
182
|
+
getToken: getCoreToken,
|
|
183
|
+
logger,
|
|
184
|
+
})
|
|
185
|
+
: null;
|
|
186
|
+
const runtimeHttp = createHttpClient({
|
|
187
|
+
baseUrl: runtimeUrl,
|
|
188
|
+
getToken: getRuntimeToken,
|
|
189
|
+
logger,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const emitter = new RuntimeEmitter();
|
|
193
|
+
const subscriptions = new Subscriptions({ http: runtimeHttp });
|
|
194
|
+
|
|
195
|
+
// The handshake payload the connection sends on every (re)open. It is a
|
|
196
|
+
// factory (not a fixed value) so each reconnect re-reads the current defaults
|
|
197
|
+
// rather than reusing a stale snapshot. The token is omitted here: the
|
|
198
|
+
// connection attaches the live access token itself via `getToken`, so the
|
|
199
|
+
// payload never carries a credential that could go stale between reopens.
|
|
200
|
+
//
|
|
201
|
+
// `initialSubscriptions` carries the LIVE subscription set on every reopen so
|
|
202
|
+
// the cloud seeds the new session's subscription key non-empty at handshake.
|
|
203
|
+
// Without this a reconnect's new (stateless) cloud session starts with an
|
|
204
|
+
// empty set and depends entirely on the follow-up REST resend's control-stream
|
|
205
|
+
// nudge — which the new owner pod's just-created `$`-positioned consumer group
|
|
206
|
+
// can miss, leaving the session with audio but no transcription provider. By
|
|
207
|
+
// riding the set in `connection.init`, the seed + the cloud's in-process
|
|
208
|
+
// post-seed reconcile (which reads the key directly, not the stream) brings
|
|
209
|
+
// providers up atomically with the session. On the very first connect the set
|
|
210
|
+
// is empty (no `set()` has run yet) and the first `setSubscriptions` REST call
|
|
211
|
+
// applies it; on every reconnect it is the live set.
|
|
212
|
+
const initPayload = (): ConnectionInit => ({
|
|
213
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
214
|
+
audio: {
|
|
215
|
+
codec: config.audio?.codec ?? DEFAULT_AUDIO_CODEC,
|
|
216
|
+
sampleRate: config.audio?.sampleRate ?? DEFAULT_AUDIO_SAMPLE_RATE,
|
|
217
|
+
// Only LC3 carries a frame size; the config type forces LC3 hosts to
|
|
218
|
+
// state theirs explicitly (decoder is sized from this — no safe guess).
|
|
219
|
+
...(config.audio?.codec === "lc3"
|
|
220
|
+
? { frameSizeBytes: config.audio.frameSizeBytes }
|
|
221
|
+
: {}),
|
|
222
|
+
initialSubscriptions: subscriptions.currentSet(),
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Build the remaining runtime pieces, then the runtime that orchestrates them.
|
|
227
|
+
const connection = new Connection({
|
|
228
|
+
ws: config.transports.ws,
|
|
229
|
+
url: toRuntimeWsUrl(runtimeUrl),
|
|
230
|
+
getToken: getRuntimeToken,
|
|
231
|
+
initPayload,
|
|
232
|
+
reconnect,
|
|
233
|
+
onAuthRejected: async () => {
|
|
234
|
+
await auth.getRuntimeToken({ forceRefresh: true });
|
|
235
|
+
},
|
|
236
|
+
logger,
|
|
237
|
+
});
|
|
238
|
+
const camera = new Camera({ http: runtimeHttp });
|
|
239
|
+
const tts = new Tts({ http: runtimeHttp });
|
|
240
|
+
const maps = new Maps({ http: runtimeHttp });
|
|
241
|
+
const audio = new UdpAudio({ udp: config.transports.udp });
|
|
242
|
+
|
|
243
|
+
const runtime = new Runtime({
|
|
244
|
+
connection,
|
|
245
|
+
emitter,
|
|
246
|
+
subscriptions,
|
|
247
|
+
camera,
|
|
248
|
+
tts,
|
|
249
|
+
maps,
|
|
250
|
+
audio,
|
|
251
|
+
logger,
|
|
252
|
+
// On a fatal AUTH_EXPIRED at handshake, runtime forces auth to drop its
|
|
253
|
+
// cached access token and refresh; the connection then re-reads the fresh
|
|
254
|
+
// token via getRuntimeToken on the reopen.
|
|
255
|
+
forceRefreshToken: () => auth.getRuntimeToken({ forceRefresh: true }),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Core is last: stateless REST on the core service, Bearer from auth.
|
|
259
|
+
const core = coreHttp ? new Core({ http: coreHttp }) : undefined;
|
|
260
|
+
|
|
261
|
+
this.auth = auth;
|
|
262
|
+
this.runtime = runtime;
|
|
263
|
+
this.core = core;
|
|
264
|
+
}
|
|
265
|
+
}
|
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,273 @@
|
|
|
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
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-request options.
|
|
20
|
+
*
|
|
21
|
+
* `bearer` overrides the default token source for this one call: the `/exchange`
|
|
22
|
+
* call presents the subject token instead of an access token, and `/refresh`
|
|
23
|
+
* presents the refresh token, so they pass `bearer` explicitly rather than going
|
|
24
|
+
* through `cloud.auth`.
|
|
25
|
+
*
|
|
26
|
+
* `idempotent` marks a call as safe to retry on a transient network error. GET
|
|
27
|
+
* is always treated as idempotent; the full-replace PUT opts in via this flag.
|
|
28
|
+
* POST is never retried by default because it may not be safe to repeat.
|
|
29
|
+
*/
|
|
30
|
+
export interface ReqOpts {
|
|
31
|
+
bearer?: string;
|
|
32
|
+
idempotent?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The REST surface the modules consume. */
|
|
36
|
+
export interface HttpClient {
|
|
37
|
+
get<T>(path: string, opts?: ReqOpts): Promise<T>;
|
|
38
|
+
head(path: string, opts?: ReqOpts): Promise<Response>;
|
|
39
|
+
post<T>(path: string, body?: unknown, opts?: ReqOpts): Promise<T>;
|
|
40
|
+
postForm<T>(path: string, form: FormData, opts?: ReqOpts): Promise<T>;
|
|
41
|
+
put<T>(path: string, body: unknown, opts?: ReqOpts): Promise<T>;
|
|
42
|
+
delete<T>(path: string, opts?: ReqOpts): Promise<T>;
|
|
43
|
+
url(path: string): string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Dependencies for the helper.
|
|
48
|
+
*
|
|
49
|
+
* `getToken` is the default Bearer source (for example
|
|
50
|
+
* `cloud.auth.getRuntimeToken` or `cloud.auth.getCoreToken`).
|
|
51
|
+
* It is optional because the auth module's own `/exchange` and `/refresh` calls
|
|
52
|
+
* run before any access token exists; those calls pass `opts.bearer` directly.
|
|
53
|
+
*/
|
|
54
|
+
export interface CreateHttpClientDeps {
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
// default Bearer source, usually cloud.auth.getRuntimeToken/getCoreToken
|
|
57
|
+
getToken?: () => Promise<string>;
|
|
58
|
+
logger: Logger;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How many times to retry a transient failure on an idempotent call. */
|
|
62
|
+
const MAX_RETRIES = 2;
|
|
63
|
+
/** Base backoff in milliseconds; doubles per attempt (250, 500, ...). */
|
|
64
|
+
const RETRY_BASE_MS = 250;
|
|
65
|
+
|
|
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
|
+
/**
|
|
72
|
+
* Join a base URL and a path without producing a double slash or dropping one.
|
|
73
|
+
*
|
|
74
|
+
* Done by hand (not `new URL`) so a `baseUrl` that already carries a path prefix
|
|
75
|
+
* (for example a proxy mount point) is preserved rather than discarded.
|
|
76
|
+
*/
|
|
77
|
+
function joinUrl(baseUrl: string, path: string): string {
|
|
78
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
79
|
+
const suffix = path.replace(/^\/+/, "");
|
|
80
|
+
return `${base}/${suffix}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createHttpClient(deps: CreateHttpClientDeps): HttpClient {
|
|
84
|
+
const { baseUrl, getToken, logger } = deps;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the Bearer to attach: a per-call override wins, otherwise the
|
|
88
|
+
* default token source. We never log the resolved token: tokens stay out of
|
|
89
|
+
* logs everywhere in this library.
|
|
90
|
+
*/
|
|
91
|
+
async function resolveBearer(opts?: ReqOpts): Promise<string | undefined> {
|
|
92
|
+
if (opts?.bearer) return opts.bearer;
|
|
93
|
+
if (getToken) return await getToken();
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The one retry loop behind every request shape (JSON, form, bodyless).
|
|
99
|
+
*
|
|
100
|
+
* A network-level failure (DNS, reset, timeout) is transient and worth a
|
|
101
|
+
* retry on an idempotent call. A non-2xx response is NOT transient here: it is
|
|
102
|
+
* a definite answer from the server, mapped to an `HttpError` for the caller
|
|
103
|
+
* to branch on (auth handles its own 401 refresh-and-retry a layer up).
|
|
104
|
+
* The thrown exhaustion error keeps the network-error detail out of its
|
|
105
|
+
* message to avoid leaking anything host-specific into a string a host might
|
|
106
|
+
* surface to a user.
|
|
107
|
+
*/
|
|
108
|
+
async function fetchWithRetry(args: {
|
|
109
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
|
|
110
|
+
path: string;
|
|
111
|
+
headers: Record<string, string>;
|
|
112
|
+
body: string | FormData | undefined;
|
|
113
|
+
idempotent: boolean;
|
|
114
|
+
}): Promise<Response> {
|
|
115
|
+
const { method, path, headers, body, idempotent } = args;
|
|
116
|
+
const url = joinUrl(baseUrl, path);
|
|
117
|
+
|
|
118
|
+
for (let attempt = 0; attempt <= (idempotent ? MAX_RETRIES : 0); attempt++) {
|
|
119
|
+
if (attempt > 0) {
|
|
120
|
+
const backoff = RETRY_BASE_MS * 2 ** (attempt - 1);
|
|
121
|
+
logger.debug("http retrying request", { method, path, attempt });
|
|
122
|
+
await delay(backoff);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let res: Response;
|
|
126
|
+
try {
|
|
127
|
+
res = await fetch(url, { method, headers, body });
|
|
128
|
+
} catch {
|
|
129
|
+
// Transient network failure: let the loop retry.
|
|
130
|
+
logger.warn("http network error", { method, path, attempt });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
// Definite answer from the server: map to a typed error, no retry.
|
|
136
|
+
throw await toHttpError(res, method, path);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return res;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Exhausted retries on a transient failure.
|
|
143
|
+
throw new HttpError(
|
|
144
|
+
`Network request failed: ${method} ${path}`,
|
|
145
|
+
0,
|
|
146
|
+
"NETWORK_ERROR",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function requestRaw(
|
|
151
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD",
|
|
152
|
+
path: string,
|
|
153
|
+
body: unknown,
|
|
154
|
+
opts?: ReqOpts,
|
|
155
|
+
): Promise<Response> {
|
|
156
|
+
const bearer = await resolveBearer(opts);
|
|
157
|
+
|
|
158
|
+
const headers: Record<string, string> = {};
|
|
159
|
+
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
|
|
160
|
+
|
|
161
|
+
let payload: string | undefined;
|
|
162
|
+
if (body !== undefined) {
|
|
163
|
+
headers["Content-Type"] = "application/json";
|
|
164
|
+
payload = JSON.stringify(body);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// GET and DELETE are idempotent by HTTP semantics, so safe to retry; other
|
|
168
|
+
// verbs opt in via the flag.
|
|
169
|
+
const idempotent =
|
|
170
|
+
opts?.idempotent ?? (method === "GET" || method === "DELETE" || method === "HEAD");
|
|
171
|
+
|
|
172
|
+
return await fetchWithRetry({ method, path, headers, body: payload, idempotent });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function request<T>(
|
|
176
|
+
method: "GET" | "POST" | "PUT" | "DELETE",
|
|
177
|
+
path: string,
|
|
178
|
+
body: unknown,
|
|
179
|
+
opts?: ReqOpts,
|
|
180
|
+
): Promise<T> {
|
|
181
|
+
const res = await requestRaw(method, path, body, opts);
|
|
182
|
+
return await parseJson<T>(res);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function requestForm<T>(
|
|
186
|
+
path: string,
|
|
187
|
+
form: FormData,
|
|
188
|
+
opts?: ReqOpts,
|
|
189
|
+
): Promise<T> {
|
|
190
|
+
const bearer = await resolveBearer(opts);
|
|
191
|
+
|
|
192
|
+
// No Content-Type here: fetch/FormData must generate the multipart
|
|
193
|
+
// boundary. POST is not idempotent, so retries stay opt-in.
|
|
194
|
+
const headers: Record<string, string> = {};
|
|
195
|
+
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
|
|
196
|
+
|
|
197
|
+
const res = await fetchWithRetry({
|
|
198
|
+
method: "POST",
|
|
199
|
+
path,
|
|
200
|
+
headers,
|
|
201
|
+
body: form,
|
|
202
|
+
idempotent: opts?.idempotent ?? false,
|
|
203
|
+
});
|
|
204
|
+
return await parseJson<T>(res);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Turn a non-2xx response into an `HttpError`, reading a machine-readable
|
|
209
|
+
* `code` from the JSON body when the server provides one. We swallow any body
|
|
210
|
+
* parse failure here because the status is the load-bearing signal and we do
|
|
211
|
+
* not want a malformed error body to mask the real status.
|
|
212
|
+
*/
|
|
213
|
+
async function toHttpError(
|
|
214
|
+
res: Response,
|
|
215
|
+
method: string,
|
|
216
|
+
path: string,
|
|
217
|
+
): Promise<HttpError> {
|
|
218
|
+
let code: string | undefined;
|
|
219
|
+
let detail = "";
|
|
220
|
+
try {
|
|
221
|
+
const data = (await res.json()) as {
|
|
222
|
+
code?: string;
|
|
223
|
+
message?: string;
|
|
224
|
+
error?: string;
|
|
225
|
+
error_description?: string;
|
|
226
|
+
};
|
|
227
|
+
code = data?.code ?? data?.error;
|
|
228
|
+
const message = data?.message ?? data?.error_description;
|
|
229
|
+
detail = message ? `: ${message}` : "";
|
|
230
|
+
} catch {
|
|
231
|
+
// No JSON body, or unparseable: fall back to status alone.
|
|
232
|
+
}
|
|
233
|
+
return new HttpError(
|
|
234
|
+
`HTTP ${res.status} on ${method} ${path}${detail}`,
|
|
235
|
+
res.status,
|
|
236
|
+
code,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Parse a successful response as JSON, tolerating an empty body (a 204 or an
|
|
242
|
+
* endpoint that returns nothing) by resolving to `undefined`.
|
|
243
|
+
*/
|
|
244
|
+
async function parseJson<T>(res: Response): Promise<T> {
|
|
245
|
+
const text = await res.text();
|
|
246
|
+
if (text.length === 0) return undefined as T;
|
|
247
|
+
return JSON.parse(text) as T;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
get<T>(path: string, opts?: ReqOpts): Promise<T> {
|
|
252
|
+
return request<T>("GET", path, undefined, opts);
|
|
253
|
+
},
|
|
254
|
+
head(path: string, opts?: ReqOpts): Promise<Response> {
|
|
255
|
+
return requestRaw("HEAD", path, undefined, opts);
|
|
256
|
+
},
|
|
257
|
+
post<T>(path: string, body?: unknown, opts?: ReqOpts): Promise<T> {
|
|
258
|
+
return request<T>("POST", path, body, opts);
|
|
259
|
+
},
|
|
260
|
+
postForm<T>(path: string, form: FormData, opts?: ReqOpts): Promise<T> {
|
|
261
|
+
return requestForm<T>(path, form, opts);
|
|
262
|
+
},
|
|
263
|
+
put<T>(path: string, body: unknown, opts?: ReqOpts): Promise<T> {
|
|
264
|
+
return request<T>("PUT", path, body, opts);
|
|
265
|
+
},
|
|
266
|
+
delete<T>(path: string, opts?: ReqOpts): Promise<T> {
|
|
267
|
+
return request<T>("DELETE", path, undefined, opts);
|
|
268
|
+
},
|
|
269
|
+
url(path: string): string {
|
|
270
|
+
return joinUrl(baseUrl, path);
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|