@bedrock-rbx/ocale 0.1.0-beta.9 → 0.1.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.
Files changed (82) hide show
  1. package/README.md +182 -0
  2. package/dist/badges.d.mts +33 -2
  3. package/dist/badges.d.mts.map +1 -1
  4. package/dist/badges.mjs +16 -4
  5. package/dist/badges.mjs.map +1 -1
  6. package/dist/{data.generated-Cb6g6asv.d.mts → data.generated-B2ceLfSn.d.mts} +9 -1
  7. package/dist/data.generated-B2ceLfSn.d.mts.map +1 -0
  8. package/dist/developer-products.d.mts +38 -2
  9. package/dist/developer-products.d.mts.map +1 -1
  10. package/dist/developer-products.mjs +32 -8
  11. package/dist/developer-products.mjs.map +1 -1
  12. package/dist/game-passes.d.mts +40 -2
  13. package/dist/game-passes.d.mts.map +1 -1
  14. package/dist/game-passes.mjs +30 -6
  15. package/dist/game-passes.mjs.map +1 -1
  16. package/dist/index.d.mts +21 -12
  17. package/dist/index.d.mts.map +1 -1
  18. package/dist/index.mjs +5 -5
  19. package/dist/locales.d.mts +1 -1
  20. package/dist/locales.mjs +2 -0
  21. package/dist/locales.mjs.map +1 -1
  22. package/dist/luau-execution.d.mts +22 -3
  23. package/dist/luau-execution.d.mts.map +1 -1
  24. package/dist/luau-execution.mjs +29 -13
  25. package/dist/luau-execution.mjs.map +1 -1
  26. package/dist/places.d.mts +20 -2
  27. package/dist/places.d.mts.map +1 -1
  28. package/dist/places.mjs +34 -14
  29. package/dist/places.mjs.map +1 -1
  30. package/dist/{poll-timeout-Dg_QFEqi.mjs → poll-timeout-BfxUWWCZ.mjs} +6 -2
  31. package/dist/poll-timeout-BfxUWWCZ.mjs.map +1 -0
  32. package/dist/{polling-helpers-CS00apdU.mjs → polling-helpers-DCw9LyJM.mjs} +225 -78
  33. package/dist/polling-helpers-DCw9LyJM.mjs.map +1 -0
  34. package/dist/{polling-BMrYajok.d.mts → polling-rj0dGREg.d.mts} +73 -14
  35. package/dist/polling-rj0dGREg.d.mts.map +1 -0
  36. package/dist/{price-information-DIrvwCmd.mjs → price-information-C4gC2CMZ.mjs} +2 -2
  37. package/dist/{price-information-DIrvwCmd.mjs.map → price-information-C4gC2CMZ.mjs.map} +1 -1
  38. package/dist/{rate-limit-D1q2Js-z.mjs → rate-limit-Dh2leqaB.mjs} +49 -3
  39. package/dist/rate-limit-Dh2leqaB.mjs.map +1 -0
  40. package/dist/resource-client-CIAkS2xQ.mjs +960 -0
  41. package/dist/resource-client-CIAkS2xQ.mjs.map +1 -0
  42. package/dist/retry-Bh2nNjBV.d.mts +234 -0
  43. package/dist/retry-Bh2nNjBV.d.mts.map +1 -0
  44. package/dist/retry-BvZRZDXs.mjs +380 -0
  45. package/dist/retry-BvZRZDXs.mjs.map +1 -0
  46. package/dist/{signatures-B5Fojgn0.mjs → signatures-JF-7Psce.mjs} +1 -1
  47. package/dist/{signatures-B5Fojgn0.mjs.map → signatures-JF-7Psce.mjs.map} +1 -1
  48. package/dist/storage.d.mts +30 -27
  49. package/dist/storage.d.mts.map +1 -1
  50. package/dist/storage.mjs +46 -31
  51. package/dist/storage.mjs.map +1 -1
  52. package/dist/testing.d.mts +24 -9
  53. package/dist/testing.d.mts.map +1 -1
  54. package/dist/testing.mjs +2 -2
  55. package/dist/testing.mjs.map +1 -1
  56. package/dist/{to-blob-B27VhoRp.mjs → to-blob-RPqMBuft.mjs} +1 -1
  57. package/dist/{to-blob-B27VhoRp.mjs.map → to-blob-RPqMBuft.mjs.map} +1 -1
  58. package/dist/{types-CwtZT1ek.d.mts → types-CRiJg5EQ.d.mts} +46 -4
  59. package/dist/types-CRiJg5EQ.d.mts.map +1 -0
  60. package/dist/universes.d.mts +47 -5
  61. package/dist/universes.d.mts.map +1 -1
  62. package/dist/universes.mjs +31 -15
  63. package/dist/universes.mjs.map +1 -1
  64. package/dist/{validation-DkL5KQqz.mjs → validation-xJZRa8tX.mjs} +4 -2
  65. package/dist/validation-xJZRa8tX.mjs.map +1 -0
  66. package/dist/{wire-CeL6W_V7.d.mts → wire-D3K-a-UP.d.mts} +3 -1
  67. package/dist/wire-D3K-a-UP.d.mts.map +1 -0
  68. package/package.json +6 -5
  69. package/dist/data.generated-Cb6g6asv.d.mts.map +0 -1
  70. package/dist/permission-error-DOVtNq3A.mjs +0 -46
  71. package/dist/permission-error-DOVtNq3A.mjs.map +0 -1
  72. package/dist/poll-timeout-Dg_QFEqi.mjs.map +0 -1
  73. package/dist/polling-BMrYajok.d.mts.map +0 -1
  74. package/dist/polling-helpers-CS00apdU.mjs.map +0 -1
  75. package/dist/rate-limit-BYuizHoD.d.mts +0 -92
  76. package/dist/rate-limit-BYuizHoD.d.mts.map +0 -1
  77. package/dist/rate-limit-D1q2Js-z.mjs.map +0 -1
  78. package/dist/resource-client-D6Efj9fU.mjs +0 -732
  79. package/dist/resource-client-D6Efj9fU.mjs.map +0 -1
  80. package/dist/types-CwtZT1ek.d.mts.map +0 -1
  81. package/dist/validation-DkL5KQqz.mjs.map +0 -1
  82. package/dist/wire-CeL6W_V7.d.mts.map +0 -1
@@ -0,0 +1,960 @@
1
+ import { n as NetworkError, r as ApiError, t as RateLimitError } from "./rate-limit-Dh2leqaB.mjs";
2
+ import { a as defaultRetryDelay, i as computeRetryWaitMs, l as PermissionError, n as IDEMPOTENT_METHOD_DEFAULTS, o as mergeConfig, s as shouldRetry } from "./retry-BvZRZDXs.mjs";
3
+ import { setTimeout } from "node:timers/promises";
4
+ //#region src/internal/utils/is-date-time-string.ts
5
+ /**
6
+ * Narrows `value` to a string that parses to a real {@link Date} via the
7
+ * `Date(string)` constructor. Used by resource parsers to gate
8
+ * `format: date-time` wire fields before handing them to `new Date(...)`,
9
+ * which silently produces an `Invalid Date` for invalid input.
10
+ *
11
+ * @param value - The unknown wire value to validate.
12
+ * @returns `true` when `value` is a string and `new Date(value).getTime()`
13
+ * is not `NaN`.
14
+ */
15
+ function isDateTimeString(value) {
16
+ if (typeof value !== "string") return false;
17
+ const parsed = new Date(value);
18
+ return !Number.isNaN(parsed.getTime());
19
+ }
20
+ //#endregion
21
+ //#region src/internal/utils/is-record.ts
22
+ /**
23
+ * Narrows `value` to a plain JSON-style record. Excludes arrays, class
24
+ * instances, primitives, and `null`/`undefined`. Used by resource
25
+ * parsers to gate property access on wire bodies whose shape isn't
26
+ * known at compile time.
27
+ *
28
+ * @param value - The unknown value to narrow.
29
+ * @returns `true` when `value` is a plain `[object Object]`.
30
+ */
31
+ function isRecord(value) {
32
+ return Object.prototype.toString.call(value) === "[object Object]";
33
+ }
34
+ //#endregion
35
+ //#region src/internal/http/budget-tracker.ts
36
+ const MS_PER_SECOND = 1e3;
37
+ /**
38
+ * Tracks the live rate-limit budget for a single scope. Primed by `observe`
39
+ * from response headers and drawn down by `reserve` as requests leave, so
40
+ * `waitMs` can pace requests across the window.
41
+ *
42
+ * Pacing has two regimes. While budget remains, requests are spread evenly over
43
+ * the time left in the window (`timeLeft / remaining`), so a burst does not
44
+ * spend the whole window's budget up front and then stall. Once the budget is
45
+ * spent, requests hold until the window resets. Budget and reset time move
46
+ * together as one window, so the tracker is either unprimed or fully primed,
47
+ * never half-known.
48
+ */
49
+ var BudgetTracker = class {
50
+ /** Time (ms) the most recent request was allowed out, for spacing. */
51
+ #lastAllowedAt = void 0;
52
+ #window = void 0;
53
+ /**
54
+ * Folds a fresh server reading in, replacing any prior window. The latest
55
+ * reading wins: observe time is monotonic, so the most recently resolved
56
+ * response is the best current estimate. The spacing reference is left
57
+ * untouched so a window refresh does not reset pacing mid-stream.
58
+ *
59
+ * @param sample - Parsed `remaining`/`resetSeconds` from a response.
60
+ * @param now - The current time in ms.
61
+ */
62
+ observe(sample, now) {
63
+ this.#window = {
64
+ predictedRemaining: sample.remaining,
65
+ resetAt: now + sample.resetSeconds * MS_PER_SECOND
66
+ };
67
+ }
68
+ /**
69
+ * Accounts for one request leaving at `now`: records the spacing reference
70
+ * and decrements the prediction. A no-op on the prediction while unprimed.
71
+ *
72
+ * @param now - The time the request was allowed out, in ms.
73
+ */
74
+ reserve(now) {
75
+ this.#lastAllowedAt = now;
76
+ if (this.#window !== void 0) this.#window = {
77
+ ...this.#window,
78
+ predictedRemaining: this.#window.predictedRemaining - 1
79
+ };
80
+ }
81
+ /**
82
+ * Milliseconds to wait before the next request is allowed.
83
+ *
84
+ * @param now - The current time in ms.
85
+ * @returns `0` when a request may go now (unprimed, or the first paced send);
86
+ * the time until reset when the budget is spent; otherwise the time until
87
+ * this request's evenly-spaced slot.
88
+ */
89
+ waitMs(now) {
90
+ if (this.#window === void 0) return 0;
91
+ const { predictedRemaining, resetAt } = this.#window;
92
+ if (predictedRemaining <= 0) return Math.max(0, resetAt - now);
93
+ if (this.#lastAllowedAt === void 0) return 0;
94
+ const interval = (resetAt - now) / predictedRemaining;
95
+ return Math.max(0, this.#lastAllowedAt + interval - now);
96
+ }
97
+ };
98
+ //#endregion
99
+ //#region src/internal/http/budget-gate.ts
100
+ /**
101
+ * Header-primed rate-limit gate shared across a client. Holds one
102
+ * {@link BudgetTracker} per API key, since the tightest Roblox window is the
103
+ * per-key one shared across every operation. Before each request the caller
104
+ * gates on the request's key (sleeping if its budget is spent), and after each
105
+ * response folds the parsed sample back in, so a sibling operation on the same
106
+ * key can head off a 429 the static per-operation token bucket cannot foresee.
107
+ * A per-operation tracker is deliberately not kept: every operation reports the
108
+ * same most-constrained `remaining`, so a per-key tracker (drawn down by all
109
+ * operations) is always the binding constraint.
110
+ *
111
+ * Gating is serialized per scope through a promise chain so concurrent
112
+ * requests on one key cannot read the same budget and reserve the same slot;
113
+ * each waits for the prior gate's reserve before computing its own.
114
+ */
115
+ var BudgetGate = class {
116
+ #chains = /* @__PURE__ */ new Map();
117
+ #sleep;
118
+ #trackers = /* @__PURE__ */ new Map();
119
+ /**
120
+ * Creates a gate bound to an injectable sleep.
121
+ *
122
+ * @param sleep - Injectable sleep (tests pass a fake clock).
123
+ */
124
+ constructor(sleep) {
125
+ this.#sleep = sleep;
126
+ }
127
+ /**
128
+ * Holds until the scope's budget permits a send, then reserves one slot.
129
+ * Runs after the prior gate on the same scope settles, whether it resolved
130
+ * or rejected, so one failed attempt cannot poison later gates on the key.
131
+ *
132
+ * @param scope - The scope key to gate on (the effective API key).
133
+ */
134
+ async gate(scope) {
135
+ const previous = this.#chains.get(scope) ?? Promise.resolve();
136
+ const runGate = async () => this.#gateOnce(scope);
137
+ const mine = previous.then(runGate, runGate);
138
+ this.#chains.set(scope, mine);
139
+ await mine;
140
+ }
141
+ /**
142
+ * Folds a response's parsed budget back onto the scope. A `undefined`
143
+ * sample (headers absent or non-numeric) is ignored, leaving the scope on
144
+ * static pacing.
145
+ *
146
+ * @param scope - The same scope key passed to {@link gate}.
147
+ * @param sample - Parsed sample, or `undefined` when none was reported.
148
+ */
149
+ observe(scope, sample) {
150
+ if (sample === void 0) return;
151
+ this.#tracker(scope).observe(sample, Date.now());
152
+ }
153
+ async #gateOnce(scope) {
154
+ const tracker = this.#tracker(scope);
155
+ const waitMs = tracker.waitMs(Date.now());
156
+ if (waitMs > 0) await this.#sleep(waitMs);
157
+ tracker.reserve(Date.now());
158
+ }
159
+ #tracker(scope) {
160
+ const existing = this.#trackers.get(scope);
161
+ if (existing !== void 0) return existing;
162
+ const tracker = new BudgetTracker();
163
+ this.#trackers.set(scope, tracker);
164
+ return tracker;
165
+ }
166
+ };
167
+ //#endregion
168
+ //#region src/internal/http/execute.ts
169
+ /**
170
+ * Retry-aware orchestration loop. Coordinates a single logical request,
171
+ * looping over `options.send` until it succeeds, the error is non-retryable,
172
+ * or `options.config.maxRetries` is exhausted. Fires observability hooks
173
+ * at each transition. Domain- and queue-agnostic: `send` may be any
174
+ * callback, including one wrapped by a rate-limit queue.
175
+ *
176
+ * @param request - The immutable request to send.
177
+ * @param options - The transport callback, resolved config, hooks, and sleep.
178
+ * @returns The first success, or the final error after retries are exhausted.
179
+ */
180
+ async function executeWithRetry(request, options) {
181
+ const { config, hooks, send, sleep } = options;
182
+ async function attempt() {
183
+ hooks.onRequest?.(request);
184
+ return send(request);
185
+ }
186
+ let result = await attempt();
187
+ for (let retry = 0; retry < config.maxRetries; retry++) {
188
+ if (result.success || !shouldRetry(result.err, config)) return result;
189
+ const { err } = result;
190
+ hooks.onRetry?.(retry + 1, err);
191
+ const waitMs = computeRetryWaitMs(err, {
192
+ attempt: retry,
193
+ retryDelay: config.retryDelay
194
+ });
195
+ hooks.onRateLimit?.(waitMs);
196
+ await sleep(waitMs);
197
+ result = await attempt();
198
+ }
199
+ return result;
200
+ }
201
+ //#endregion
202
+ //#region src/internal/http/rate-limit-sample.ts
203
+ /**
204
+ * Reduces a comma-separated rate-limit header value (e.g. `"0, 70000"`) to a
205
+ * single non-negative integer via `combine`. Tokens are trimmed; blank and
206
+ * non-finite tokens (`""`, `"Infinity"`, `"abc"`) are dropped so a stray value
207
+ * cannot corrupt the result. Returns `undefined` when the header is absent or
208
+ * has no finite tokens.
209
+ *
210
+ * @param headerValue - The raw header value, or `undefined` if missing.
211
+ * @param combine - Pairwise reducer, `Math.min` for remaining, `Math.max` for reset.
212
+ * @returns The reduced, floored, clamped value, or `undefined`.
213
+ */
214
+ function reduceRateLimitTokens(headerValue, combine) {
215
+ if (headerValue === void 0) return;
216
+ const tokens = headerValue.split(",").map((part) => part.trim()).filter((part) => part !== "").map((part) => Number(part)).filter((value) => Number.isFinite(value));
217
+ if (tokens.length === 0) return;
218
+ return Math.max(0, Math.floor(tokens.reduce(combine)));
219
+ }
220
+ /**
221
+ * Parses the `x-ratelimit-remaining` and `x-ratelimit-reset` response headers
222
+ * into a {@link RateLimitSample}. Each header may carry a comma-separated list
223
+ * of per-window values; `remaining` takes the smallest (most constrained) and
224
+ * `resetSeconds` takes the largest (longest wait), symmetric to how a 429's
225
+ * retry delay is reduced. Returns `undefined` when either header is missing or
226
+ * has no finite numeric tokens, so a caller can fall back to static pacing.
227
+ *
228
+ * @param headers - Response headers with lowercased keys.
229
+ * @returns The parsed sample, or `undefined` when the budget cannot be read.
230
+ */
231
+ function parseRateLimitHeaders(headers) {
232
+ const remaining = reduceRateLimitTokens(headers["x-ratelimit-remaining"], (a, b) => Math.min(a, b));
233
+ const resetSeconds = reduceRateLimitTokens(headers["x-ratelimit-reset"], (a, b) => Math.max(a, b));
234
+ if (remaining === void 0 || resetSeconds === void 0) return;
235
+ return {
236
+ remaining,
237
+ resetSeconds
238
+ };
239
+ }
240
+ //#endregion
241
+ //#region src/internal/http/rate-limit-observation.ts
242
+ /**
243
+ * Extracts a {@link RateLimitSample} from a transport result so the budget gate
244
+ * can be fed from every attempt. A 2xx carries the budget in its headers; a 429
245
+ * carries it on the {@link RateLimitError} (the raw headers are dropped before
246
+ * this point). Any other error, or a response that reported no budget, yields
247
+ * `undefined` and leaves the gate on static pacing.
248
+ *
249
+ * @param result - The classified transport result for one attempt.
250
+ * @returns The parsed sample, or `undefined` when none was reported.
251
+ */
252
+ function rateLimitSampleFromResult(result) {
253
+ if (result.success) return parseRateLimitHeaders(result.data.headers);
254
+ const { err } = result;
255
+ if (err instanceof RateLimitError && err.remaining !== void 0) return {
256
+ remaining: err.remaining,
257
+ resetSeconds: err.retryAfterSeconds
258
+ };
259
+ }
260
+ //#endregion
261
+ //#region src/internal/http/rate-limit-queue.ts
262
+ /**
263
+ * Token-bucket rate limiter for a single `(apiKey, operation)` pair. Every
264
+ * call to `acquire` consumes one token; when the bucket is empty the call
265
+ * waits until a token regenerates before invoking the task. Burst capacity
266
+ * equals `maxPerSecond`, refilling at `maxPerSecond` tokens per second.
267
+ *
268
+ * Implemented as a leaky bucket tracking drain debt in ms. `#lastCheck`
269
+ * advances by `waitMs` after every sleep so the algorithm stays correct
270
+ * whether or not the injected sleep moves `Date.now()` forward.
271
+ */
272
+ var RateLimitQueue = class {
273
+ #hooks;
274
+ #intervalMs;
275
+ #maxBucketLevel;
276
+ #sleep;
277
+ #bucketLevel = 0;
278
+ #chain = Promise.resolve();
279
+ #lastCheck = Date.now();
280
+ /**
281
+ * Creates a rate-limit queue bound to a single operation.
282
+ *
283
+ * @param limit - The operation key and its per-second request ceiling.
284
+ * @param hooks - Observability callbacks; `onRateLimit` fires when the
285
+ * bucket is empty and a sleep is about to start.
286
+ * @param sleep - Injectable sleep (tests pass a fake).
287
+ */
288
+ constructor(limit, hooks, sleep) {
289
+ this.#intervalMs = 1e3 / limit.maxPerSecond;
290
+ this.#maxBucketLevel = limit.maxPerSecond * this.#intervalMs;
291
+ this.#hooks = hooks;
292
+ this.#sleep = sleep;
293
+ }
294
+ /**
295
+ * Waits for a token — sleeping and firing `hooks.onRateLimit` if the
296
+ * bucket is empty — then executes `task`. Concurrent callers are
297
+ * serialized at token acquisition; tasks themselves run independently
298
+ * once their token is secured.
299
+ *
300
+ * @param task - The request to run once a token is available.
301
+ * @returns The value produced by `task`.
302
+ */
303
+ async acquire(task) {
304
+ const myTurn = this.#chain.then(async () => this.#waitForToken());
305
+ this.#chain = myTurn;
306
+ await myTurn;
307
+ return task();
308
+ }
309
+ async #waitForToken() {
310
+ const now = Math.max(Date.now(), this.#lastCheck);
311
+ const drained = Math.max(0, this.#bucketLevel - (now - this.#lastCheck));
312
+ this.#lastCheck = now;
313
+ if (drained + this.#intervalMs <= this.#maxBucketLevel) {
314
+ this.#bucketLevel = drained + this.#intervalMs;
315
+ return;
316
+ }
317
+ const waitMs = drained + this.#intervalMs - this.#maxBucketLevel;
318
+ this.#hooks.onRateLimit?.(waitMs);
319
+ await this.#sleep(waitMs);
320
+ this.#bucketLevel = this.#maxBucketLevel;
321
+ this.#lastCheck = now + waitMs;
322
+ }
323
+ };
324
+ //#endregion
325
+ //#region src/internal/utils/try-catch.ts
326
+ /**
327
+ * Wraps a promise into a {@link Result}, catching rejections.
328
+ *
329
+ * @template T - The resolved value type.
330
+ * @param promise - The promise to wrap.
331
+ * @returns A Result containing the resolved value or the rejection error.
332
+ */
333
+ async function tryCatch(promise) {
334
+ try {
335
+ return {
336
+ data: await promise,
337
+ success: true
338
+ };
339
+ } catch (err) {
340
+ return {
341
+ err: err instanceof Error ? err : new Error(String(err)),
342
+ success: false
343
+ };
344
+ }
345
+ }
346
+ //#endregion
347
+ //#region src/internal/http/diagnostics.ts
348
+ const DIAGNOSTIC_HEADER_ALLOWLIST = new Set([
349
+ "cf-ray",
350
+ "server",
351
+ "via",
352
+ "x-request-id"
353
+ ]);
354
+ const DIAGNOSTIC_HEADER_PREFIX = "x-roblox-";
355
+ const TITLE_PATTERN = /<title[^>]*>([\S\s]*?)<\/title>/i;
356
+ const H1_PATTERN = /<h1[^>]*>([\S\s]*?)<\/h1>/i;
357
+ const TAG_PATTERN = /<[^>]*>/g;
358
+ const WHITESPACE_PATTERN = /\s+/g;
359
+ /**
360
+ * Extracts a one-line human summary from an HTML gateway error page, or returns
361
+ * `undefined` when the body is not such a page. A load balancer (HAProxy-style)
362
+ * rejects a request before it reaches Open Cloud and answers with an HTML page,
363
+ * not a JSON Open Cloud error; dumping that HTML whole is noise. The body is
364
+ * treated as HTML when the content-type is `text/html` or the trimmed body is
365
+ * tag-led (`<html`/`<!doctype html`), and the summary is taken from the
366
+ * `<title>` (falling back to the first `<h1>`), tags stripped and whitespace
367
+ * collapsed.
368
+ *
369
+ * @param contentType - The response `content-type` header, if present.
370
+ * @param rawText - The raw response body text.
371
+ * @returns The extracted summary, or `undefined` when the body is not an HTML
372
+ * gateway page (or carries no title/h1 text).
373
+ */
374
+ function extractGatewaySummary(contentType, rawText) {
375
+ if (!isHtmlBody(contentType, rawText)) return;
376
+ return firstTagText(rawText, TITLE_PATTERN) ?? firstTagText(rawText, H1_PATTERN);
377
+ }
378
+ /**
379
+ * Filters a lowercased header record down to the diagnostic allowlist: a few
380
+ * named escalation headers plus any `x-roblox-*` header. Keeps errors light and
381
+ * avoids retaining anything sensitive from the full response header set.
382
+ *
383
+ * @param headers - The full header record (lowercased keys).
384
+ * @returns A record containing only the allowlisted headers that were present.
385
+ */
386
+ function pickDiagnosticHeaders(headers) {
387
+ const picked = {};
388
+ for (const [name, value] of Object.entries(headers)) if (DIAGNOSTIC_HEADER_ALLOWLIST.has(name) || name.startsWith(DIAGNOSTIC_HEADER_PREFIX)) picked[name] = value;
389
+ return picked;
390
+ }
391
+ function firstTagText(html, pattern) {
392
+ const inner = pattern.exec(html)?.[1];
393
+ if (inner === void 0) return;
394
+ const text = inner.replace(TAG_PATTERN, " ").replace(WHITESPACE_PATTERN, " ").trim();
395
+ return text === "" ? void 0 : text;
396
+ }
397
+ function isHtmlBody(contentType, rawText) {
398
+ if (contentType?.toLowerCase().includes("text/html") === true) return true;
399
+ const head = rawText.trimStart().toLowerCase();
400
+ return head.startsWith("<html") || head.startsWith("<!doctype html");
401
+ }
402
+ //#endregion
403
+ //#region src/internal/http/fetch-client.ts
404
+ const MAX_DETAIL_LENGTH = 500;
405
+ const CONTENT_TYPE_HEADER = "content-type";
406
+ /**
407
+ * Converts a `Headers` object to a plain record with lowercased keys.
408
+ *
409
+ * @param headers - The `Headers` instance to convert.
410
+ * @returns A record mapping lowercased header names to their values.
411
+ */
412
+ function headersToRecord(headers) {
413
+ return Object.fromEntries(headers);
414
+ }
415
+ /**
416
+ * Permissively extracts a machine-readable error code from a response body.
417
+ *
418
+ * Modern Open Cloud responses use `{ errorCode: string, message: string }`;
419
+ * the legacy game-internationalization endpoints use
420
+ * `{ errors: [{ code: number, message: string }, ...] }`. Both shapes are
421
+ * checked; numeric legacy codes are returned as strings so callers see one
422
+ * consistent type.
423
+ *
424
+ * @param body - The parsed response body (unknown shape).
425
+ * @returns The error code if present, otherwise `undefined`.
426
+ */
427
+ function extractErrorCode(body) {
428
+ if (body === null || typeof body !== "object") return;
429
+ const errorCode = Reflect.get(body, "errorCode");
430
+ if (typeof errorCode === "string") return errorCode;
431
+ return extractLegacyCode(body);
432
+ }
433
+ /**
434
+ * Permissively extracts a human-readable error message from a response body.
435
+ *
436
+ * Modern Open Cloud responses expose `message` at the top level; the legacy
437
+ * game-internationalization endpoints nest it under `errors[0].message`.
438
+ *
439
+ * @param body - The parsed response body (unknown shape).
440
+ * @returns The message if present, otherwise `undefined`.
441
+ */
442
+ function extractErrorMessage(body) {
443
+ if (body === null || typeof body !== "object") return;
444
+ const message = Reflect.get(body, "message");
445
+ if (typeof message === "string") return message;
446
+ return extractLegacyMessage(body);
447
+ }
448
+ /**
449
+ * Parses the `x-ratelimit-reset` header value into seconds. On a 429 the header
450
+ * is a comma-separated list of per-window reset times (e.g. `"22, 0"`, one entry
451
+ * per rate-limit window); the largest value is the longest-resetting window and
452
+ * the only safe wait that won't retry into a still-exhausted window. A single
453
+ * value is treated as a one-element list.
454
+ *
455
+ * @param headerValue - The raw header value, or `undefined` if missing.
456
+ * @returns The number of seconds to wait, or 0 if missing/invalid.
457
+ */
458
+ function parseRetryAfterSeconds(headerValue) {
459
+ return reduceRateLimitTokens(headerValue, (a, b) => Math.max(a, b)) ?? 0;
460
+ }
461
+ /**
462
+ * Joins the base URL from config with the relative path from the request.
463
+ *
464
+ * @param request - The HTTP request containing the relative URL.
465
+ * @param config - The request config containing the base URL.
466
+ * @returns The fully-qualified URL string.
467
+ */
468
+ function buildUrl(request, config) {
469
+ return `${config.baseUrl.endsWith("/") ? config.baseUrl.slice(0, -1) : config.baseUrl}${request.url}`;
470
+ }
471
+ /**
472
+ * Constructs the `RequestInit` options for a `fetch` call.
473
+ *
474
+ * @param request - The HTTP request to build options for.
475
+ * @param config - The request config containing API key and timeout.
476
+ * @returns A `RequestInit` object ready for `fetch`.
477
+ */
478
+ function buildFetchOptions(request, config) {
479
+ const headers = new Headers({ "x-api-key": config.apiKey });
480
+ const options = {
481
+ headers,
482
+ method: request.method
483
+ };
484
+ if (request.body instanceof FormData) options.body = request.body;
485
+ else if (request.body instanceof Uint8Array) {
486
+ headers.set(CONTENT_TYPE_HEADER, "application/octet-stream");
487
+ options.body = request.body;
488
+ } else if (request.body !== void 0) {
489
+ headers.set(CONTENT_TYPE_HEADER, "application/json");
490
+ options.body = JSON.stringify(request.body);
491
+ }
492
+ if (request.headers !== void 0) for (const [name, value] of Object.entries(request.headers)) {
493
+ if (name.toLowerCase() === "x-api-key") continue;
494
+ headers.set(name, value);
495
+ }
496
+ if (config.timeout !== void 0) options.signal = AbortSignal.timeout(config.timeout);
497
+ return options;
498
+ }
499
+ /**
500
+ * Creates an {@link HttpClient} backed by the Fetch API.
501
+ *
502
+ * @param fetchFunc - The fetch implementation to use. Defaults to `globalThis.fetch`.
503
+ * @param now - Monotonic-ish clock used to measure request elapsed time.
504
+ * Defaults to `Date.now`; injectable so tests can assert a fixed duration.
505
+ * @returns An HttpClient that classifies responses into typed Results.
506
+ */
507
+ function createFetchHttpClient(fetchFunc = globalThis.fetch, now = Date.now) {
508
+ return { async request(httpRequest, config) {
509
+ const url = buildUrl(httpRequest, config);
510
+ const options = buildFetchOptions(httpRequest, config);
511
+ const target = {
512
+ method: httpRequest.method,
513
+ url
514
+ };
515
+ const { elapsedMs, fetchResult } = await timedFetch(now, async () => fetchFunc(url, options));
516
+ if (!fetchResult.success) return {
517
+ err: networkError(fetchResult.err, target),
518
+ success: false
519
+ };
520
+ const context = {
521
+ elapsedMs,
522
+ method: target.method,
523
+ url: target.url
524
+ };
525
+ const classified = await tryCatch(classifyResponse(fetchResult.data, context));
526
+ if (!classified.success) return {
527
+ err: networkError(classified.err, target),
528
+ success: false
529
+ };
530
+ return classified.data;
531
+ } };
532
+ }
533
+ function readLegacyErrorEntry(body) {
534
+ const errors = Reflect.get(body, "errors");
535
+ if (!Array.isArray(errors)) return;
536
+ const [first] = errors;
537
+ if (typeof first !== "object" || first === null) return;
538
+ return first;
539
+ }
540
+ function extractLegacyCode(body) {
541
+ const first = readLegacyErrorEntry(body);
542
+ if (first === void 0) return;
543
+ const code = Reflect.get(first, "code");
544
+ if (typeof code === "string") return code;
545
+ return typeof code === "number" ? String(code) : void 0;
546
+ }
547
+ function extractLegacyMessage(body) {
548
+ const first = readLegacyErrorEntry(body);
549
+ if (first === void 0) return;
550
+ const message = Reflect.get(first, "message");
551
+ return typeof message === "string" ? message : void 0;
552
+ }
553
+ /**
554
+ * Runs `send` and reports both its Result and how long it was in flight,
555
+ * measured with `now`. Isolated so the timing start need not sit in the request
556
+ * body ahead of the transport-failure early return.
557
+ *
558
+ * @param now - The clock used to bound the call.
559
+ * @param send - A thunk that issues the fetch.
560
+ * @returns The fetch Result and the elapsed milliseconds.
561
+ */
562
+ async function timedFetch(now, send) {
563
+ const start = now();
564
+ const fetchResult = await tryCatch(send());
565
+ return {
566
+ elapsedMs: Math.max(0, now() - start),
567
+ fetchResult
568
+ };
569
+ }
570
+ function networkError(cause, target) {
571
+ return new NetworkError("Network request failed", {
572
+ cause,
573
+ method: target.method,
574
+ url: target.url
575
+ });
576
+ }
577
+ function formatApiErrorMessage(parts) {
578
+ const { code, message, status } = parts;
579
+ const base = `HTTP ${status}`;
580
+ if (message === void 0 && code === void 0) return base;
581
+ if (message === void 0) return `${base} (code ${code})`;
582
+ if (code === void 0) return `${base}: ${message}`;
583
+ return `${base}: ${message} (code ${code})`;
584
+ }
585
+ /**
586
+ * Projects a read body to the detail carried on an error: the parsed JSON when
587
+ * it parsed, otherwise the raw text truncated to {@link MAX_DETAIL_LENGTH}.
588
+ *
589
+ * @param text - The raw response body text.
590
+ * @param parsed - The best-effort parse result from {@link readResponseBody}.
591
+ * @returns The parsed body, or the truncated raw text on a parse failure.
592
+ */
593
+ function bodyDetail(text, parsed) {
594
+ return parsed.success ? parsed.data : text.slice(0, MAX_DETAIL_LENGTH);
595
+ }
596
+ function createApiError(args) {
597
+ const { context, rawText, response } = args;
598
+ const { status } = response;
599
+ const headers = headersToRecord(response.headers);
600
+ const requestContext = {
601
+ elapsedMs: context.elapsedMs,
602
+ method: context.method,
603
+ responseHeaders: pickDiagnosticHeaders(headers),
604
+ statusCode: status,
605
+ url: context.url
606
+ };
607
+ const gatewaySummary = extractGatewaySummary(headers[CONTENT_TYPE_HEADER], rawText);
608
+ if (gatewaySummary !== void 0) return new ApiError(`HTTP ${status}`, {
609
+ ...requestContext,
610
+ gatewaySummary
611
+ });
612
+ const body = bodyDetail(rawText, args.parsed);
613
+ const code = extractErrorCode(body);
614
+ return new ApiError(formatApiErrorMessage({
615
+ code,
616
+ message: extractErrorMessage(body),
617
+ status
618
+ }), {
619
+ ...requestContext,
620
+ code,
621
+ details: body
622
+ });
623
+ }
624
+ /**
625
+ * Parses response text as JSON, returning the underlying `SyntaxError` on
626
+ * failure rather than throwing. The synchronous sibling of {@link tryCatch}.
627
+ *
628
+ * @param text - The raw response body text.
629
+ * @returns A Result wrapping the parsed value, or the parse error.
630
+ */
631
+ function parseJson(text) {
632
+ try {
633
+ return {
634
+ data: JSON.parse(text),
635
+ success: true
636
+ };
637
+ } catch (err) {
638
+ return {
639
+ err: err instanceof Error ? err : new Error(String(err)),
640
+ success: false
641
+ };
642
+ }
643
+ }
644
+ /**
645
+ * Reads a response body once and parses it best-effort: an empty body is a
646
+ * successful `undefined`, otherwise the JSON parse result (which carries the
647
+ * `SyntaxError` on failure). Returns the raw `text` alongside so callers that
648
+ * need the original bytes (parse-failure diagnostics) do not re-read the
649
+ * consumed stream.
650
+ *
651
+ * @param response - The Response whose body to read.
652
+ * @returns The parse result and the raw text.
653
+ */
654
+ async function readResponseBody(response) {
655
+ const text = await response.text();
656
+ return {
657
+ parsed: text === "" ? {
658
+ data: void 0,
659
+ success: true
660
+ } : parseJson(text),
661
+ text
662
+ };
663
+ }
664
+ async function createRateLimitError(response) {
665
+ const headers = headersToRecord(response.headers);
666
+ const { parsed, text } = await readResponseBody(response);
667
+ return new RateLimitError("Rate limited", {
668
+ details: bodyDetail(text, parsed),
669
+ remaining: reduceRateLimitTokens(headers["x-ratelimit-remaining"], (a, b) => Math.min(a, b)),
670
+ retryAfterSeconds: parseRetryAfterSeconds(headers["x-ratelimit-reset"]),
671
+ statusCode: response.status
672
+ });
673
+ }
674
+ /**
675
+ * Builds the error for a 2xx response whose body could not be parsed as JSON,
676
+ * preserving the parse `cause`, the (truncated) raw body, and the declared
677
+ * content-type so the failure can be diagnosed after the fact.
678
+ *
679
+ * @param args - The Response, raw body text, and underlying parse error.
680
+ * @returns An ApiError carrying the diagnostic context.
681
+ */
682
+ function parseFailureError({ cause, response, text }) {
683
+ return new ApiError(`Failed to parse response body (content-type: ${response.headers.get(CONTENT_TYPE_HEADER) ?? "unknown"})`, {
684
+ cause,
685
+ details: text.slice(0, MAX_DETAIL_LENGTH),
686
+ statusCode: response.status
687
+ });
688
+ }
689
+ /**
690
+ * Classifies a fetch `Response` into a typed `Result`.
691
+ *
692
+ * The body is read once and parsed best-effort. Error responses (status >= 300)
693
+ * never require valid JSON: an error body that is not valid JSON degrades to a
694
+ * status-based {@link ApiError} carrying the raw text, and an HTML gateway page
695
+ * is summarized rather than dumped. A parse failure is only fatal on a 2xx,
696
+ * where a parseable body is part of the contract.
697
+ *
698
+ * @param response - The raw fetch Response to classify.
699
+ * @param context - The request context (method, url, elapsed time) threaded
700
+ * onto any {@link ApiError} built for an error response.
701
+ * @returns A Result containing an HttpResponse on success or an OpenCloudError on failure.
702
+ */
703
+ async function classifyResponse(response, context) {
704
+ if (response.status === 429) return {
705
+ err: await createRateLimitError(response),
706
+ success: false
707
+ };
708
+ const { parsed, text } = await readResponseBody(response);
709
+ if (response.status >= 300) return {
710
+ err: createApiError({
711
+ context,
712
+ parsed,
713
+ rawText: text,
714
+ response
715
+ }),
716
+ success: false
717
+ };
718
+ if (!parsed.success) return {
719
+ err: parseFailureError({
720
+ cause: parsed.err,
721
+ response,
722
+ text
723
+ }),
724
+ success: false
725
+ };
726
+ return {
727
+ data: {
728
+ body: parsed.data,
729
+ headers: headersToRecord(response.headers),
730
+ status: response.status
731
+ },
732
+ success: true
733
+ };
734
+ }
735
+ //#endregion
736
+ //#region src/internal/http/resolve-dependencies.ts
737
+ /**
738
+ * Resolves the concrete HTTP client and sleep implementation a resource
739
+ * client should use. Falls back to the fetch-backed HTTP client and the
740
+ * default `setTimeout`-based sleep when the caller omits the test seams.
741
+ *
742
+ * Extracted so resource client constructors can keep their dependency
743
+ * resolution logic in a single, unit-testable place; this makes the
744
+ * default branches easy to cover without stubbing globals like `fetch`.
745
+ *
746
+ * @param options - Optional {@link HttpClient} and {@link SleepFunc} test seams.
747
+ * @returns A {@link ResolvedDependencies} with defaults applied.
748
+ */
749
+ function resolveDependencies(options) {
750
+ return {
751
+ httpClient: options.httpClient ?? createFetchHttpClient(),
752
+ sleep: options.sleep ?? setTimeout
753
+ };
754
+ }
755
+ //#endregion
756
+ //#region src/internal/http/upload-request.ts
757
+ /**
758
+ * Reports whether a request is an upload: its body is `FormData`
759
+ * (multipart) or `Uint8Array` (raw binary). Upload latency is
760
+ * bandwidth-bound rather than compute-bound, so the SDK applies no default
761
+ * request timeout to these requests; a sensible wall-clock budget depends on
762
+ * payload size and link quality the SDK cannot know.
763
+ *
764
+ * @param request - The built request to classify.
765
+ * @returns `true` when the body is `FormData` or `Uint8Array`.
766
+ */
767
+ function isUploadRequest(request) {
768
+ return request.body instanceof FormData || request.body instanceof Uint8Array;
769
+ }
770
+ //#endregion
771
+ //#region src/internal/resource-client.ts
772
+ /**
773
+ * Wraps an infallible request build as a {@link Result}-returning
774
+ * `buildRequest` callback compatible with {@link ResourceMethodSpec}.
775
+ * Use from a resource client whose builder cannot fail; resource clients
776
+ * with local validation should construct the {@link Result} directly.
777
+ *
778
+ * @param request - The pre-built {@link HttpRequest}.
779
+ * @returns A success Result wrapping the request.
780
+ */
781
+ function okRequest(request) {
782
+ return {
783
+ data: request,
784
+ success: true
785
+ };
786
+ }
787
+ /**
788
+ * A {@link ResourceMethodSpec.parse} implementation for endpoints that return
789
+ * no business payload on success (such as `DELETE` and reorder operations).
790
+ * Surfaces `undefined` data and never inspects the response body.
791
+ *
792
+ * @returns A success Result with `undefined` data.
793
+ */
794
+ function parseEmptyResponse() {
795
+ return {
796
+ data: void 0,
797
+ success: true
798
+ };
799
+ }
800
+ const CLIENT_DEFAULTS = Object.freeze({
801
+ baseUrl: "https://apis.roblox.com",
802
+ maxRetries: 3,
803
+ retryableStatuses: IDEMPOTENT_METHOD_DEFAULTS.retryableStatuses,
804
+ retryableTransportCodes: IDEMPOTENT_METHOD_DEFAULTS.retryableTransportCodes,
805
+ retryDelay: defaultRetryDelay,
806
+ timeout: 3e4
807
+ });
808
+ /**
809
+ * Internal orchestrator shared by every Open Cloud resource client. Holds
810
+ * the frozen client config, observability hooks, injected HTTP client and
811
+ * sleep, and the per-effective-key rate-limit queue registry. Resource
812
+ * classes compose one instance and dispatch every public method through
813
+ * {@link ResourceClient.execute} with a per-method {@link ResourceMethodSpec}.
814
+ * Not exported from any package subpath; reachable only via sibling
815
+ * `src/resources/**` modules in this package.
816
+ */
817
+ var ResourceClient = class {
818
+ #budgets;
819
+ #config;
820
+ #hooks;
821
+ #httpClient;
822
+ #queues = /* @__PURE__ */ new Map();
823
+ #sleep;
824
+ /**
825
+ * Creates a new {@link ResourceClient}. Resolves the injected HTTP
826
+ * client and sleep (defaulting to fetch + `setTimeout`) and freezes the
827
+ * merged client config so subsequent calls cannot mutate it.
828
+ *
829
+ * @param options - Client-level configuration including the API key
830
+ * and optional construction-time test seams.
831
+ */
832
+ constructor(options) {
833
+ const { apiKey, hooks, httpClient, sleep, ...overrides } = options;
834
+ const resolved = resolveDependencies({
835
+ httpClient,
836
+ sleep
837
+ });
838
+ this.#httpClient = resolved.httpClient;
839
+ this.#sleep = resolved.sleep;
840
+ this.#budgets = new BudgetGate(this.#sleep);
841
+ this.#hooks = hooks ?? {};
842
+ this.#config = Object.freeze({
843
+ ...CLIENT_DEFAULTS,
844
+ apiKey,
845
+ ...overrides
846
+ });
847
+ }
848
+ /**
849
+ * Dispatches a single resource-method call. Merges the frozen client
850
+ * config with the method's `methodDefaults` and the caller's optional
851
+ * per-request `options`, routes through the effective-apiKey rate-limit
852
+ * queue, runs the retry loop, and finally parses the response with the
853
+ * spec's parser.
854
+ *
855
+ * @param call - The per-method spec, resource-specific parameters, and
856
+ * optional per-request overrides.
857
+ * @returns The parsed success payload or the {@link OpenCloudError} that
858
+ * caused the request to fail. Never throws.
859
+ */
860
+ async execute(call) {
861
+ const { options, parameters, spec } = call;
862
+ const merged = mergeConfig(this.#config, {
863
+ methodDefaults: spec.methodDefaults,
864
+ methodKind: spec.methodKind,
865
+ requestOptions: options ?? {}
866
+ });
867
+ const requestResult = spec.buildRequest(parameters);
868
+ if (!requestResult.success) return requestResult;
869
+ const requestConfig = buildRequestConfig({
870
+ merged,
871
+ options,
872
+ request: requestResult.data
873
+ });
874
+ const httpResult = await this.#getQueue(merged.apiKey, spec.operationLimit).acquire(async () => {
875
+ return executeWithRetry(requestResult.data, {
876
+ config: merged,
877
+ hooks: this.#hooks,
878
+ send: this.#gatedSend(merged.apiKey, requestConfig),
879
+ sleep: this.#sleep
880
+ });
881
+ });
882
+ if (!httpResult.success) return {
883
+ err: enrichPermissionError(httpResult.err, spec),
884
+ success: false
885
+ };
886
+ return spec.parse(httpResult.data);
887
+ }
888
+ /**
889
+ * Returns the sleep function used by this client instance.
890
+ *
891
+ * @returns The sleep function injected at construction time.
892
+ */
893
+ get sleep() {
894
+ return this.#sleep;
895
+ }
896
+ /**
897
+ * Builds the transport callback for one logical call, wrapping the HTTP
898
+ * client with the budget gate: each attempt waits on the API key's budget
899
+ * before sending, then folds the response's reported budget back in so the
900
+ * next attempt (or a sibling operation on the same key) can head off a 429.
901
+ *
902
+ * @param apiKey - The effective API key to gate on.
903
+ * @param requestConfig - The resolved per-request transport config.
904
+ * @returns A send callback for {@link executeWithRetry}.
905
+ */
906
+ #gatedSend(apiKey, requestConfig) {
907
+ return async (toSend) => {
908
+ await this.#budgets.gate(apiKey);
909
+ const sendResult = await this.#httpClient.request(toSend, requestConfig);
910
+ this.#budgets.observe(apiKey, rateLimitSampleFromResult(sendResult));
911
+ return sendResult;
912
+ };
913
+ }
914
+ #getQueue(apiKey, limit) {
915
+ const key = `${apiKey}::${limit.operationKey}`;
916
+ const existing = this.#queues.get(key);
917
+ if (existing !== void 0) return existing;
918
+ const queue = new RateLimitQueue(limit, this.#hooks, this.#sleep);
919
+ this.#queues.set(key, queue);
920
+ return queue;
921
+ }
922
+ };
923
+ /**
924
+ * Resolves the per-request {@link RequestConfig}. Upload requests
925
+ * ({@link isUploadRequest}) carry no default timeout: a multi-megabyte place
926
+ * file over a slow link is bandwidth-bound, so a client-side deadline only
927
+ * fires spuriously. An explicit `options.timeout` still applies to any
928
+ * request; every non-upload request keeps the merged default.
929
+ *
930
+ * @param inputs - The merged config, the built request, and per-request overrides.
931
+ * @returns The config to hand to the transport, with `timeout` omitted when
932
+ * no client-side deadline should apply.
933
+ */
934
+ function buildRequestConfig(inputs) {
935
+ const { merged, options, request } = inputs;
936
+ const shouldOmitDefaultTimeout = options?.timeout === void 0 && isUploadRequest(request);
937
+ return {
938
+ apiKey: merged.apiKey,
939
+ baseUrl: merged.baseUrl,
940
+ ...shouldOmitDefaultTimeout ? {} : { timeout: merged.timeout }
941
+ };
942
+ }
943
+ function enrichPermissionError(err, spec) {
944
+ if (spec.requiredScopes === void 0) return err;
945
+ if (err instanceof PermissionError) return err;
946
+ if (!(err instanceof ApiError)) return err;
947
+ if (err.statusCode !== 401 && err.statusCode !== 403) return err;
948
+ return new PermissionError(err.message, {
949
+ cause: err.cause,
950
+ code: err.code,
951
+ details: err.details,
952
+ operationKey: spec.operationLimit.operationKey,
953
+ requiredScopes: spec.requiredScopes,
954
+ statusCode: err.statusCode
955
+ });
956
+ }
957
+ //#endregion
958
+ export { isDateTimeString as a, isRecord as i, okRequest as n, parseEmptyResponse as r, ResourceClient as t };
959
+
960
+ //# sourceMappingURL=resource-client-CIAkS2xQ.mjs.map