@bedrock-rbx/ocale 0.1.0-beta.1
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/LICENSE +21 -0
- package/dist/badges.d.mts +186 -0
- package/dist/badges.d.mts.map +1 -0
- package/dist/badges.mjs +309 -0
- package/dist/badges.mjs.map +1 -0
- package/dist/developer-products.d.mts +245 -0
- package/dist/developer-products.d.mts.map +1 -0
- package/dist/developer-products.mjs +388 -0
- package/dist/developer-products.mjs.map +1 -0
- package/dist/game-passes.d.mts +210 -0
- package/dist/game-passes.d.mts.map +1 -0
- package/dist/game-passes.mjs +397 -0
- package/dist/game-passes.mjs.map +1 -0
- package/dist/index.d.mts +191 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/places.d.mts +161 -0
- package/dist/places.d.mts.map +1 -0
- package/dist/places.mjs +403 -0
- package/dist/places.mjs.map +1 -0
- package/dist/price-information-CmpscMc4.mjs +42 -0
- package/dist/price-information-CmpscMc4.mjs.map +1 -0
- package/dist/rate-limit-BBU_4xnZ.mjs +135 -0
- package/dist/rate-limit-BBU_4xnZ.mjs.map +1 -0
- package/dist/resource-client-CaS_j3yg.mjs +652 -0
- package/dist/resource-client-CaS_j3yg.mjs.map +1 -0
- package/dist/to-blob-1BtHsDGK.mjs +18 -0
- package/dist/to-blob-1BtHsDGK.mjs.map +1 -0
- package/dist/types-YCTsM8Qd.d.mts +214 -0
- package/dist/types-YCTsM8Qd.d.mts.map +1 -0
- package/dist/universes.d.mts +387 -0
- package/dist/universes.d.mts.map +1 -0
- package/dist/universes.mjs +705 -0
- package/dist/universes.mjs.map +1 -0
- package/dist/validation-CTZzJhmd.mjs +38 -0
- package/dist/validation-CTZzJhmd.mjs.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { i as ApiError, n as PermissionError, r as NetworkError, t as RateLimitError } from "./rate-limit-BBU_4xnZ.mjs";
|
|
2
|
+
import { setTimeout } from "node:timers/promises";
|
|
3
|
+
//#region src/internal/utils/is-record.ts
|
|
4
|
+
/**
|
|
5
|
+
* Narrows `value` to a plain JSON-style record. Excludes arrays, class
|
|
6
|
+
* instances, primitives, and `null`/`undefined`. Used by resource
|
|
7
|
+
* parsers to gate property access on wire bodies whose shape isn't
|
|
8
|
+
* known at compile time.
|
|
9
|
+
*
|
|
10
|
+
* @param value - The unknown value to narrow.
|
|
11
|
+
* @returns `true` when `value` is a plain `[object Object]`.
|
|
12
|
+
*/
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/internal/http/retry.ts
|
|
18
|
+
/**
|
|
19
|
+
* Default retry status codes for idempotent operations (read, list, update,
|
|
20
|
+
* delete). Safe to retry on both rate limits and transient server errors.
|
|
21
|
+
*/
|
|
22
|
+
const IDEMPOTENT_METHOD_DEFAULTS = Object.freeze({ retryableStatuses: Object.freeze([
|
|
23
|
+
429,
|
|
24
|
+
500,
|
|
25
|
+
502,
|
|
26
|
+
503,
|
|
27
|
+
504
|
|
28
|
+
]) });
|
|
29
|
+
/**
|
|
30
|
+
* Default retry status codes for create operations. Retries rate limits only,
|
|
31
|
+
* to prevent duplicate resources on 5xx (Roblox Open Cloud has no
|
|
32
|
+
* idempotency-key support).
|
|
33
|
+
*/
|
|
34
|
+
const CREATE_METHOD_DEFAULTS = Object.freeze({ retryableStatuses: Object.freeze([429]) });
|
|
35
|
+
/**
|
|
36
|
+
* Default exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped).
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { defaultRetryDelay } from "./retry";
|
|
42
|
+
*
|
|
43
|
+
* expect(defaultRetryDelay(0)).toBe(1000);
|
|
44
|
+
* expect(defaultRetryDelay(4)).toBe(16_000);
|
|
45
|
+
* expect(defaultRetryDelay(10)).toBe(30_000);
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @param attempt - Zero-indexed retry attempt number.
|
|
49
|
+
* @returns Wait duration in milliseconds.
|
|
50
|
+
*/
|
|
51
|
+
function defaultRetryDelay(attempt) {
|
|
52
|
+
return Math.min(1e3 * 2 ** attempt, 3e4);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Computes how long to wait before the next retry. Prefers the server's
|
|
56
|
+
* suggested delay when the error is a {@link RateLimitError} with a positive
|
|
57
|
+
* `retryAfterSeconds`; otherwise falls through to `retryDelay(attempt)`.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { RateLimitError } from "../../errors/rate-limit.ts";
|
|
63
|
+
* import { computeRetryWaitMs, defaultRetryDelay } from "./retry";
|
|
64
|
+
*
|
|
65
|
+
* const error = new RateLimitError("slow down", { retryAfterSeconds: 3 });
|
|
66
|
+
*
|
|
67
|
+
* expect(computeRetryWaitMs(error, { attempt: 0, retryDelay: defaultRetryDelay })).toBe(
|
|
68
|
+
* 3000,
|
|
69
|
+
* );
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* import { ApiError } from "../../errors/api-error.ts";
|
|
76
|
+
* import { computeRetryWaitMs, defaultRetryDelay } from "./retry";
|
|
77
|
+
*
|
|
78
|
+
* const error = new ApiError("server error", { statusCode: 503 });
|
|
79
|
+
*
|
|
80
|
+
* expect(computeRetryWaitMs(error, { attempt: 2, retryDelay: defaultRetryDelay })).toBe(
|
|
81
|
+
* 4000,
|
|
82
|
+
* );
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* @param error - The error returned by the failing request.
|
|
86
|
+
* @param options - Retry attempt index and fallback delay function.
|
|
87
|
+
* @returns Wait duration in milliseconds before the next attempt.
|
|
88
|
+
*/
|
|
89
|
+
function computeRetryWaitMs(error, options) {
|
|
90
|
+
if (error instanceof RateLimitError && error.retryAfterSeconds > 0) return error.retryAfterSeconds * 1e3;
|
|
91
|
+
return options.retryDelay(options.attempt);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Decides whether a failed request is eligible for retry under the given
|
|
95
|
+
* `retryableStatuses`. Only {@link RateLimitError} (checked against 429) and
|
|
96
|
+
* {@link ApiError} (checked against its `statusCode`) are retryable — network
|
|
97
|
+
* errors and other failures always return `false`.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* import { RateLimitError } from "../../errors/rate-limit.ts";
|
|
103
|
+
* import { shouldRetry } from "./retry";
|
|
104
|
+
*
|
|
105
|
+
* const error = new RateLimitError("", { retryAfterSeconds: 1 });
|
|
106
|
+
*
|
|
107
|
+
* expect(shouldRetry(error, { retryableStatuses: [429] })).toBe(true);
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* import { ApiError } from "../../errors/api-error.ts";
|
|
114
|
+
* import { shouldRetry } from "./retry";
|
|
115
|
+
*
|
|
116
|
+
* const error = new ApiError("", { statusCode: 503 });
|
|
117
|
+
*
|
|
118
|
+
* expect(shouldRetry(error, { retryableStatuses: [429, 500, 502, 503, 504] })).toBe(
|
|
119
|
+
* true,
|
|
120
|
+
* );
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
*
|
|
125
|
+
* ```ts
|
|
126
|
+
* import { NetworkError } from "../../errors/network-error.ts";
|
|
127
|
+
* import { shouldRetry } from "./retry";
|
|
128
|
+
*
|
|
129
|
+
* const error = new NetworkError("offline");
|
|
130
|
+
*
|
|
131
|
+
* expect(shouldRetry(error, { retryableStatuses: [429] })).toBe(false);
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* @param error - The error returned by the failing request.
|
|
135
|
+
* @param config - Object carrying the retry-eligible status list.
|
|
136
|
+
* @returns `true` if the error should be retried, `false` otherwise.
|
|
137
|
+
*/
|
|
138
|
+
function shouldRetry(error, config) {
|
|
139
|
+
if (error instanceof RateLimitError) return config.retryableStatuses.includes(429);
|
|
140
|
+
if (error instanceof ApiError) return config.retryableStatuses.includes(error.statusCode);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolves the effective config for a single request by shallow-merging the
|
|
145
|
+
* client config, method defaults, and per-request options. Precedence depends
|
|
146
|
+
* on `methodKind`:
|
|
147
|
+
*
|
|
148
|
+
* - `"create"`: method defaults override client config, so client-level
|
|
149
|
+
* settings cannot silently relax create-method safety. Only explicit
|
|
150
|
+
* per-request `requestOptions` can.
|
|
151
|
+
* - `"idempotent"`: client config overrides method defaults, so consumers
|
|
152
|
+
* can loosen or tighten retry policy globally. `requestOptions` still wins
|
|
153
|
+
* when provided.
|
|
154
|
+
*
|
|
155
|
+
* Array-valued fields like `retryableStatuses` are *replaced*, not extended.
|
|
156
|
+
*
|
|
157
|
+
* @template T - Concrete `RetryResolvable` subtype being merged.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
*
|
|
161
|
+
* ```ts
|
|
162
|
+
* import {
|
|
163
|
+
* CREATE_METHOD_DEFAULTS,
|
|
164
|
+
* defaultRetryDelay,
|
|
165
|
+
* mergeConfig,
|
|
166
|
+
* type RetryResolvable,
|
|
167
|
+
* } from "./retry";
|
|
168
|
+
*
|
|
169
|
+
* const clientConfig: RetryResolvable = {
|
|
170
|
+
* apiKey: "k",
|
|
171
|
+
* baseUrl: "https://apis.roblox.com",
|
|
172
|
+
* maxRetries: 3,
|
|
173
|
+
* retryableStatuses: [429, 500],
|
|
174
|
+
* retryDelay: defaultRetryDelay,
|
|
175
|
+
* timeout: 30_000,
|
|
176
|
+
* };
|
|
177
|
+
*
|
|
178
|
+
* const merged = mergeConfig(clientConfig, {
|
|
179
|
+
* methodDefaults: CREATE_METHOD_DEFAULTS,
|
|
180
|
+
* methodKind: "create",
|
|
181
|
+
* });
|
|
182
|
+
*
|
|
183
|
+
* expect(merged.retryableStatuses).toStrictEqual([429]);
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
*
|
|
188
|
+
* ```ts
|
|
189
|
+
* import {
|
|
190
|
+
* defaultRetryDelay,
|
|
191
|
+
* IDEMPOTENT_METHOD_DEFAULTS,
|
|
192
|
+
* mergeConfig,
|
|
193
|
+
* type RetryResolvable,
|
|
194
|
+
* } from "./retry";
|
|
195
|
+
*
|
|
196
|
+
* const clientConfig: RetryResolvable = {
|
|
197
|
+
* apiKey: "k",
|
|
198
|
+
* baseUrl: "https://apis.roblox.com",
|
|
199
|
+
* maxRetries: 3,
|
|
200
|
+
* retryableStatuses: [429],
|
|
201
|
+
* retryDelay: defaultRetryDelay,
|
|
202
|
+
* timeout: 30_000,
|
|
203
|
+
* };
|
|
204
|
+
*
|
|
205
|
+
* const merged = mergeConfig(clientConfig, {
|
|
206
|
+
* methodDefaults: IDEMPOTENT_METHOD_DEFAULTS,
|
|
207
|
+
* methodKind: "idempotent",
|
|
208
|
+
* requestOptions: { timeout: 10_000 },
|
|
209
|
+
* });
|
|
210
|
+
*
|
|
211
|
+
* expect(merged.retryableStatuses).toStrictEqual([429]);
|
|
212
|
+
* expect(merged.timeout).toBe(10_000);
|
|
213
|
+
* ```
|
|
214
|
+
*
|
|
215
|
+
* @param clientConfig - Config frozen at client construction.
|
|
216
|
+
* @param options - Method defaults, method kind, and optional per-request overrides.
|
|
217
|
+
* @returns A new merged config object. Inputs are not mutated.
|
|
218
|
+
*/
|
|
219
|
+
function mergeConfig(clientConfig, options) {
|
|
220
|
+
const { methodDefaults, methodKind, requestOptions } = options;
|
|
221
|
+
switch (methodKind) {
|
|
222
|
+
case "create": return {
|
|
223
|
+
...clientConfig,
|
|
224
|
+
...methodDefaults,
|
|
225
|
+
...requestOptions
|
|
226
|
+
};
|
|
227
|
+
case "idempotent": return {
|
|
228
|
+
...methodDefaults,
|
|
229
|
+
...clientConfig,
|
|
230
|
+
...requestOptions
|
|
231
|
+
};
|
|
232
|
+
default: throw new Error(`Unexpected methodKind: ${String(methodKind)}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/internal/http/execute.ts
|
|
237
|
+
/**
|
|
238
|
+
* Retry-aware orchestration loop. Coordinates a single logical request,
|
|
239
|
+
* looping over `options.send` until it succeeds, the error is non-retryable,
|
|
240
|
+
* or `options.config.maxRetries` is exhausted. Fires observability hooks
|
|
241
|
+
* at each transition. Domain- and queue-agnostic: `send` may be any
|
|
242
|
+
* callback, including one wrapped by a rate-limit queue.
|
|
243
|
+
*
|
|
244
|
+
* @param request - The immutable request to send.
|
|
245
|
+
* @param options - The transport callback, resolved config, hooks, and sleep.
|
|
246
|
+
* @returns The first success, or the final error after retries are exhausted.
|
|
247
|
+
*/
|
|
248
|
+
async function executeWithRetry(request, options) {
|
|
249
|
+
const { config, hooks, send, sleep } = options;
|
|
250
|
+
async function attempt() {
|
|
251
|
+
hooks.onRequest?.(request);
|
|
252
|
+
return send(request);
|
|
253
|
+
}
|
|
254
|
+
let result = await attempt();
|
|
255
|
+
for (let retry = 0; retry < config.maxRetries; retry++) {
|
|
256
|
+
if (result.success || !shouldRetry(result.err, config)) return result;
|
|
257
|
+
const { err } = result;
|
|
258
|
+
hooks.onRetry?.(retry + 1, err);
|
|
259
|
+
const waitMs = computeRetryWaitMs(err, {
|
|
260
|
+
attempt: retry,
|
|
261
|
+
retryDelay: config.retryDelay
|
|
262
|
+
});
|
|
263
|
+
hooks.onRateLimit?.(waitMs);
|
|
264
|
+
await sleep(waitMs);
|
|
265
|
+
result = await attempt();
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/internal/http/rate-limit-queue.ts
|
|
271
|
+
/**
|
|
272
|
+
* Token-bucket rate limiter for a single `(apiKey, operation)` pair. Every
|
|
273
|
+
* call to `acquire` consumes one token; when the bucket is empty the call
|
|
274
|
+
* waits until a token regenerates before invoking the task. Burst capacity
|
|
275
|
+
* equals `maxPerSecond`, refilling at `maxPerSecond` tokens per second.
|
|
276
|
+
*
|
|
277
|
+
* Implemented as a leaky bucket tracking drain debt in ms. `#lastCheck`
|
|
278
|
+
* advances by `waitMs` after every sleep so the algorithm stays correct
|
|
279
|
+
* whether or not the injected sleep moves `Date.now()` forward.
|
|
280
|
+
*/
|
|
281
|
+
var RateLimitQueue = class {
|
|
282
|
+
#hooks;
|
|
283
|
+
#intervalMs;
|
|
284
|
+
#maxBucketLevel;
|
|
285
|
+
#sleep;
|
|
286
|
+
#bucketLevel = 0;
|
|
287
|
+
#chain = Promise.resolve();
|
|
288
|
+
#lastCheck = Date.now();
|
|
289
|
+
/**
|
|
290
|
+
* Creates a rate-limit queue bound to a single operation.
|
|
291
|
+
*
|
|
292
|
+
* @param limit - The operation key and its per-second request ceiling.
|
|
293
|
+
* @param hooks - Observability callbacks; `onRateLimit` fires when the
|
|
294
|
+
* bucket is empty and a sleep is about to start.
|
|
295
|
+
* @param sleep - Injectable sleep (tests pass a fake).
|
|
296
|
+
*/
|
|
297
|
+
constructor(limit, hooks, sleep) {
|
|
298
|
+
this.#intervalMs = 1e3 / limit.maxPerSecond;
|
|
299
|
+
this.#maxBucketLevel = limit.maxPerSecond * this.#intervalMs;
|
|
300
|
+
this.#hooks = hooks;
|
|
301
|
+
this.#sleep = sleep;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Waits for a token — sleeping and firing `hooks.onRateLimit` if the
|
|
305
|
+
* bucket is empty — then executes `task`. Concurrent callers are
|
|
306
|
+
* serialized at token acquisition; tasks themselves run independently
|
|
307
|
+
* once their token is secured.
|
|
308
|
+
*
|
|
309
|
+
* @param task - The request to run once a token is available.
|
|
310
|
+
* @returns The value produced by `task`.
|
|
311
|
+
*/
|
|
312
|
+
async acquire(task) {
|
|
313
|
+
const myTurn = this.#chain.then(async () => this.#waitForToken());
|
|
314
|
+
this.#chain = myTurn;
|
|
315
|
+
await myTurn;
|
|
316
|
+
return task();
|
|
317
|
+
}
|
|
318
|
+
async #waitForToken() {
|
|
319
|
+
const now = Math.max(Date.now(), this.#lastCheck);
|
|
320
|
+
const drained = Math.max(0, this.#bucketLevel - (now - this.#lastCheck));
|
|
321
|
+
this.#lastCheck = now;
|
|
322
|
+
if (drained + this.#intervalMs <= this.#maxBucketLevel) {
|
|
323
|
+
this.#bucketLevel = drained + this.#intervalMs;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const waitMs = drained + this.#intervalMs - this.#maxBucketLevel;
|
|
327
|
+
this.#hooks.onRateLimit?.(waitMs);
|
|
328
|
+
await this.#sleep(waitMs);
|
|
329
|
+
this.#bucketLevel = this.#maxBucketLevel;
|
|
330
|
+
this.#lastCheck = now + waitMs;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region src/internal/utils/try-catch.ts
|
|
335
|
+
/**
|
|
336
|
+
* Wraps a promise into a {@link Result}, catching rejections.
|
|
337
|
+
*
|
|
338
|
+
* @template T - The resolved value type.
|
|
339
|
+
* @param promise - The promise to wrap.
|
|
340
|
+
* @returns A Result containing the resolved value or the rejection error.
|
|
341
|
+
*/
|
|
342
|
+
async function tryCatch(promise) {
|
|
343
|
+
try {
|
|
344
|
+
return {
|
|
345
|
+
data: await promise,
|
|
346
|
+
success: true
|
|
347
|
+
};
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return {
|
|
350
|
+
err: err instanceof Error ? err : new Error(String(err)),
|
|
351
|
+
success: false
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/internal/http/fetch-client.ts
|
|
357
|
+
/**
|
|
358
|
+
* Converts a `Headers` object to a plain record with lowercased keys.
|
|
359
|
+
*
|
|
360
|
+
* @param headers - The `Headers` instance to convert.
|
|
361
|
+
* @returns A record mapping lowercased header names to their values.
|
|
362
|
+
*/
|
|
363
|
+
function headersToRecord(headers) {
|
|
364
|
+
return Object.fromEntries(headers);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Permissively extracts a top-level `errorCode` string field from a
|
|
368
|
+
* response body.
|
|
369
|
+
*
|
|
370
|
+
* @param body - The parsed response body (unknown shape).
|
|
371
|
+
* @returns The `errorCode` string if present, otherwise `undefined`.
|
|
372
|
+
*/
|
|
373
|
+
function extractErrorCode(body) {
|
|
374
|
+
if (body === null || typeof body !== "object") return;
|
|
375
|
+
const errorCode = Reflect.get(body, "errorCode");
|
|
376
|
+
return typeof errorCode === "string" ? errorCode : void 0;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Parses the `x-ratelimit-reset` header value into seconds.
|
|
380
|
+
*
|
|
381
|
+
* @param headerValue - The raw header value, or `undefined` if missing.
|
|
382
|
+
* @returns The number of seconds to wait, or 0 if missing/invalid.
|
|
383
|
+
*/
|
|
384
|
+
function parseRetryAfterSeconds(headerValue) {
|
|
385
|
+
const parsed = Number(headerValue);
|
|
386
|
+
if (Number.isNaN(parsed)) return 0;
|
|
387
|
+
return Math.max(0, Math.floor(parsed));
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Joins the base URL from config with the relative path from the request.
|
|
391
|
+
*
|
|
392
|
+
* @param request - The HTTP request containing the relative URL.
|
|
393
|
+
* @param config - The request config containing the base URL.
|
|
394
|
+
* @returns The fully-qualified URL string.
|
|
395
|
+
*/
|
|
396
|
+
function buildUrl(request, config) {
|
|
397
|
+
return `${config.baseUrl.endsWith("/") ? config.baseUrl.slice(0, -1) : config.baseUrl}${request.url}`;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Constructs the `RequestInit` options for a `fetch` call.
|
|
401
|
+
*
|
|
402
|
+
* @param request - The HTTP request to build options for.
|
|
403
|
+
* @param config - The request config containing API key and timeout.
|
|
404
|
+
* @returns A `RequestInit` object ready for `fetch`.
|
|
405
|
+
*/
|
|
406
|
+
function buildFetchOptions(request, config) {
|
|
407
|
+
const headers = new Headers({ "x-api-key": config.apiKey });
|
|
408
|
+
const options = {
|
|
409
|
+
headers,
|
|
410
|
+
method: request.method
|
|
411
|
+
};
|
|
412
|
+
if (request.body instanceof FormData) options.body = request.body;
|
|
413
|
+
else if (request.body instanceof Uint8Array) {
|
|
414
|
+
headers.set("content-type", "application/octet-stream");
|
|
415
|
+
options.body = request.body;
|
|
416
|
+
} else if (request.body !== void 0) {
|
|
417
|
+
headers.set("content-type", "application/json");
|
|
418
|
+
options.body = JSON.stringify(request.body);
|
|
419
|
+
}
|
|
420
|
+
if (request.headers !== void 0) for (const [name, value] of Object.entries(request.headers)) {
|
|
421
|
+
if (name.toLowerCase() === "x-api-key") continue;
|
|
422
|
+
headers.set(name, value);
|
|
423
|
+
}
|
|
424
|
+
if (config.timeout !== void 0) options.signal = AbortSignal.timeout(config.timeout);
|
|
425
|
+
return options;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Creates an {@link HttpClient} backed by the Fetch API.
|
|
429
|
+
*
|
|
430
|
+
* @param fetchFunc - The fetch implementation to use. Defaults to `globalThis.fetch`.
|
|
431
|
+
* @returns An HttpClient that classifies responses into typed Results.
|
|
432
|
+
*/
|
|
433
|
+
function createFetchHttpClient(fetchFunc = globalThis.fetch) {
|
|
434
|
+
return { async request(httpRequest, config) {
|
|
435
|
+
const fetchResult = await tryCatch(fetchFunc(buildUrl(httpRequest, config), buildFetchOptions(httpRequest, config)));
|
|
436
|
+
if (!fetchResult.success) return {
|
|
437
|
+
err: new NetworkError("Network request failed", { cause: fetchResult.err }),
|
|
438
|
+
success: false
|
|
439
|
+
};
|
|
440
|
+
return classifyResponse(fetchResult.data);
|
|
441
|
+
} };
|
|
442
|
+
}
|
|
443
|
+
function createApiError(status, body) {
|
|
444
|
+
return new ApiError(`HTTP ${status}`, {
|
|
445
|
+
code: extractErrorCode(body),
|
|
446
|
+
statusCode: status
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Classifies a fetch `Response` into a typed `Result`.
|
|
451
|
+
*
|
|
452
|
+
* @param response - The raw fetch Response to classify.
|
|
453
|
+
* @returns A Result containing an HttpResponse on success or an OpenCloudError on failure.
|
|
454
|
+
*/
|
|
455
|
+
function createRateLimitError(response) {
|
|
456
|
+
return new RateLimitError("Rate limited", { retryAfterSeconds: parseRetryAfterSeconds(response.headers.get("x-ratelimit-reset") ?? void 0) });
|
|
457
|
+
}
|
|
458
|
+
async function readResponseBody(response) {
|
|
459
|
+
try {
|
|
460
|
+
const text = await response.text();
|
|
461
|
+
return {
|
|
462
|
+
data: text === "" ? void 0 : JSON.parse(text),
|
|
463
|
+
success: true
|
|
464
|
+
};
|
|
465
|
+
} catch {
|
|
466
|
+
return {
|
|
467
|
+
err: new ApiError("Failed to parse response body", { statusCode: response.status }),
|
|
468
|
+
success: false
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async function classifyResponse(response) {
|
|
473
|
+
if (response.status === 429) return {
|
|
474
|
+
err: createRateLimitError(response),
|
|
475
|
+
success: false
|
|
476
|
+
};
|
|
477
|
+
const bodyResult = await readResponseBody(response);
|
|
478
|
+
if (!bodyResult.success) return bodyResult;
|
|
479
|
+
if (response.status >= 300) return {
|
|
480
|
+
err: createApiError(response.status, bodyResult.data),
|
|
481
|
+
success: false
|
|
482
|
+
};
|
|
483
|
+
return {
|
|
484
|
+
data: {
|
|
485
|
+
body: bodyResult.data,
|
|
486
|
+
headers: headersToRecord(response.headers),
|
|
487
|
+
status: response.status
|
|
488
|
+
},
|
|
489
|
+
success: true
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
//#endregion
|
|
493
|
+
//#region src/internal/http/resolve-dependencies.ts
|
|
494
|
+
/**
|
|
495
|
+
* Resolves the concrete HTTP client and sleep implementation a resource
|
|
496
|
+
* client should use. Falls back to the fetch-backed HTTP client and the
|
|
497
|
+
* default `setTimeout`-based sleep when the caller omits the test seams.
|
|
498
|
+
*
|
|
499
|
+
* Extracted so resource client constructors can keep their dependency
|
|
500
|
+
* resolution logic in a single, unit-testable place; this makes the
|
|
501
|
+
* default branches easy to cover without stubbing globals like `fetch`.
|
|
502
|
+
*
|
|
503
|
+
* @param options - Optional {@link HttpClient} and {@link SleepFunc} test seams.
|
|
504
|
+
* @returns A {@link ResolvedDependencies} with defaults applied.
|
|
505
|
+
*/
|
|
506
|
+
function resolveDependencies(options) {
|
|
507
|
+
return {
|
|
508
|
+
httpClient: options.httpClient ?? createFetchHttpClient(),
|
|
509
|
+
sleep: options.sleep ?? setTimeout
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
//#region src/internal/resource-client.ts
|
|
514
|
+
/**
|
|
515
|
+
* Wraps an infallible request build as a {@link Result}-returning
|
|
516
|
+
* `buildRequest` callback compatible with {@link ResourceMethodSpec}.
|
|
517
|
+
* Use from a resource client whose builder cannot fail; resource clients
|
|
518
|
+
* with local validation should construct the {@link Result} directly.
|
|
519
|
+
*
|
|
520
|
+
* @param request - The pre-built {@link HttpRequest}.
|
|
521
|
+
* @returns A success Result wrapping the request.
|
|
522
|
+
*/
|
|
523
|
+
function okRequest(request) {
|
|
524
|
+
return {
|
|
525
|
+
data: request,
|
|
526
|
+
success: true
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* A {@link ResourceMethodSpec.parse} implementation for endpoints that return
|
|
531
|
+
* no business payload on success (such as `DELETE` and reorder operations).
|
|
532
|
+
* Surfaces `undefined` data and never inspects the response body.
|
|
533
|
+
*
|
|
534
|
+
* @returns A success Result with `undefined` data.
|
|
535
|
+
*/
|
|
536
|
+
function parseEmptyResponse() {
|
|
537
|
+
return {
|
|
538
|
+
data: void 0,
|
|
539
|
+
success: true
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const CLIENT_DEFAULTS = Object.freeze({
|
|
543
|
+
baseUrl: "https://apis.roblox.com",
|
|
544
|
+
maxRetries: 3,
|
|
545
|
+
retryableStatuses: IDEMPOTENT_METHOD_DEFAULTS.retryableStatuses,
|
|
546
|
+
retryDelay: defaultRetryDelay,
|
|
547
|
+
timeout: 3e4
|
|
548
|
+
});
|
|
549
|
+
/**
|
|
550
|
+
* Internal orchestrator shared by every Open Cloud resource client. Holds
|
|
551
|
+
* the frozen client config, observability hooks, injected HTTP client and
|
|
552
|
+
* sleep, and the per-effective-key rate-limit queue registry. Resource
|
|
553
|
+
* classes compose one instance and dispatch every public method through
|
|
554
|
+
* {@link ResourceClient.execute} with a per-method {@link ResourceMethodSpec}.
|
|
555
|
+
* Not exported from any package subpath; reachable only via sibling
|
|
556
|
+
* `src/resources/**` modules in this package.
|
|
557
|
+
*/
|
|
558
|
+
var ResourceClient = class {
|
|
559
|
+
#config;
|
|
560
|
+
#hooks;
|
|
561
|
+
#httpClient;
|
|
562
|
+
#queues = /* @__PURE__ */ new Map();
|
|
563
|
+
#sleep;
|
|
564
|
+
/**
|
|
565
|
+
* Creates a new {@link ResourceClient}. Resolves the injected HTTP
|
|
566
|
+
* client and sleep (defaulting to fetch + `setTimeout`) and freezes the
|
|
567
|
+
* merged client config so subsequent calls cannot mutate it.
|
|
568
|
+
*
|
|
569
|
+
* @param options - Client-level configuration including the API key
|
|
570
|
+
* and optional construction-time test seams.
|
|
571
|
+
*/
|
|
572
|
+
constructor(options) {
|
|
573
|
+
const { apiKey, hooks, httpClient, sleep, ...overrides } = options;
|
|
574
|
+
const resolved = resolveDependencies({
|
|
575
|
+
httpClient,
|
|
576
|
+
sleep
|
|
577
|
+
});
|
|
578
|
+
this.#httpClient = resolved.httpClient;
|
|
579
|
+
this.#sleep = resolved.sleep;
|
|
580
|
+
this.#hooks = hooks ?? {};
|
|
581
|
+
this.#config = Object.freeze({
|
|
582
|
+
...CLIENT_DEFAULTS,
|
|
583
|
+
apiKey,
|
|
584
|
+
...overrides
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Dispatches a single resource-method call. Merges the frozen client
|
|
589
|
+
* config with the method's `methodDefaults` and the caller's optional
|
|
590
|
+
* per-request `options`, routes through the effective-apiKey rate-limit
|
|
591
|
+
* queue, runs the retry loop, and finally parses the response with the
|
|
592
|
+
* spec's parser.
|
|
593
|
+
*
|
|
594
|
+
* @param call - The per-method spec, resource-specific parameters, and
|
|
595
|
+
* optional per-request overrides.
|
|
596
|
+
* @returns The parsed success payload or the {@link OpenCloudError} that
|
|
597
|
+
* caused the request to fail. Never throws.
|
|
598
|
+
*/
|
|
599
|
+
async execute(call) {
|
|
600
|
+
const { options, parameters, spec } = call;
|
|
601
|
+
const merged = mergeConfig(this.#config, {
|
|
602
|
+
methodDefaults: spec.methodDefaults,
|
|
603
|
+
methodKind: spec.methodKind,
|
|
604
|
+
requestOptions: options ?? {}
|
|
605
|
+
});
|
|
606
|
+
const requestResult = spec.buildRequest(parameters);
|
|
607
|
+
if (!requestResult.success) return requestResult;
|
|
608
|
+
const requestConfig = {
|
|
609
|
+
apiKey: merged.apiKey,
|
|
610
|
+
baseUrl: merged.baseUrl,
|
|
611
|
+
timeout: merged.timeout
|
|
612
|
+
};
|
|
613
|
+
const httpResult = await this.#getQueue(merged.apiKey, spec.operationLimit).acquire(async () => {
|
|
614
|
+
return executeWithRetry(requestResult.data, {
|
|
615
|
+
config: merged,
|
|
616
|
+
hooks: this.#hooks,
|
|
617
|
+
send: async (toSend) => this.#httpClient.request(toSend, requestConfig),
|
|
618
|
+
sleep: this.#sleep
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
if (!httpResult.success) return {
|
|
622
|
+
err: enrichPermissionError(httpResult.err, spec),
|
|
623
|
+
success: false
|
|
624
|
+
};
|
|
625
|
+
return spec.parse(httpResult.data);
|
|
626
|
+
}
|
|
627
|
+
#getQueue(apiKey, limit) {
|
|
628
|
+
const key = `${apiKey}::${limit.operationKey}`;
|
|
629
|
+
const existing = this.#queues.get(key);
|
|
630
|
+
if (existing !== void 0) return existing;
|
|
631
|
+
const queue = new RateLimitQueue(limit, this.#hooks, this.#sleep);
|
|
632
|
+
this.#queues.set(key, queue);
|
|
633
|
+
return queue;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
function enrichPermissionError(err, spec) {
|
|
637
|
+
if (spec.requiredScopes === void 0) return err;
|
|
638
|
+
if (err instanceof PermissionError) return err;
|
|
639
|
+
if (!(err instanceof ApiError)) return err;
|
|
640
|
+
if (err.statusCode !== 401 && err.statusCode !== 403) return err;
|
|
641
|
+
return new PermissionError(err.message, {
|
|
642
|
+
cause: err.cause,
|
|
643
|
+
code: err.code,
|
|
644
|
+
operationKey: spec.operationLimit.operationKey,
|
|
645
|
+
requiredScopes: spec.requiredScopes,
|
|
646
|
+
statusCode: err.statusCode
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
//#endregion
|
|
650
|
+
export { IDEMPOTENT_METHOD_DEFAULTS as a, CREATE_METHOD_DEFAULTS as i, okRequest as n, isRecord as o, parseEmptyResponse as r, ResourceClient as t };
|
|
651
|
+
|
|
652
|
+
//# sourceMappingURL=resource-client-CaS_j3yg.mjs.map
|