@rig-ts/client 0.1.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/dist/index.d.mts +810 -0
- package/dist/index.mjs +994 -0
- package/package.json +33 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
//#region src/credential.ts
|
|
2
|
+
/** Reports whether a credential wants the runtime handed to it. */
|
|
3
|
+
function isBindable(c) {
|
|
4
|
+
return c !== void 0 && typeof c.bind === "function";
|
|
5
|
+
}
|
|
6
|
+
/** Reports whether a credential can answer a 401 with something new. */
|
|
7
|
+
function isReauthorizer(c) {
|
|
8
|
+
return c !== void 0 && typeof c.reauthorize === "function";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A bearer token that never changes.
|
|
12
|
+
*
|
|
13
|
+
* Right for a token from somewhere else — a test fixture, an environment
|
|
14
|
+
* variable, a token minted by the surrounding application. A token that expires
|
|
15
|
+
* wants a `Session`, which refreshes ahead of the expiry rather than discovering
|
|
16
|
+
* it through a failed request.
|
|
17
|
+
*/
|
|
18
|
+
function staticToken(token) {
|
|
19
|
+
return { apply(headers) {
|
|
20
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
21
|
+
} };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* An API key, presented the same way a token is.
|
|
25
|
+
*
|
|
26
|
+
* The server tells the two apart by what the value is, not by how it arrived, so
|
|
27
|
+
* this is a separate name only because a caller holding one should not have to
|
|
28
|
+
* know that. It is {@link staticToken} and not a copy of it: two bodies that
|
|
29
|
+
* have to stay identical is how one of them eventually does not, and there is
|
|
30
|
+
* nothing here that could correctly differ — the day a key travels somewhere a
|
|
31
|
+
* token does not, this stops being an alias and starts being a function.
|
|
32
|
+
*/
|
|
33
|
+
const apiKey = staticToken;
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/rate-limit.ts
|
|
36
|
+
/**
|
|
37
|
+
* The headers a rig server describes a caller's budget with.
|
|
38
|
+
*
|
|
39
|
+
* They go out on every response, not only on a refusal, which is the whole
|
|
40
|
+
* point: a client that can see it is at 900 of 1000 can slow down before it is
|
|
41
|
+
* refused.
|
|
42
|
+
*/
|
|
43
|
+
const RATE_LIMIT_LIMIT = "RateLimit-Limit";
|
|
44
|
+
/** How much of the budget has been spent. */
|
|
45
|
+
const used = (s) => Math.max(s.limit - s.remaining, 0);
|
|
46
|
+
/**
|
|
47
|
+
* How much of the budget is spent, from 0 to 1.
|
|
48
|
+
*
|
|
49
|
+
* It is the number worth alerting on, and it is here rather than left to the
|
|
50
|
+
* caller because the obvious arithmetic divides by zero: a response from a
|
|
51
|
+
* server with no limit configured carries no headers and leaves `limit` at 0.
|
|
52
|
+
*/
|
|
53
|
+
const fraction = (s) => s.limit <= 0 ? 0 : used(s) / s.limit;
|
|
54
|
+
/**
|
|
55
|
+
* Reads the status out of a response, or `undefined` when the server said
|
|
56
|
+
* nothing at all.
|
|
57
|
+
*
|
|
58
|
+
* A server with no `throttle:` block sends none of these headers, and a caller
|
|
59
|
+
* that treated an absent header as zero would read "no budget left" out of a
|
|
60
|
+
* server that has no limits.
|
|
61
|
+
*/
|
|
62
|
+
const rateLimitOf = (op, res) => {
|
|
63
|
+
const limit = readInt(res.headers.get(RATE_LIMIT_LIMIT));
|
|
64
|
+
if (limit === void 0) return void 0;
|
|
65
|
+
return {
|
|
66
|
+
op,
|
|
67
|
+
limit,
|
|
68
|
+
remaining: readInt(res.headers.get("RateLimit-Remaining")) ?? 0,
|
|
69
|
+
resetAfterMs: (readInt(res.headers.get("RateLimit-Reset")) ?? 0) * 1e3,
|
|
70
|
+
refused: res.status === 429
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const readInt = (raw) => {
|
|
74
|
+
if (raw === null || raw.trim() === "") return void 0;
|
|
75
|
+
const n = Number(raw);
|
|
76
|
+
return Number.isInteger(n) && n >= 0 ? n : void 0;
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/runtime.ts
|
|
80
|
+
/** Where the generated server looks for a caller's own request identifier. */
|
|
81
|
+
const DEFAULT_REQUEST_ID_HEADER = "X-Request-Id";
|
|
82
|
+
/**
|
|
83
|
+
* Carries the API revision, and is what `rig.yaml`'s `api.revision_header`
|
|
84
|
+
* defaults to. A generated client passes its project's own, so this is the
|
|
85
|
+
* fallback for a client somebody built by hand.
|
|
86
|
+
*/
|
|
87
|
+
const DEFAULT_REVISION_HEADER = "API-Revision";
|
|
88
|
+
/**
|
|
89
|
+
* One client's configuration and the state it accumulates.
|
|
90
|
+
*
|
|
91
|
+
* The remembered QUERY decision is the reason this is state rather than
|
|
92
|
+
* configuration: a client that learned an intermediary refuses QUERY should not
|
|
93
|
+
* have to learn it again on the next search.
|
|
94
|
+
*/
|
|
95
|
+
var Runtime = class {
|
|
96
|
+
api;
|
|
97
|
+
fetch;
|
|
98
|
+
now;
|
|
99
|
+
retry;
|
|
100
|
+
timeoutMs;
|
|
101
|
+
/**
|
|
102
|
+
* Where a caller's own request identifier is sent. Readable rather than
|
|
103
|
+
* private because the transport writes to it too: a call that names its own
|
|
104
|
+
* identifier has to land in the header this client was configured with, or a
|
|
105
|
+
* deployment that moved the header gets two of them disagreeing.
|
|
106
|
+
*/
|
|
107
|
+
requestIdHeader;
|
|
108
|
+
/** The randomness in a backoff, held here so a test can make one deterministic. */
|
|
109
|
+
jitter = (n) => Math.floor(Math.random() * n);
|
|
110
|
+
baseUrl;
|
|
111
|
+
headers;
|
|
112
|
+
requestIdOf;
|
|
113
|
+
revision;
|
|
114
|
+
revisionHeader;
|
|
115
|
+
credential;
|
|
116
|
+
onRateLimit;
|
|
117
|
+
/** Records that QUERY was refused once and is not worth trying again. */
|
|
118
|
+
searchByPost = false;
|
|
119
|
+
constructor(config, api) {
|
|
120
|
+
if (config.baseUrl === void 0) throw new Error("@rig-ts/client: a baseUrl is required");
|
|
121
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
122
|
+
this.api = api;
|
|
123
|
+
this.headers = new Headers(config.headers);
|
|
124
|
+
this.credential = config.credential;
|
|
125
|
+
this.requestIdOf = config.requestId;
|
|
126
|
+
this.requestIdHeader = config.requestIdHeader ?? "X-Request-Id";
|
|
127
|
+
this.revision = config.revision ?? api.revision ?? "";
|
|
128
|
+
this.revisionHeader = config.revisionHeader ?? api.revisionHeader ?? "API-Revision";
|
|
129
|
+
this.fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
130
|
+
this.now = config.now ?? Date.now;
|
|
131
|
+
this.retry = config.retry ?? {};
|
|
132
|
+
this.timeoutMs = config.timeoutMs;
|
|
133
|
+
this.onRateLimit = config.onRateLimit;
|
|
134
|
+
if (isBindable(this.credential)) this.credential.bind(this);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Hands the caller's budget to the configured callback, if the response said
|
|
138
|
+
* anything about it and anybody is listening.
|
|
139
|
+
*
|
|
140
|
+
* A throwing callback must not fail the call it was observing: it is
|
|
141
|
+
* telemetry about a request that otherwise succeeded, and turning a bad
|
|
142
|
+
* gauge into a failed write would be the wrong trade in both directions.
|
|
143
|
+
*/
|
|
144
|
+
observeRateLimit(op, res) {
|
|
145
|
+
if (this.onRateLimit === void 0) return;
|
|
146
|
+
const status = rateLimitOf(op, res);
|
|
147
|
+
if (status === void 0) return;
|
|
148
|
+
try {
|
|
149
|
+
this.onRateLimit(status);
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
/** The origin requests go to. */
|
|
153
|
+
get origin() {
|
|
154
|
+
return this.baseUrl;
|
|
155
|
+
}
|
|
156
|
+
/** The credential in force, or `undefined`. */
|
|
157
|
+
getCredential() {
|
|
158
|
+
return this.credential;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Installs a credential, replacing whatever was there. It is what signing in
|
|
162
|
+
* does, and what a caller does by hand when they already hold a token from
|
|
163
|
+
* somewhere else.
|
|
164
|
+
*/
|
|
165
|
+
use(credential) {
|
|
166
|
+
this.credential = credential;
|
|
167
|
+
if (isBindable(credential)) credential.bind(this);
|
|
168
|
+
}
|
|
169
|
+
/** Whether this client has learned that QUERY does not get through. */
|
|
170
|
+
searchesByPost() {
|
|
171
|
+
return this.searchByPost;
|
|
172
|
+
}
|
|
173
|
+
/** Remembers a refused QUERY, so it is tried once per client and not once per call. */
|
|
174
|
+
rememberSearchByPost() {
|
|
175
|
+
this.searchByPost = true;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The absolute URL for an operation.
|
|
179
|
+
*
|
|
180
|
+
* `extra` is what a call option added, and it wins where the two name the
|
|
181
|
+
* same parameter: the operation's own query came from the typed arguments of
|
|
182
|
+
* a generated method, and an option is the caller saying something about
|
|
183
|
+
* this one call afterwards.
|
|
184
|
+
*/
|
|
185
|
+
url(path, query, extra, root) {
|
|
186
|
+
const base = root ? "" : this.api.basePath;
|
|
187
|
+
let out = `${this.baseUrl}${base}${path}`;
|
|
188
|
+
const merged = new URLSearchParams(query);
|
|
189
|
+
if (extra !== void 0) for (const [key, value] of extra) merged.set(key, value);
|
|
190
|
+
const search = merged.toString();
|
|
191
|
+
if (search !== "") out += `?${search}`;
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/** The headers every request from this client carries, before the call's own. */
|
|
195
|
+
baseHeaders() {
|
|
196
|
+
const headers = new Headers(this.headers);
|
|
197
|
+
if (this.revision !== "") headers.set(this.revisionHeader, this.revision);
|
|
198
|
+
if (this.requestIdOf !== void 0) {
|
|
199
|
+
const id = this.requestIdOf();
|
|
200
|
+
if (id !== "") headers.set(this.requestIdHeader, id);
|
|
201
|
+
}
|
|
202
|
+
return headers;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/errors.ts
|
|
207
|
+
/**
|
|
208
|
+
* A request the server refused.
|
|
209
|
+
*
|
|
210
|
+
* The code, not the status, is what to switch on. Three unrelated failures
|
|
211
|
+
* share a 400 and none of them share a code, which is the whole reason the
|
|
212
|
+
* generated server sends one.
|
|
213
|
+
*
|
|
214
|
+
* `TFields` is the shape of the body that caused the refusal. A caller does not
|
|
215
|
+
* name it: the generated client declares a guard per call that does, so
|
|
216
|
+
* `isTodoCreateError(err)` reads back what `todos.create` refused. Naming it by
|
|
217
|
+
* hand is what `fieldsAs` asks for, and it is why the per-call guard exists —
|
|
218
|
+
* the wrong shape decodes perfectly and answers with an empty object, because
|
|
219
|
+
* every member of a field-error shape is optional.
|
|
220
|
+
*/
|
|
221
|
+
var RigError = class extends Error {
|
|
222
|
+
/**
|
|
223
|
+
* The HTTP status, for the cases where only it is meaningful — a 502 from
|
|
224
|
+
* something in front of the server, say, which carries no code.
|
|
225
|
+
*/
|
|
226
|
+
status;
|
|
227
|
+
/**
|
|
228
|
+
* The machine-readable reason. Empty when the failure came from something
|
|
229
|
+
* that is not a rig server.
|
|
230
|
+
*/
|
|
231
|
+
code;
|
|
232
|
+
/** Prose, for a person. It is not meant to be parsed. */
|
|
233
|
+
detail;
|
|
234
|
+
/**
|
|
235
|
+
* Correlates this failure with the server's logs. Quoting it in a bug
|
|
236
|
+
* report is the difference between a search and a guess.
|
|
237
|
+
*/
|
|
238
|
+
requestId;
|
|
239
|
+
/**
|
|
240
|
+
* What was wrong with each member of the body — one member per field,
|
|
241
|
+
* holding what was wrong with it.
|
|
242
|
+
*
|
|
243
|
+
* `undefined` for every refusal but a 422. A 404 has a code and a message
|
|
244
|
+
* and nothing to put beside a control, and an empty object there would read
|
|
245
|
+
* as a body nobody complained about.
|
|
246
|
+
*/
|
|
247
|
+
fields;
|
|
248
|
+
/**
|
|
249
|
+
* How long the server asked the caller to wait, in milliseconds, from the
|
|
250
|
+
* header of the same name in either form it takes: the seconds rig's own
|
|
251
|
+
* server sends, and the date something in front of it might. Zero when it
|
|
252
|
+
* said nothing, or asked for a moment already past.
|
|
253
|
+
*
|
|
254
|
+
* The SDK honours it for a call it may repeat, so this is mostly for the
|
|
255
|
+
* refusal that came back anyway — where the interval was longer than the
|
|
256
|
+
* call had left to spend.
|
|
257
|
+
*/
|
|
258
|
+
retryAfterMs;
|
|
259
|
+
/**
|
|
260
|
+
* The start of the raw response, kept for a failure that decoded into
|
|
261
|
+
* nothing useful — a proxy's HTML error page, say. Bounded even where the
|
|
262
|
+
* read was not, so a large validation failure is complete in `fields` and
|
|
263
|
+
* cut short here.
|
|
264
|
+
*/
|
|
265
|
+
body;
|
|
266
|
+
constructor(init) {
|
|
267
|
+
super(describe(init.status, init.code ?? "", init.detail ?? "", init.requestId ?? ""));
|
|
268
|
+
this.name = "RigError";
|
|
269
|
+
this.status = init.status;
|
|
270
|
+
this.code = init.code ?? "";
|
|
271
|
+
this.detail = init.detail ?? "";
|
|
272
|
+
this.requestId = init.requestId ?? "";
|
|
273
|
+
this.fields = init.fields;
|
|
274
|
+
this.retryAfterMs = init.retryAfterMs ?? 0;
|
|
275
|
+
this.body = init.body ?? "";
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* The codes a rig server sends, matching `rig/runtime/rigerr`. The constant a
|
|
280
|
+
* client switches on is the constant a handler returned.
|
|
281
|
+
*/
|
|
282
|
+
const ErrorCode = {
|
|
283
|
+
BadRequest: "BadRequest",
|
|
284
|
+
Unauthorized: "Unauthorized",
|
|
285
|
+
Forbidden: "Forbidden",
|
|
286
|
+
NotFound: "NotFound",
|
|
287
|
+
Conflict: "Conflict",
|
|
288
|
+
UnprocessableEntity: "UnprocessableEntity",
|
|
289
|
+
RateLimited: "RateLimited",
|
|
290
|
+
TooLarge: "TooLarge",
|
|
291
|
+
UnsupportedMediaType: "UnsupportedMediaType",
|
|
292
|
+
UpgradeRequired: "UpgradeRequired",
|
|
293
|
+
Internal: "Internal"
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Why one member of a body was refused, matching `rig/runtime/rigerr`.
|
|
297
|
+
*
|
|
298
|
+
* The same nine codes for every project, so a form can decide what to show from
|
|
299
|
+
* the code and fall back to the message rather than parsing it.
|
|
300
|
+
*/
|
|
301
|
+
const FieldCode = {
|
|
302
|
+
CannotBeEmpty: "CannotBeEmpty",
|
|
303
|
+
CannotBeNull: "CannotBeNull",
|
|
304
|
+
TooLong: "TooLong",
|
|
305
|
+
TooShort: "TooShort",
|
|
306
|
+
OutOfRange: "OutOfRange",
|
|
307
|
+
InvalidValue: "InvalidValue",
|
|
308
|
+
AlreadyExists: "AlreadyExists",
|
|
309
|
+
NotFound: "NotFound",
|
|
310
|
+
NotAllowed: "NotAllowed"
|
|
311
|
+
};
|
|
312
|
+
/**
|
|
313
|
+
* Reports whether a thrown value is a refusal the server explained.
|
|
314
|
+
*
|
|
315
|
+
* False for anything that never reached the server — a DNS failure, an aborted
|
|
316
|
+
* request — because there is no envelope for a code or a field to have come
|
|
317
|
+
* from.
|
|
318
|
+
*/
|
|
319
|
+
function isRigError(err) {
|
|
320
|
+
return err instanceof RigError;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The code on a refusal, or the empty string for anything else.
|
|
324
|
+
*
|
|
325
|
+
* The predicates below are one line each on top of it; reach for it directly
|
|
326
|
+
* when switching over several codes at once.
|
|
327
|
+
*/
|
|
328
|
+
function codeOf(err) {
|
|
329
|
+
return isRigError(err) ? err.code : "";
|
|
330
|
+
}
|
|
331
|
+
/** True when the row, or the route, was not there. */
|
|
332
|
+
const isNotFound = (err) => codeOf(err) === ErrorCode.NotFound;
|
|
333
|
+
/** True when the write lost a race, or would have broken a uniqueness rule. */
|
|
334
|
+
const isConflict = (err) => codeOf(err) === ErrorCode.Conflict;
|
|
335
|
+
/** True when the caller was not signed in, or the token had expired. */
|
|
336
|
+
const isUnauthorized = (err) => codeOf(err) === ErrorCode.Unauthorized;
|
|
337
|
+
/** True when the caller was signed in and still not allowed. */
|
|
338
|
+
const isForbidden = (err) => codeOf(err) === ErrorCode.Forbidden;
|
|
339
|
+
/** True when the body was refused field by field. `fields` says which. */
|
|
340
|
+
const isInvalid = (err) => codeOf(err) === ErrorCode.UnprocessableEntity;
|
|
341
|
+
/** True when the caller was asked to slow down. `retryAfterMs` says how long. */
|
|
342
|
+
const isRateLimited = (err) => codeOf(err) === ErrorCode.RateLimited;
|
|
343
|
+
/** True when the body, or the upload, was over the limit. */
|
|
344
|
+
const isTooLarge = (err) => codeOf(err) === ErrorCode.TooLarge;
|
|
345
|
+
/** True when the content type was not one this route accepts. */
|
|
346
|
+
const isUnsupportedMediaType = (err) => codeOf(err) === ErrorCode.UnsupportedMediaType;
|
|
347
|
+
/** True when the client is older than the API surface still supports. */
|
|
348
|
+
const isUpgradeRequired = (err) => codeOf(err) === ErrorCode.UpgradeRequired;
|
|
349
|
+
/**
|
|
350
|
+
* Reads a refusal back as the shape of the body that caused it.
|
|
351
|
+
*
|
|
352
|
+
* This is the hand-written counterpart of the generated per-call guards, for a
|
|
353
|
+
* request made through the runtime directly. It cannot check that the shape is
|
|
354
|
+
* the right one — that is what the generated guard is for.
|
|
355
|
+
*/
|
|
356
|
+
function fieldsAs(err) {
|
|
357
|
+
return isRigError(err) ? err.fields : void 0;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The longest response kept on {@link RigError.body}. Enough to recognise a
|
|
361
|
+
* proxy's error page, short enough that it does not become the log line.
|
|
362
|
+
*/
|
|
363
|
+
const MAX_ERROR_BODY = 8 * 1024;
|
|
364
|
+
/**
|
|
365
|
+
* Reads a refused response into an error.
|
|
366
|
+
*
|
|
367
|
+
* The response is consumed here, whatever the content type — a caller that got
|
|
368
|
+
* this far is not going to read the body a second way.
|
|
369
|
+
*
|
|
370
|
+
* `requestIdHeader` is the name the client is configured with, passed in rather
|
|
371
|
+
* than written here: a project that renamed the header on the way out was still
|
|
372
|
+
* having the answer read back under the default name, so the identifier went
|
|
373
|
+
* missing from every refusal in exactly the projects that cared enough to
|
|
374
|
+
* rename it.
|
|
375
|
+
*/
|
|
376
|
+
async function readError(res, nowMs, requestIdHeader) {
|
|
377
|
+
const headerRequestId = res.headers.get(requestIdHeader) ?? "";
|
|
378
|
+
const retryAfterMs = parseRetryAfter(res.headers.get("Retry-After"), nowMs);
|
|
379
|
+
let raw = "";
|
|
380
|
+
try {
|
|
381
|
+
raw = await res.text();
|
|
382
|
+
} catch {}
|
|
383
|
+
const envelope = isJson(res.headers.get("Content-Type")) ? decode(raw) : void 0;
|
|
384
|
+
return new RigError({
|
|
385
|
+
status: res.status,
|
|
386
|
+
code: envelope?.code ?? "",
|
|
387
|
+
detail: envelope?.message ?? "",
|
|
388
|
+
requestId: envelope?.request_id ?? headerRequestId,
|
|
389
|
+
...envelope?.fields !== void 0 ? { fields: envelope.fields } : {},
|
|
390
|
+
retryAfterMs,
|
|
391
|
+
body: raw.slice(0, MAX_ERROR_BODY)
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
function decode(raw) {
|
|
395
|
+
if (raw === "") return void 0;
|
|
396
|
+
try {
|
|
397
|
+
const parsed = JSON.parse(raw);
|
|
398
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
399
|
+
return parsed;
|
|
400
|
+
} catch {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function isJson(contentType) {
|
|
405
|
+
if (contentType === null) return false;
|
|
406
|
+
const media = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
407
|
+
return media === "application/json" || media.endsWith("+json");
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Reads Retry-After in either form the specification allows: a count of seconds,
|
|
411
|
+
* or an HTTP date. An interval already past is zero rather than negative.
|
|
412
|
+
*/
|
|
413
|
+
function parseRetryAfter(value, nowMs) {
|
|
414
|
+
if (value === null || value.trim() === "") return 0;
|
|
415
|
+
const seconds = Number(value.trim());
|
|
416
|
+
if (Number.isFinite(seconds)) return seconds > 0 ? seconds * 1e3 : 0;
|
|
417
|
+
const at = Date.parse(value);
|
|
418
|
+
if (Number.isNaN(at)) return 0;
|
|
419
|
+
return Math.max(0, at - nowMs);
|
|
420
|
+
}
|
|
421
|
+
function describe(status, code, detail, requestId) {
|
|
422
|
+
let out = "rig: ";
|
|
423
|
+
if (code !== "") out += `${code}: `;
|
|
424
|
+
out += detail !== "" ? detail : `HTTP ${status}`;
|
|
425
|
+
out += ` (${status})`;
|
|
426
|
+
if (requestId !== "") out += ` [request ${requestId}]`;
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/op.ts
|
|
431
|
+
/**
|
|
432
|
+
* The HTTP method a generated search uses.
|
|
433
|
+
*
|
|
434
|
+
* A method rather than a POST because a search is a read: it has a body, and it
|
|
435
|
+
* is safe and idempotent, and pretending otherwise is what made every API in the
|
|
436
|
+
* world invent `/_search`.
|
|
437
|
+
*/
|
|
438
|
+
const METHOD_QUERY = "QUERY";
|
|
439
|
+
/**
|
|
440
|
+
* Reports whether this operation may be sent twice.
|
|
441
|
+
*
|
|
442
|
+
* Asked of the operation the generated method built, before a refused QUERY is
|
|
443
|
+
* rewritten: a deployment whose proxy refuses QUERY is exactly the one whose
|
|
444
|
+
* searches most need retrying, and keying on the wire method would quietly take
|
|
445
|
+
* that away.
|
|
446
|
+
*
|
|
447
|
+
* A delete is in, on the strength of what it leaves behind rather than what it
|
|
448
|
+
* answers. If the first attempt succeeded and its answer was lost, the second
|
|
449
|
+
* one is a 404 — so a delete that worked can come back `isNotFound`. The row is
|
|
450
|
+
* gone either way, and a duplicated create is not recoverable that way, which is
|
|
451
|
+
* the whole of the distinction.
|
|
452
|
+
*/
|
|
453
|
+
function isIdempotent(op) {
|
|
454
|
+
return op.method === "GET" || op.method === "HEAD" || op.method === "DELETE" || op.method === "QUERY";
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Reports whether this operation can produce something a second one would
|
|
458
|
+
* duplicate, and so whether it is worth naming with an idempotency key.
|
|
459
|
+
*
|
|
460
|
+
* A form is excluded, and that is about the server rather than about the client.
|
|
461
|
+
* An upload route is the one write a rig server does not record against a key:
|
|
462
|
+
* its body is still arriving when the service is called, and a transaction held
|
|
463
|
+
* open for a transfer is a pooled connection held open for a transfer. So a key
|
|
464
|
+
* generated here would name a write nobody wrote down, and repeating it would
|
|
465
|
+
* store the file twice.
|
|
466
|
+
*/
|
|
467
|
+
function writes(op) {
|
|
468
|
+
if (op.form !== void 0) return false;
|
|
469
|
+
return op.method === "POST" || op.method === "PATCH" || op.method === "PUT";
|
|
470
|
+
}
|
|
471
|
+
/** The same operation addressed to the alias route. */
|
|
472
|
+
function asPost(op) {
|
|
473
|
+
const { fallback, ...rest } = op;
|
|
474
|
+
return {
|
|
475
|
+
...rest,
|
|
476
|
+
method: "POST",
|
|
477
|
+
path: fallback ?? op.path
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* How long to wait before the next attempt, where `attempt` is the one that just
|
|
482
|
+
* failed.
|
|
483
|
+
*
|
|
484
|
+
* `afterMs` is what the server asked for, and it wins outright: `Retry-After` is
|
|
485
|
+
* not a hint, it is the interval after which the request stops being refused,
|
|
486
|
+
* and guessing a shorter one spends the next attempt on the same refusal. It is
|
|
487
|
+
* not jittered, because it is a boundary rather than a guess — and it also
|
|
488
|
+
* cancels the immediate first retry, because a server that has just said when to
|
|
489
|
+
* come back has answered that question.
|
|
490
|
+
*
|
|
491
|
+
* It is returned whole rather than clamped to {@link MAX_RETRY_AFTER_MS}.
|
|
492
|
+
* Clamping would mean going back before the server said to, which is a request
|
|
493
|
+
* refused twice; an interval too long to agree to is one the caller is told
|
|
494
|
+
* about instead.
|
|
495
|
+
*/
|
|
496
|
+
function retryDelayMs(retry, attempt, afterMs, jitter) {
|
|
497
|
+
if (afterMs > 0) return afterMs;
|
|
498
|
+
if (attempt <= 1) return 0;
|
|
499
|
+
const cap = retry.capMs ?? 2e3;
|
|
500
|
+
const base = retry.baseMs ?? 1e3;
|
|
501
|
+
const window = Math.min(base * 2 ** Math.min(attempt - 2, 32), cap);
|
|
502
|
+
const half = Math.floor(window / 2);
|
|
503
|
+
return half + jitter(half + 1);
|
|
504
|
+
}
|
|
505
|
+
/** How many attempts this configuration allows. */
|
|
506
|
+
function attemptsOf(retry) {
|
|
507
|
+
const n = retry.attempts ?? 0;
|
|
508
|
+
return n > 0 ? n : 4;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Reports whether a status is worth sending the same request for again.
|
|
512
|
+
*
|
|
513
|
+
* A list rather than `status >= 500`. A 501 is excluded deliberately: it is the
|
|
514
|
+
* QUERY fallback's own signal, and a method nobody in the chain has heard of
|
|
515
|
+
* will not have been heard of a second later. A 505 is about the protocol, and
|
|
516
|
+
* no wait fixes that either.
|
|
517
|
+
*/
|
|
518
|
+
function retryable(status) {
|
|
519
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
520
|
+
}
|
|
521
|
+
/** Whether a wait of `ms` still leaves time to make the attempt after it. */
|
|
522
|
+
function budgetAllows(budget, ms) {
|
|
523
|
+
if (budget.deadlineMs === void 0) return true;
|
|
524
|
+
return budget.now() + ms < budget.deadlineMs;
|
|
525
|
+
}
|
|
526
|
+
/** What one attempt may spend, or `undefined` when the call has no ceiling. */
|
|
527
|
+
function budgetLeash(budget) {
|
|
528
|
+
if (budget.deadlineMs === void 0) return void 0;
|
|
529
|
+
return Math.max(0, budget.deadlineMs - budget.now());
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Waits, unless the caller's signal goes first.
|
|
533
|
+
*
|
|
534
|
+
* Rejects with the reason the wait was abandoned rather than with a timer
|
|
535
|
+
* error, so an aborted call reports what aborted it.
|
|
536
|
+
*/
|
|
537
|
+
function waitFor(ms, signal) {
|
|
538
|
+
if (ms <= 0) return Promise.resolve();
|
|
539
|
+
return new Promise((resolve, reject) => {
|
|
540
|
+
if (signal?.aborted === true) {
|
|
541
|
+
reject(signal.reason);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const timer = setTimeout(() => {
|
|
545
|
+
signal?.removeEventListener("abort", onAbort);
|
|
546
|
+
resolve();
|
|
547
|
+
}, ms);
|
|
548
|
+
function onAbort() {
|
|
549
|
+
clearTimeout(timer);
|
|
550
|
+
reject(signal?.reason);
|
|
551
|
+
}
|
|
552
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/transport.ts
|
|
557
|
+
/** The query-string key that widens an owner-scoped read. */
|
|
558
|
+
const SCOPE_PARAM = "scope";
|
|
559
|
+
/**
|
|
560
|
+
* Performs a call the document says answers with a body, and decodes it.
|
|
561
|
+
*
|
|
562
|
+
* A 204 where a body was promised throws rather than resolving to `undefined`.
|
|
563
|
+
* That is a deliberate trade against {@link sendOptional}: the alternative is
|
|
564
|
+
* every generated method answering `T | undefined`, so every call site narrows a
|
|
565
|
+
* value that in practice is always there — and the one time it is not, the
|
|
566
|
+
* server and the document disagree, which is a bug and reads better as one.
|
|
567
|
+
*/
|
|
568
|
+
async function send(rt, op, opts = {}) {
|
|
569
|
+
const out = await sendOptional(rt, op, opts);
|
|
570
|
+
if (out === void 0) throw new Error(`@rig-ts/client: ${op.method} ${op.path} answered with no body, but the API document says it has one`);
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Performs a call and decodes the response body, or `undefined` when there was
|
|
575
|
+
* none.
|
|
576
|
+
*
|
|
577
|
+
* This is the shape for an endpoint that can honestly answer either way, and for
|
|
578
|
+
* a request made through the runtime by hand.
|
|
579
|
+
*/
|
|
580
|
+
async function sendOptional(rt, op, opts = {}) {
|
|
581
|
+
const res = await call(rt, op, opts);
|
|
582
|
+
if (res.status === 204 || res.status === 304) return void 0;
|
|
583
|
+
const raw = await res.text();
|
|
584
|
+
if (raw === "") return void 0;
|
|
585
|
+
try {
|
|
586
|
+
return JSON.parse(raw);
|
|
587
|
+
} catch (cause) {
|
|
588
|
+
throw new Error(`@rig-ts/client: reading the response to ${op.method} ${op.path}`, { cause });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Performs a call that answers with nothing, such as a delete.
|
|
593
|
+
*
|
|
594
|
+
* Any body is drained and discarded rather than parsed: an endpoint that grows
|
|
595
|
+
* one later should not break a client that never wanted it.
|
|
596
|
+
*/
|
|
597
|
+
async function sendNoContent(rt, op, opts = {}) {
|
|
598
|
+
await (await call(rt, op, opts)).arrayBuffer().catch(() => void 0);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Performs a call and hands back the response unread, for a download.
|
|
602
|
+
*
|
|
603
|
+
* The caller owns the body from here: nothing below reads it, so streaming a
|
|
604
|
+
* large file does not require buffering it first.
|
|
605
|
+
*/
|
|
606
|
+
async function sendContent(rt, op, opts = {}) {
|
|
607
|
+
return await call(rt, op, opts);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Sends the request, handling the three things every call shares: a credential
|
|
611
|
+
* that may need refreshing, a QUERY an intermediary may refuse, and a failure
|
|
612
|
+
* the server has asked to be given another go at.
|
|
613
|
+
*
|
|
614
|
+
* The three re-sends are counted separately, deliberately. The fallback happens
|
|
615
|
+
* at most once and is the same request addressed differently; the
|
|
616
|
+
* reauthorization happens at most once and is the same request with a different
|
|
617
|
+
* credential; only the retry is the same request in the same words, and only it
|
|
618
|
+
* spends the attempt budget. Counting them together would leave a search behind
|
|
619
|
+
* a refusing proxy with one retry fewer than the read beside it, for a reason
|
|
620
|
+
* nobody looking at either could see.
|
|
621
|
+
*/
|
|
622
|
+
async function call(rt, initial, opts) {
|
|
623
|
+
let op = initial;
|
|
624
|
+
let repeatable = isIdempotent(op);
|
|
625
|
+
const retry = {
|
|
626
|
+
...rt.retry,
|
|
627
|
+
...opts.attempts !== void 0 ? { attempts: opts.attempts } : {}
|
|
628
|
+
};
|
|
629
|
+
const budget = budgetFor(rt, opts);
|
|
630
|
+
const headers = new Headers(opts.headers);
|
|
631
|
+
const attempts = attemptsOf(retry);
|
|
632
|
+
const ladder = {
|
|
633
|
+
rt,
|
|
634
|
+
opts,
|
|
635
|
+
retry,
|
|
636
|
+
budget,
|
|
637
|
+
attempts
|
|
638
|
+
};
|
|
639
|
+
if (!repeatable && writes(op) && attempts > 1) {
|
|
640
|
+
const key = opts.idempotencyKey ?? headers.get("Idempotency-Key") ?? newIdempotencyKey();
|
|
641
|
+
if (key !== "") {
|
|
642
|
+
headers.set("Idempotency-Key", key);
|
|
643
|
+
repeatable = true;
|
|
644
|
+
}
|
|
645
|
+
} else if (opts.idempotencyKey !== void 0) headers.set("Idempotency-Key", opts.idempotencyKey);
|
|
646
|
+
if (op.method === "QUERY" && op.fallback !== void 0 && rt.searchesByPost()) op = asPost(op);
|
|
647
|
+
let attempt = 1;
|
|
648
|
+
let fellBack = false;
|
|
649
|
+
let reauthorized = false;
|
|
650
|
+
for (;;) {
|
|
651
|
+
let res;
|
|
652
|
+
try {
|
|
653
|
+
res = await attemptOnce(rt, op, opts, headers, budgetLeash(budget));
|
|
654
|
+
} catch (err) {
|
|
655
|
+
attempt = await backoffOrThrow(ladder, attempt, repeatable && !isAbort(err, opts.signal), 0, err);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (!fellBack && op.method === "QUERY" && op.fallback !== void 0 && (res.status === 405 || res.status === 501)) {
|
|
659
|
+
await res.arrayBuffer().catch(() => void 0);
|
|
660
|
+
rt.rememberSearchByPost();
|
|
661
|
+
fellBack = true;
|
|
662
|
+
op = asPost(op);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (res.status === 401 && !reauthorized) {
|
|
666
|
+
const cred = rt.getCredential();
|
|
667
|
+
if (isReauthorizer(cred) && opts.anonymous !== true) {
|
|
668
|
+
const refusal = await readError(res, rt.now(), rt.requestIdHeader);
|
|
669
|
+
if (!await cred.reauthorize(opts.signal)) throw refusal;
|
|
670
|
+
reauthorized = true;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (res.status === 304 && opts.ifNoneMatch !== void 0) return res;
|
|
675
|
+
if (res.status < 200 || res.status > 299) {
|
|
676
|
+
const refusal = await readError(res, rt.now(), rt.requestIdHeader);
|
|
677
|
+
attempt = await backoffOrThrow(ladder, attempt, repeatable && retryable(res.status), refusal.retryAfterMs, refusal);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
return res;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Waits for the gap before the next send, and answers what attempt it is.
|
|
685
|
+
*
|
|
686
|
+
* Throws `cause` — the caller's own error, never one invented here — when there
|
|
687
|
+
* is no next send: nothing left to repeat, no attempts left, or no room in the
|
|
688
|
+
* budget. So the caller gets the transport's own failure, or the server's own
|
|
689
|
+
* refusal with `retryAfterMs` still on it, rather than a timeout from this
|
|
690
|
+
* clock blaming it for somebody else's outage.
|
|
691
|
+
*
|
|
692
|
+
* The two call sites disagree about exactly two things, and those are the two
|
|
693
|
+
* parameters: what makes a failure worth repeating at all — an abort is the
|
|
694
|
+
* transport's own answer and a 400 is the server's — and what the server asked
|
|
695
|
+
* to be waited. Everything under them is one ladder: the attempt ceiling, the
|
|
696
|
+
* delay, the budget, the wait, the increment. It is written once because a
|
|
697
|
+
* retry engine whose two halves count differently is a bug that surfaces as a
|
|
698
|
+
* call giving up one send early, on the path nobody was looking at.
|
|
699
|
+
*
|
|
700
|
+
* A `retryAfterMs` of 0 is a wait nobody asked for, which is why the ceiling
|
|
701
|
+
* test below is safe for the transport-throw site that passes it: no server
|
|
702
|
+
* said anything, so there is nothing to refuse.
|
|
703
|
+
*/
|
|
704
|
+
async function backoffOrThrow(ladder, attempt, worthRepeating, retryAfterMs, cause) {
|
|
705
|
+
const { rt, opts, retry, budget, attempts } = ladder;
|
|
706
|
+
if (!worthRepeating || attempt >= attempts) throw cause;
|
|
707
|
+
const wait = retryDelayMs(retry, attempt, retryAfterMs, rt.jitter);
|
|
708
|
+
if (retryAfterMs > 3e4 || !budgetAllows(budget, wait)) throw cause;
|
|
709
|
+
await waitFor(wait, opts.signal);
|
|
710
|
+
return attempt + 1;
|
|
711
|
+
}
|
|
712
|
+
/** Builds and performs one HTTP request. */
|
|
713
|
+
async function attemptOnce(rt, op, opts, callHeaders, leashMs) {
|
|
714
|
+
if (op.body !== void 0 && op.form !== void 0) throw new Error(`@rig-ts/client: ${op.method} ${op.path} carries both a JSON body and a form; a generated method sends one or the other`);
|
|
715
|
+
const headers = rt.baseHeaders();
|
|
716
|
+
for (const [key, value] of callHeaders) headers.set(key, value);
|
|
717
|
+
headers.set("Accept", op.accept ?? "application/json");
|
|
718
|
+
if (opts.requestId !== void 0) headers.set(rt.requestIdHeader, opts.requestId);
|
|
719
|
+
if (opts.ifNoneMatch !== void 0) headers.set("If-None-Match", opts.ifNoneMatch);
|
|
720
|
+
let body;
|
|
721
|
+
if (op.form !== void 0) body = op.form;
|
|
722
|
+
else if (op.body !== void 0) {
|
|
723
|
+
headers.set("Content-Type", "application/json");
|
|
724
|
+
body = JSON.stringify(op.body);
|
|
725
|
+
}
|
|
726
|
+
if (opts.anonymous !== true) await rt.getCredential()?.apply(headers, opts.signal);
|
|
727
|
+
const extra = new URLSearchParams(opts.query);
|
|
728
|
+
if (opts.wide === true) extra.set(SCOPE_PARAM, "all");
|
|
729
|
+
const url = rt.url(op.path, op.query, extra, op.root === true);
|
|
730
|
+
const signal = leashMs === void 0 ? opts.signal : combine(opts.signal, leashMs);
|
|
731
|
+
const res = await rt.fetch(url, {
|
|
732
|
+
method: op.method,
|
|
733
|
+
headers,
|
|
734
|
+
...body !== void 0 ? { body } : {},
|
|
735
|
+
...signal !== void 0 ? { signal } : {}
|
|
736
|
+
});
|
|
737
|
+
rt.observeRateLimit(op.name, res);
|
|
738
|
+
return res;
|
|
739
|
+
}
|
|
740
|
+
/** What is left of the time this call was given. */
|
|
741
|
+
function budgetFor(rt, opts) {
|
|
742
|
+
const ms = opts.timeoutMs ?? rt.timeoutMs;
|
|
743
|
+
return {
|
|
744
|
+
deadlineMs: ms === void 0 ? void 0 : rt.now() + ms,
|
|
745
|
+
now: rt.now
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* A key that names this write.
|
|
750
|
+
*
|
|
751
|
+
* Generated rather than asked for, because a key a caller has to remember is a
|
|
752
|
+
* key most callers will not send, and then the retry they were counting on is
|
|
753
|
+
* the one thing this SDK will not do for them. It is per call and not per
|
|
754
|
+
* attempt: the point is for the second send to be recognisable as the first.
|
|
755
|
+
*/
|
|
756
|
+
function newIdempotencyKey() {
|
|
757
|
+
return globalThis.crypto?.randomUUID?.() ?? "";
|
|
758
|
+
}
|
|
759
|
+
/** Whether a thrown value is this call being abandoned rather than failing. */
|
|
760
|
+
function isAbort(err, signal) {
|
|
761
|
+
if (signal?.aborted === true) return true;
|
|
762
|
+
return err instanceof Error && err.name === "AbortError";
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* The caller's signal, or a fresh one that also fires when this attempt's share
|
|
766
|
+
* of the budget runs out.
|
|
767
|
+
*/
|
|
768
|
+
function combine(signal, ms) {
|
|
769
|
+
const timeout = AbortSignal.timeout(ms);
|
|
770
|
+
return signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
|
|
771
|
+
}
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/session.ts
|
|
774
|
+
/** Thrown when a session has nothing left to present. */
|
|
775
|
+
var NoSessionError = class extends Error {
|
|
776
|
+
constructor() {
|
|
777
|
+
super("@rig-ts/client: no session; sign in again");
|
|
778
|
+
this.name = "NoSessionError";
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
/**
|
|
782
|
+
* A credential that keeps itself fresh.
|
|
783
|
+
*
|
|
784
|
+
* It holds the pair a sign-in returned and exchanges the refresh token before
|
|
785
|
+
* the access token expires, using the leeway in the document's auth profile — so
|
|
786
|
+
* a page open all day makes a handful of refresh calls at moments of its own
|
|
787
|
+
* choosing, rather than discovering the expiry through a failed request in the
|
|
788
|
+
* middle of something.
|
|
789
|
+
*
|
|
790
|
+
* Several calls arriving at an expiry at once take turns: the ones behind find
|
|
791
|
+
* the work already done instead of each spending a rotation. That matters more
|
|
792
|
+
* than it sounds — the server bounds how often a session may rotate, and a
|
|
793
|
+
* client that raced with itself would spend that budget on nothing.
|
|
794
|
+
*/
|
|
795
|
+
var Session = class {
|
|
796
|
+
tokens;
|
|
797
|
+
runtime;
|
|
798
|
+
/**
|
|
799
|
+
* Held for the length of an exchange, so a hundred callers discovering the
|
|
800
|
+
* same expiry produce one refresh.
|
|
801
|
+
*/
|
|
802
|
+
inFlight;
|
|
803
|
+
/** Called when a new pair is issued — a place to persist it. */
|
|
804
|
+
onTokens;
|
|
805
|
+
constructor(tokens = {}) {
|
|
806
|
+
this.tokens = tokens;
|
|
807
|
+
}
|
|
808
|
+
/** The pair currently held, for a program that stores it between runs. */
|
|
809
|
+
getTokens() {
|
|
810
|
+
return this.tokens;
|
|
811
|
+
}
|
|
812
|
+
/** Identifies the session, for showing it in a list and revoking it. */
|
|
813
|
+
get sessionId() {
|
|
814
|
+
return this.tokens.sessionId ?? "";
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Swaps in a newly issued pair, which is what a refresh, a tenant switch and
|
|
818
|
+
* a password change all produce.
|
|
819
|
+
*/
|
|
820
|
+
replace(pair) {
|
|
821
|
+
this.tokens = pair.refreshToken === void 0 || pair.refreshToken === "" ? {
|
|
822
|
+
...pair,
|
|
823
|
+
...this.tokens.refreshToken !== void 0 ? { refreshToken: this.tokens.refreshToken } : {},
|
|
824
|
+
...this.tokens.refreshExpiresAt !== void 0 ? { refreshExpiresAt: this.tokens.refreshExpiresAt } : {}
|
|
825
|
+
} : pair;
|
|
826
|
+
this.onTokens?.(this.tokens);
|
|
827
|
+
}
|
|
828
|
+
/** Receives the client this session refreshes through. */
|
|
829
|
+
bind(runtime) {
|
|
830
|
+
this.runtime = runtime;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Adds the token, refreshing first if this request might outlive it.
|
|
834
|
+
*/
|
|
835
|
+
async apply(headers, signal) {
|
|
836
|
+
if (this.stale()) await this.exchange(signal);
|
|
837
|
+
const token = this.tokens.accessToken;
|
|
838
|
+
if (token === void 0 || token === "") throw new NoSessionError();
|
|
839
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* A 401 despite a token that looked current is a revoked or invalidated one,
|
|
843
|
+
* and the refresh token is the only thing left to try.
|
|
844
|
+
*
|
|
845
|
+
* It exchanges whatever the expiry says, because the expiry is exactly what
|
|
846
|
+
* has just been proved wrong.
|
|
847
|
+
*/
|
|
848
|
+
async reauthorize(signal) {
|
|
849
|
+
return await this.exchange(signal);
|
|
850
|
+
}
|
|
851
|
+
/** Whether the access token is expired or close enough to it. */
|
|
852
|
+
stale() {
|
|
853
|
+
const rt = this.runtime;
|
|
854
|
+
if (rt === void 0) return false;
|
|
855
|
+
if (this.tokens.accessToken === void 0 || this.tokens.accessToken === "") return false;
|
|
856
|
+
const expiresAt = this.tokens.expiresAt;
|
|
857
|
+
if (expiresAt === void 0) return false;
|
|
858
|
+
const at = Date.parse(expiresAt);
|
|
859
|
+
if (Number.isNaN(at)) return false;
|
|
860
|
+
const leeway = rt.api.auth?.rotationLeewayMs ?? 0;
|
|
861
|
+
return rt.now() + leeway >= at;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Exchanges the refresh token for a new pair, once however many callers ask.
|
|
865
|
+
*
|
|
866
|
+
* Answers false rather than throwing when there is nothing to exchange: a
|
|
867
|
+
* session that was never signed in leaves the 401 that prompted this as the
|
|
868
|
+
* answer, which is what the caller needs to see.
|
|
869
|
+
*/
|
|
870
|
+
async exchange(signal) {
|
|
871
|
+
if (this.inFlight !== void 0) return await this.inFlight;
|
|
872
|
+
const rt = this.runtime;
|
|
873
|
+
const refreshToken = this.tokens.refreshToken;
|
|
874
|
+
if (rt === void 0 || refreshToken === void 0 || refreshToken === "") return false;
|
|
875
|
+
const basePath = rt.api.auth?.basePath ?? "/auth";
|
|
876
|
+
this.inFlight = (async () => {
|
|
877
|
+
const pair = await send(rt, {
|
|
878
|
+
name: "authRefresh",
|
|
879
|
+
method: "POST",
|
|
880
|
+
root: true,
|
|
881
|
+
path: `${basePath}/refresh`,
|
|
882
|
+
body: { refreshToken }
|
|
883
|
+
}, {
|
|
884
|
+
anonymous: true,
|
|
885
|
+
...signal !== void 0 ? { signal } : {}
|
|
886
|
+
});
|
|
887
|
+
if (pair === void 0) return false;
|
|
888
|
+
this.replace(pair);
|
|
889
|
+
return true;
|
|
890
|
+
})().finally(() => {
|
|
891
|
+
this.inFlight = void 0;
|
|
892
|
+
});
|
|
893
|
+
return await this.inFlight;
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
//#endregion
|
|
897
|
+
//#region src/paginate.ts
|
|
898
|
+
/**
|
|
899
|
+
* Walks a paginated read to its end.
|
|
900
|
+
*
|
|
901
|
+
* `fetch` is handed the offset to ask for and returns one page; the limit is
|
|
902
|
+
* whatever the caller's query said, which `fetch` closes over. Iteration stops
|
|
903
|
+
* after the page that reaches the reported total, at the first failure, or when
|
|
904
|
+
* a page comes back empty — that last one is the guard against a server whose
|
|
905
|
+
* total disagrees with what it returns, which would otherwise be an infinite
|
|
906
|
+
* loop rather than a bug report.
|
|
907
|
+
*
|
|
908
|
+
* A failure is thrown, so `for await` reports it where the caller is standing.
|
|
909
|
+
* There is no partial answer: what came before the failure was yielded, and
|
|
910
|
+
* nothing after it is.
|
|
911
|
+
*
|
|
912
|
+
* ```ts
|
|
913
|
+
* for await (const todo of paginate(0, (offset) =>
|
|
914
|
+
* client.todos.list({ limit: 100, offset }).then(toPage)
|
|
915
|
+
* )) {
|
|
916
|
+
* …
|
|
917
|
+
* }
|
|
918
|
+
* ```
|
|
919
|
+
*/
|
|
920
|
+
async function* paginate(startOffset, fetch) {
|
|
921
|
+
let offset = startOffset;
|
|
922
|
+
for (;;) {
|
|
923
|
+
const page = await fetch(offset);
|
|
924
|
+
for (const item of page.items) yield item;
|
|
925
|
+
if (page.items.length === 0) return;
|
|
926
|
+
offset += page.items.length;
|
|
927
|
+
if (offset >= page.total) return;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
//#endregion
|
|
931
|
+
//#region src/query.ts
|
|
932
|
+
/** Writes one parameter, or nothing when the value is absent. */
|
|
933
|
+
function setParam(query, key, value) {
|
|
934
|
+
if (value === void 0 || value === null) return;
|
|
935
|
+
query.set(key, formatParam(value));
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Writes a repeated parameter, one key per value.
|
|
939
|
+
*
|
|
940
|
+
* An empty array writes nothing rather than an empty key, for the same reason a
|
|
941
|
+
* single absent value does: the server distinguishes "no filter" from "a filter
|
|
942
|
+
* matching nothing".
|
|
943
|
+
*/
|
|
944
|
+
function setParams(query, key, values) {
|
|
945
|
+
if (values === void 0 || values === null) return;
|
|
946
|
+
for (const value of values) query.append(key, formatParam(value));
|
|
947
|
+
}
|
|
948
|
+
/** How one value is written. */
|
|
949
|
+
function formatParam(value) {
|
|
950
|
+
if (value instanceof Date) return value.toISOString();
|
|
951
|
+
return String(value);
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Escapes a value for a path segment.
|
|
955
|
+
*
|
|
956
|
+
* An identifier that arrived from somewhere else can be anything at all, and a
|
|
957
|
+
* slash in one would otherwise silently address a different route.
|
|
958
|
+
*/
|
|
959
|
+
function pathValue(value) {
|
|
960
|
+
return encodeURIComponent(value);
|
|
961
|
+
}
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/upload.ts
|
|
964
|
+
/** The part the row travels in. The server reads the parts in order and needs
|
|
965
|
+
* the row before it has anywhere to put the bytes. */
|
|
966
|
+
const JSON_PART = "json";
|
|
967
|
+
/**
|
|
968
|
+
* Builds the `multipart/form-data` body rig's upload endpoints take: the row,
|
|
969
|
+
* and the files beside it.
|
|
970
|
+
*
|
|
971
|
+
* It is the shape rig's own endpoints take rather than a general form encoder. A
|
|
972
|
+
* generated method calls it; a caller supplies the {@link Upload}s.
|
|
973
|
+
*
|
|
974
|
+
* `row` is left out entirely when absent, which is what a bare upload does:
|
|
975
|
+
* there is no row to send, only bytes for a row that already exists.
|
|
976
|
+
*
|
|
977
|
+
* An absent upload is left out the same way, so a generated method can name
|
|
978
|
+
* every file column of a create unconditionally. What makes a required one
|
|
979
|
+
* impossible to leave out is the generated shape it arrives in — the member is
|
|
980
|
+
* optional there only where the column is nullable — and not a check here,
|
|
981
|
+
* which would report at runtime what the compiler has already refused.
|
|
982
|
+
*/
|
|
983
|
+
function multipart(row, files) {
|
|
984
|
+
const form = new FormData();
|
|
985
|
+
if (row !== void 0) form.append(JSON_PART, new Blob([JSON.stringify(row)], { type: "application/json" }));
|
|
986
|
+
for (const [field, upload] of files) {
|
|
987
|
+
if (upload === void 0) continue;
|
|
988
|
+
const body = upload.contentType === void 0 ? upload.body : new Blob([upload.body], { type: upload.contentType });
|
|
989
|
+
form.append(field, body, upload.name);
|
|
990
|
+
}
|
|
991
|
+
return form;
|
|
992
|
+
}
|
|
993
|
+
//#endregion
|
|
994
|
+
export { DEFAULT_REQUEST_ID_HEADER, DEFAULT_REVISION_HEADER, ErrorCode, FieldCode, METHOD_QUERY, NoSessionError, RigError, Runtime, Session, apiKey, codeOf, fieldsAs, fraction, isConflict, isForbidden, isInvalid, isNotFound, isRateLimited, isReauthorizer, isRigError, isTooLarge, isUnauthorized, isUnsupportedMediaType, isUpgradeRequired, multipart, paginate, pathValue, rateLimitOf, send, sendContent, sendNoContent, sendOptional, setParam, setParams, staticToken, used };
|