Date: 2025-12-13 Status: Historical (superseded by implementation)
Package: @bedrock-rbx/ocale (renamed from @bedrock-rbx/open-cloud)
Heads up. This document captures the design as it was planned in December 2025. The shipped implementation diverged on several points. Do not read this as a specification. For the current contract, consult:
- packages/open-cloud/CLAUDE.md for orientation and principles
- ADR-010 for the rate-limit, retry, and hook contract
- ADR-011 for the simplified library architecture
- ADR-012 for the class-based client and per-request overrides shape
- the source under
packages/open-cloud/src/Categories of drift between this doc and reality: URL paths come from the vendored OpenAPI schema rather than hardcoded prefixes; rate limits are per-operation rather than per-client; retries wrap the queue via a send-callback rather than living inside a queue slot; response parsing sits outside the retry loop; build output is
.mjs/.d.mtsviavp pack, not.jsviatsdown.
This document outlines the design for @bedrock-rbx/open-cloud, a standalone
TypeScript HTTP client for Roblox Open Cloud APIs. The package provides
type-safe, functional access to ~12 Open Cloud service categories with optimal
tree-shaking, Result-based error handling, and per-request configuration
overrides.
Key Design Decisions:
- Single package with subpath exports (bundle optimization)
- Class-based clients with immutable config (encapsulation + ergonomics)
- Result types over exceptions (functional, explicit error handling)
- Stainless-style per-request overrides (multi-key support)
- Simplified architecture (pure builders/parsers, no FCIS terminology)
- Standalone & Publishable: Usable independently of Bedrock CLI
- Bundle Optimized: Subpath exports enable optimal tree-shaking
- Type-Safe: Full TypeScript coverage with exported types
- Testable: 100% coverage, pure functions tested without mocks
- Zero Dependencies: Lightweight, secure, fast installation
- Developer-Friendly: Intuitive API inspired by Stainless-generated SDKs
Research reveals ~12 major service categories (not 800 individual endpoints):
- Data and memory stores
- Luau execution
- Monetization (creator store, subscriptions)
- Universes and places
- Users and groups
- Game passes
- Developer products
- Badges
- Asset delivery and permissions
- Game internationalization
- Publish
- Game icons and thumbnails
Implication: Modest API surface makes subpath exports practical without package explosion.
AWS SDK v3:
- Separate packages per service (
@aws-sdk/client-dynamodb) - Client instances with per-request overrides
- Tree-shaking via modular packages
OpenAI SDK (Stainless-generated):
- Single package, client instance pattern
- Per-request config override via optional second parameter
- TypeScript-first with strong type safety
Our approach: Combine both patterns - single package with subpath exports + Stainless-style client pattern.
| Feature | API Status | Operations |
|---|---|---|
| Game Passes | ✅ Full Support | Create, Read, Update, List |
| Developer Products | ✅ Full Support | Create, Read, Update, List |
| Game Icons | ✅ Full Support | Upload, Delete |
| Game Thumbnails | ✅ Full Support | Upload, Delete, Reorder, Alt Text, Get Media |
| Universes | ✅ Full Support | Read, Update (title, settings) |
- Avatar settings (Studio-only, no programmatic API)
- Advanced localization (auto-translate, bulk operations)
- Additional services (groups, users, data stores)
- OAuth 2.0 support (API keys only for v0.1)
packages/open-cloud/
├── src/
│ ├── resources/ # Service clients (public API)
│ │ ├── game-passes/
│ │ │ ├── index.ts # GamePassesClient class
│ │ │ ├── types.ts # Public types
│ │ │ ├── builders.ts # Pure: request builders
│ │ │ └── parsers.ts # Pure: response parsers
│ │ ├── developer-products/
│ │ ├── game-icons/
│ │ ├── game-thumbnails/
│ │ └── universes/
│ │
│ ├── internal/ # Internal utilities (not exported)
│ │ ├── http/
│ │ │ ├── fetch-client.ts # HTTP implementation
│ │ │ ├── rate-limit-queue.ts # Rate limiting & queuing
│ │ │ ├── multipart.ts # Multipart encoding
│ │ │ └── types.ts # HttpRequest, HttpResponse
│ │ └── utils/
│ │ └── try-catch.ts # tryCatch helper
│ │
│ ├── errors/ # Error classes (exported)
│ │ ├── base.ts # OpenCloudError
│ │ ├── rate-limit.ts # RateLimitError
│ │ ├── api-error.ts # ApiError
│ │ ├── network-error.ts # NetworkError
│ │ └── validation-error.ts # ValidationError
│ │
│ ├── client/ # Shared client types
│ │ └── types.ts # OpenCloudClientOptions, RequestOptions
│ │
│ ├── types.ts # Shared types (Result, etc.)
│ └── index.ts # Root export (errors + shared types only)
│
└── tests/
├── helpers/ # Test utilities
│ └── fake-http-client.ts # Fake HTTP client for testing
│
├── unit/ # Pure function tests
│ └── resources/
│ └── game-passes/
│ ├── builders.spec.ts
│ └── parsers.spec.ts
│
└── integration/ # Client tests with fake HTTP
└── resources/
└── game-passes/
└── client.spec.ts
Why NOT strict FCIS?
ADR-002 prescribes FCIS for the Bedrock CLI (the application with business
logic). ADR-011 refines ADR-002 for library packages: a package may use a
simplified architecture if it satisfies a five-criteria rubric (single
responsibility, minimal translation, stable external contract, no meaningful
pure core to separate, swappability is imaginary). @bedrock-rbx/open-cloud
satisfies all five — it is an HTTP client library for one vendor API. Forcing
"Ports and Adapters" terminology on it would produce Ports with exactly one
implementation: ceremony rather than architecture.
What we keep from functional principles:
- ✅ Pure builders (testable request construction)
- ✅ Pure parsers (testable response parsing)
- ✅ Separation of concerns (public vs internal)
- ✅ Result types (explicit error handling)
- ✅ Immutable config (no hidden state)
What we simplify:
- ❌ No "Core/Shell" terminology (confusing for libraries)
- ❌ No "Ports" (HTTP isn't swappable - it's the contract)
- ❌ No "Adapters" (just call it "HTTP implementation")
Class-based clients with immutable config:
export class GamePassesClient {
private readonly config: OpenCloudConfig;
private readonly http: HttpClient;
constructor(options: OpenCloudClientOptions) {
this.config = Object.freeze({ ...options });
this.http = options.httpClient ?? createHttpClient();
}
public async create(
parameters: CreateGamePassParams,
options?: RequestOptions,
): Promise<Result<GamePass, OpenCloudError>> {
const request = buildCreateRequest(parameters); // Pure function
const result = await this.http.request(request, {
...this.config,
...options, // Per-request override
});
if (!result.success) {
return result;
}
const gamePass = parseGamePassResponse(result.data.body); // Pure function
return { data: gamePass, success: true };
}
}Inspired by Stainless-generated SDKs:
const client = new GamePassesClient({ apiKey: "main-key" });
// Normal usage (uses main-key)
const result = await client.create({
name: "VIP Pass",
priceInRobux: 100,
universeId: "123",
});
// Override for asset uploads (different account for moderation safety)
const resultWithIcon = await client.create(
{
name: "VIP Pass",
iconFile: imageData,
priceInRobux: 100,
universeId: "123",
},
{
apiKey: "asset-upload-key", // Override API key for this request
timeout: 60000, // Override timeout
},
);Use case: Bedrock users can use separate API keys for asset uploads to mitigate content moderation account bans.
Aligned with functional skill principles:
export type Result<T, E = Error> =
{ data: T; success: true } | { err: E; success: false };
// All client methods return Promise<Result<T, OpenCloudError>>
const result = await client.create(params);
if (!result.success) {
if (result.err instanceof RateLimitError) {
await sleep(result.err.retryAfterSeconds * 1000);
// retry...
} else {
console.error("Failed:", result.err.message);
}
return;
}
console.log("Success:", result.data.id);Why Result over exceptions:
- Explicit error handling (visible in type system)
- No hidden control flow
- Aligns with functional programming principles
- TypeScript narrows types correctly
- TypeScript-first: camelCase (not snake_case like Roblox API)
- Strict types: No
any, preferunknownfor untyped data - Readonly responses: All response types immutable
- Export everything: All public types exported for consumers
// Request types
export interface CreateGamePassParameters {
name: string;
description?: string;
iconFile?: Uint8Array;
priceInRobux: number;
universeId: string;
}
// Response types (readonly)
export interface GamePass {
readonly id: string;
readonly name: string;
readonly createdTimestamp: string;
readonly description?: string;
readonly iconImageId?: string;
readonly path: string;
readonly priceInRobux: number;
readonly updatedTimestamp: string;
}
export interface OpenCloudClientOptions {
apiKey: string;
baseUrl?: string;
// Testing
httpClient?: HttpClient; // Inject fake HTTP client for tests
// Retry configuration
maxRetries?: number; // Default: 3
onRateLimit?: (waitMs: number) => void;
// Observability hooks
onRequest?: (request: HttpRequest) => void;
onRetry?: (attempt: number, error: OpenCloudError) => void;
retryableStatuses?: Array<number>; // Default: [429, 500, 502, 503, 504]
retryDelay?: (attempt: number) => number; // Exponential backoff
timeout?: number;
}
// Pagination
export interface PaginatedResponse<T> {
readonly items: ReadonlyArray<T>;
readonly nextPageToken?: string;
}
// Per ADR-012, RequestOptions is derived from OpenCloudClientOptions via
// Pick rather than declared independently, so the two types cannot drift.
// Construction-only fields (`httpClient` — the test seam) and client-level
// concerns (`onRequest`, `onRetry`, `onRateLimit` — observability hooks are
// a client-level concern per ADR-010 and ADR-012) are intentionally
// excluded from the Pick.
export type RequestOptions = Pick<
OpenCloudClientOptions,
| "apiKey"
| "baseUrl"
| "maxRetries"
| "retryableStatuses"
| "retryDelay"
| "timeout"
>;
// Note: override semantics are **shallow merge**. Passing
// `retryableStatuses: [429]` in `RequestOptions` replaces the default array
// entirely — it does not extend it. See ADR-012.This section documents expected SDK behavior for edge cases that may occur during normal operation.
| Scenario | Behavior |
|---|---|
| Empty file (0 bytes) | Allow (API may reject, return ApiError) |
| Oversized file | Client-side validation (return ValidationError) |
| Invalid MIME type | Allow (API validates, return ApiError if rejected) |
| Unicode in filename | Encode properly in multipart Content-Disposition |
| Missing filename | Generate default: upload-{timestamp}.bin |
| Null/undefined file data | Return ValidationError before making request |
Implementation notes:
- Max file size limits per API must be researched and enforced client-side
- Use
TextEncoderfor proper filename encoding in multipart headers - Validation happens in builders (pure functions return Result types)
| Scenario | Behavior |
|---|---|
| Empty result set | Return { items: [], nextPageToken: undefined } |
| Invalid nextPageToken | Pass to API, return ApiError if rejected |
| Expired nextPageToken | API returns error, client propagates as ApiError |
| Missing maxPageSize | Use API default (typically 100) |
| maxPageSize out of bounds | Client validates against API limits, return error |
| Scenario | Behavior |
|---|---|
| Multiple clients, same API key | Correctness hazard — see note below |
| Very large queue (>1000 items) | No limit, continue queuing (memory warning) |
| Queue during client disposal | Pending requests complete, new rejected |
| Concurrent key override | Each key gets separate queue lazily on one client |
Multiple clients sharing an API key is a correctness hazard, not just a
performance gap (ADR-010). Two GamePassesClient instances with the same key
maintain independent Map<string, RateLimitQueue> fields that do not
coordinate, so the SDK silently double-spends the key's server-side quota and
produces 429s the built-in rate limiter should have prevented. Adaptive 429
backoff absorbs the over-issue transparently, but the SDK will have promised
rate limiting it did not deliver.
Correct-by-construction solution: use a single client instance per
resource type and distribute work across multiple API keys via per-request
RequestOptions.apiKey overrides (ADR-012). Rate-limit queues are keyed by the
effective API key inside a single client, so every request for a given key
routes through the same queue regardless of which call site supplied it. Bedrock
CLI uses one client per resource type in any case.
Implementation notes:
- Document the single-client + per-request-override pattern in the package README as the recommended multi-key workflow
- Consider adding queue size metrics to observability hooks in future
| Scenario | Behavior |
|---|---|
| Request timeout | Return NetworkError after configured timeout |
| Timeout during retry | Each retry attempt gets full timeout window |
| DNS resolution failure | Return NetworkError with cause |
| Connection refused | Return NetworkError with cause |
| Response body parse error | Return ApiError "Failed to parse response" |
| Partial response received | Depends on fetch() behavior, likely NetworkError |
| Scenario | Behavior |
|---|---|
| Empty string API key | Allow (API will reject with 401/403) |
| Malformed API key | Allow (API validates format) |
| Expired API key | API returns 401/403, propagate as ApiError |
| Key without permissions | API returns 403, propagate as ApiError |
| Key for wrong universe | API returns 403/404, propagate as ApiError |
Rationale: Client-side key validation is fragile and can break when Roblox changes key formats. Let the API be the source of truth.
SDK manages concurrency, not the CLI. Bedrock can fire all requests at once - the SDK queues and rate-limits them internally. This prevents the CLI from needing to coordinate retries with its own queue.
const client = new GamePassesClient({
apiKey: "key",
maxRetries: 3,
onRateLimit: (waitMs) => console.log(`[RATE LIMIT] Waiting ${waitMs}ms...`),
// Observability hooks (optional)
onRequest: (request) => {
return console.log(`[REQUEST] ${request.method} ${request.url}`);
},
onRetry: (attempt, error) => {
return console.log(`[RETRY ${attempt}] ${error.message}`);
},
});
// Bedrock fires all 10 requests - SDK queues internally
// Rate limits are built-in (no configuration needed)
const results = await Promise.all([
client.create({ name: "Pass 1", priceInRobux: 100, universeId: "123" }),
client.create({ name: "Pass 2", priceInRobux: 100, universeId: "123" }),
// ... 10 total
]);Note: Rate limits are built into each client based on Roblox's documented API limits. Users don't configure them - the SDK knows the correct limits per API.
Per ADR-010, the default retryableStatuses list is per-method, not
per-client. Read/list/update/delete methods default to
[429, 500, 502, 503, 504]; create methods default to [429] only (no 5xx
retry, because Roblox does not support idempotency keys — see the Idempotency
section below). Client-level retryableStatuses in OpenCloudClientOptions
does not override the create-method guard; only a retryableStatuses value
passed in RequestOptions to a specific create() call can override it, for
consumers who can guarantee idempotency externally.
// Rate limits per API (from Roblox documentation)
const GAME_PASSES_RATE_LIMIT = {
requestsPerMinute: 60,
requestsPerSecond: 10,
};
// Default retryable status codes per operation class (ADR-010).
const RETRYABLE_STATUSES_SAFE = [429, 500, 502, 503, 504] as const;
const RETRYABLE_STATUSES_CREATE = [429] as const;
class GamePassesClient {
private readonly config: OpenCloudConfig;
private readonly queues: Map<string, RateLimitQueue>;
constructor(options: OpenCloudClientOptions) {
this.config = Object.freeze({ ...options });
this.queues = new Map();
}
public async create(
parameters: CreateGamePassParams,
options?: RequestOptions,
): Promise<Result<GamePass, OpenCloudError>> {
// Create-method guard: default [429] only. RequestOptions can override
// this; client-level retryableStatuses cannot (ADR-010).
const methodDefaults = {
retryableStatuses: [...RETRYABLE_STATUSES_CREATE],
};
const mergedConfig = { ...this.config, ...methodDefaults, ...options };
const queue = this.getQueue(mergedConfig.apiKey);
return queue.add(async () => {
return this.executeWithRetry(
buildCreateRequest(parameters),
mergedConfig,
);
});
}
public async get(
gamePassId: string,
options?: RequestOptions,
): Promise<Result<GamePass, OpenCloudError>> {
// Idempotent operation: default [429, 5xx]. Both client-level and
// per-request retryableStatuses can override.
const methodDefaults = { retryableStatuses: [...RETRYABLE_STATUSES_SAFE] };
const mergedConfig = { ...methodDefaults, ...this.config, ...options };
const queue = this.getQueue(mergedConfig.apiKey);
return queue.add(async () => {
return this.executeWithRetry(buildGetRequest(gamePassId), mergedConfig);
});
}
private buildRetryConfig(config: RequestConfig): {
maxRetries: number;
retryableStatuses: ReadonlyArray<number>;
retryDelay: (attempt: number) => number;
} {
return {
maxRetries: config.maxRetries ?? 3,
// `config.retryableStatuses` is always set here — callers layer in
// a per-method default before calling `executeWithRetry`.
retryableStatuses: config.retryableStatuses,
retryDelay:
config.retryDelay ??
((attemptNumber: number) => {
return Math.min(1000 * 2 ** attemptNumber, 30000);
}),
};
}
private async executeWithRetry(
request: HttpRequest,
config: RequestConfig,
): Promise<Result<GamePass, OpenCloudError>> {
const retryConfig = this.buildRetryConfig(config);
let lastError: OpenCloudError | undefined;
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
this.config.onRequest?.(request);
const result = await this.http.request(request, config);
if (result.success) {
return {
data: parseGamePassResponse(result.data.body),
success: true,
};
}
lastError = result.err;
if (
!this.shouldRetry(result.err, retryConfig) ||
attempt === retryConfig.maxRetries
) {
return result;
}
this.config.onRetry?.(attempt + 1, result.err);
// Adaptive 429 backoff: prefer `x-ratelimit-reset` when present,
// fall back to exponential backoff otherwise (ADR-010).
const waitMs = computeRetryWaitMs(
result.err,
attempt,
retryConfig.retryDelay,
);
this.config.onRateLimit?.(waitMs);
await sleep(waitMs);
}
// Fallback: should never reach here, but TypeScript requires it
return {
err:
lastError ??
new NetworkError("Request failed after all retry attempts"),
success: false,
};
}
private getQueue(apiKey: string): RateLimitQueue {
const existingQueue = this.queues.get(apiKey);
if (existingQueue) {
return existingQueue;
}
const queue = new RateLimitQueue({
onWait: this.config.onRateLimit,
requestsPerMinute: GAME_PASSES_RATE_LIMIT.requestsPerMinute,
requestsPerSecond: GAME_PASSES_RATE_LIMIT.requestsPerSecond,
});
this.queues.set(apiKey, queue);
return queue;
}
private shouldRetry(
error: OpenCloudError,
config: { retryableStatuses: ReadonlyArray<number> },
): boolean {
if (error instanceof RateLimitError) {
return config.retryableStatuses.includes(429);
}
return (
error instanceof ApiError &&
config.retryableStatuses.includes(error.statusCode)
);
}
}
// Pure helper. Returns the adaptive wait for a 429 (from
// RateLimitError.retryAfterSeconds, populated from the `x-ratelimit-reset`
// response header at parse time) or exponential backoff for any other
// retryable error.
function computeRetryWaitMs(
error: OpenCloudError,
attempt: number,
retryDelay: (attempt: number) => number,
): number {
if (error instanceof RateLimitError && error.retryAfterSeconds > 0) {
return error.retryAfterSeconds * 1000;
}
return retryDelay(attempt);
}Key precedence rules (ADR-010):
- Create methods: merge order is
{ ...this.config, ...methodDefaults, ...options }. The method default ([429]) sits between the client config andRequestOptions, so it clobbers any client-levelretryableStatusesbut can itself be overridden by a per-requestretryableStatuses. - Read/list/update/delete methods: merge order is
{ ...methodDefaults, ...this.config, ...options }. The method default ([429, 500, 502, 503, 504]) is at the bottom; both client-level and per-requestretryableStatusesoverride it. - 429 wait time:
RateLimitError.retryAfterSecondsis populated from thex-ratelimit-resetresponse header during response parsing. When it is present and positive,computeRetryWaitMsuses it directly; otherwise it falls back to the configuredretryDelayexponential-backoff function.
Implementation Note: Actual Roblox Open Cloud rate limits must be researched and documented per API during implementation. The values shown above (60/min, 10/sec) are placeholders.
Roblox 429 response headers (confirmed, per ADR-010):
x-ratelimit-limit— the quota for the windowx-ratelimit-remaining— requests left in the current windowx-ratelimit-reset— seconds until the window resets
The response parser populates RateLimitError.retryAfterSeconds from
x-ratelimit-reset when it is present on a 429, and computeRetryWaitMs uses
it directly so the SDK never over- or under-waits on throttled responses. When
the header is missing or unparseable, the SDK falls back to the configured
exponential backoff (min(1000 * 2^attempt, 30_000) ms).
Research sources:
- Roblox Open Cloud documentation
- Roblox Cloud Rate Limits reference
- HTTP 429 response headers (
x-ratelimit-limit,x-ratelimit-remaining,x-ratelimit-reset) - Roblox developer forums/announcements
Each client should have its own rate limit constants based on the specific API.
- ✅ Bedrock doesn't manage queues - Fire all requests, SDK handles it
- ✅ Rate limiting automatic - SDK knows correct limits per API
- ✅ Retries transparent - SDK handles, CLI gets notified via hooks
- ✅ Observability - CLI knows when retries/rate-limits happen
- ✅ Testable - Inject fake HTTP client, no actual rate limiting in tests
- ✅ Per-key rate limiting - Different API keys maintain separate quotas
Research completed: Roblox Open Cloud API documentation does not specify
support for idempotency headers (such as Idempotency-Key or X-Request-Id).
Sources:
Problem: Without idempotency support, retrying failed create operations on 5xx errors could result in duplicate resources.
Solution: Different retry strategies based on operation type and error code.
| Operation Type | 429 (Rate Limit) | 500/502/503/504 (Server Error) |
|---|---|---|
| Create | ✅ Retry | ❌ Do not retry |
| Read/List | ✅ Retry | ✅ Retry (safe, idempotent) |
| Update | ✅ Retry | ✅ Retry (idempotent by PUT) |
| Delete | ✅ Retry | ✅ Retry (idempotent) |
Per-method retry configuration is implemented as shown in the "Internal Implementation" code block above. The merge precedence guarantees that:
- Create methods default to
[429]and cannot be relaxed by client-levelretryableStatuses, only by aretryableStatusesvalue passed inRequestOptionsto a specific call. - Read, list, update, and delete methods default to
[429, 500, 502, 503, 504]and accept overrides from either the client config orRequestOptions.
Benefits:
- ✅ Prevents duplicate resources from retried creates
- ✅ Safe retries for idempotent operations (read, update, delete)
- ✅ Users who can guarantee idempotency externally can still relax the create
guard via
RequestOptions.retryableStatuses - ✅ Explicit and self-documenting
Trade-offs:
⚠️ Users must handle failed create operations manually (no auto-retry on 5xx, unless they opt in viaRequestOptions.retryableStatuses)⚠️ More verbose than a blanket retry policy
If Roblox Open Cloud adds idempotency key support in the future:
- Add
idempotencyKey?: stringtoRequestOptions - Include
Idempotency-Keyheader in requests when provided - Enable 5xx retries for create operations when idempotency key is present
- Update documentation with idempotency best practices
OpenCloudError (base)
├── RateLimitError (429, includes retryAfterSeconds)
├── ApiError (4xx/5xx with statusCode and code)
├── NetworkError (connection failures, fetch errors)
└── ValidationError (client-side validation)
export class OpenCloudError extends Error {
public readonly cause?: unknown;
public override readonly name = "OpenCloudError";
constructor(message: string, cause?: unknown) {
super(message);
this.cause = cause;
}
}export class ApiError extends OpenCloudError {
public readonly code?: string;
public override readonly name = "ApiError";
public readonly statusCode: number;
constructor(message: string, statusCode: number, code?: string) {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}export class RateLimitError extends OpenCloudError {
public override readonly name = "RateLimitError";
public readonly retryAfterSeconds: number;
constructor(message: string, retryAfterSeconds: number) {
super(message);
this.retryAfterSeconds = retryAfterSeconds;
}
}// Internal HTTP client returns Results
export interface HttpClient {
request(
request: HttpRequest,
config: RequestConfig,
): Promise<Result<HttpResponse, OpenCloudError>>;
}
export function createHttpClient(): HttpClient {
return {
async request(request, config) {
const fetchResult = await performFetch(request, config);
if (!fetchResult.success) {
return fetchResult;
}
const response = fetchResult.data;
const bodyResult = await parseResponseBody(response);
if (!bodyResult.success) {
return bodyResult;
}
if (!response.ok) {
return {
err: createErrorFromResponse(response, bodyResult.data),
success: false,
};
}
return {
data: {
body: bodyResult.data,
headers: Object.fromEntries(response.headers),
status: response.status,
},
success: true,
};
},
};
}
async function parseResponseBody(
response: Response,
): Promise<Result<unknown, OpenCloudError>> {
const bodyResult = await tryCatch(() => response.json());
if (!bodyResult.success) {
return {
err: new ApiError("Failed to parse response", response.status),
success: false,
};
}
return { data: bodyResult.data, success: true };
}
// Implementation uses tryCatch helper (from functional skill)
async function performFetch(
request: HttpRequest,
config: RequestConfig,
): Promise<Result<Response, OpenCloudError>> {
const result = await tryCatch(() => {
return fetch(buildUrl(request, config), buildFetchOptions(request, config));
});
if (!result.success) {
return {
err: new NetworkError("Network request failed", result.err),
success: false,
};
}
return { data: result.data, success: true };
}{
"name": "@bedrock-rbx/open-cloud",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./game-passes": {
"types": "./dist/resources/game-passes/index.d.ts",
"default": "./dist/resources/game-passes/index.js"
},
"./developer-products": {
"types": "./dist/resources/developer-products/index.d.ts",
"default": "./dist/resources/developer-products/index.js"
},
"./game-icons": {
"types": "./dist/resources/game-icons/index.d.ts",
"default": "./dist/resources/game-icons/index.js"
},
"./game-thumbnails": {
"types": "./dist/resources/game-thumbnails/index.d.ts",
"default": "./dist/resources/game-thumbnails/index.js"
},
"./universes": {
"types": "./dist/resources/universes/index.d.ts",
"default": "./dist/resources/universes/index.js"
}
},
"files": ["dist"]
}Following Turborepo recommendations: avoid barrel files that re-export everything.
export type { RequestOptions, OpenCloudClientOptions } from "./client/types";
// src/index.ts - ONLY shared utilities, NOT resource clients
export {
OpenCloudError,
RateLimitError,
ApiError,
NetworkError,
ValidationError,
} from "./errors";
export type { Result } from "./types";// ✅ CORRECT - Shared utilities from root
import { RateLimitError, type Result } from "@bedrock-rbx/open-cloud";
// ❌ IMPOSSIBLE - Clients are NOT exported from the root barrel, so
// `import { GamePassesClient } from "@bedrock-rbx/open-cloud"` is a TypeScript
// error; it prevents an accidental barrel import.
// ✅ CORRECT - Subpath import (best tree-shaking)
import { GamePassesClient } from "@bedrock-rbx/open-cloud/game-passes";
import { UniversesClient } from "@bedrock-rbx/open-cloud/universes";Benefits:
- Forces explicit subpath imports (optimal tree-shaking)
- Autocomplete can't suggest wrong import path
- Aligns with Turborepo best practices
- Only bundle what you import
tests/
├── unit/ # Pure function tests (no I/O)
│ └── resources/
│ └── game-passes/
│ ├── builders.spec.ts # Test buildCreateRequest(), etc.
│ └── parsers.spec.ts # Test parseGamePassResponse(), etc.
│
└── integration/ # Client tests with fake HTTP
└── resources/
└── game-passes/
└── client.spec.ts # Test GamePassesClient with mocked HTTP
Following Bedrock conventions:
// tests/unit/resources/game-passes/builders.spec.ts
import { buildCreateRequest } from "../../../../src/resources/game-passes/builders";
// ✅ Use function reference in describe()
describe(buildCreateRequest, () => {
it("should build request with required fields", () => {
const request = buildCreateRequest({
name: "VIP Pass",
priceInRobux: 100,
universeId: "123",
});
expect(request).toStrictEqual({
body: {
name: "VIP Pass",
priceInRobux: 100,
},
method: "POST",
url: "/cloud/v2/universes/123/game-passes",
});
});
it("should include optional description", () => {
const request = buildCreateRequest({
name: "VIP Pass",
description: "Access VIP area",
priceInRobux: 100,
universeId: "123",
});
expect(request.body).toHaveProperty("description", "Access VIP area");
});
});// tests/integration/resources/game-passes/client.spec.ts
import { GamePassesClient } from "../../../../src/resources/game-passes";
import { createFakeHttpClient } from "../../../helpers/fake-http-client";
function createTestClient(http: HttpClient): GamePassesClient {
return new GamePassesClient({
apiKey: "test-key",
httpClient: http,
});
}
describe(GamePassesClient, () => {
it("should create game pass successfully", async () => {
expect.assertions(3);
const http = createFakeHttpClient();
http.mockResponse({
body: {
name: "VIP Pass",
createdTimestamp: "2025-01-01T00:00:00Z",
path: "universes/123/game-passes/456",
priceInRobux: 100,
updatedTimestamp: "2025-01-01T00:00:00Z",
},
status: 200,
});
const client = createTestClient(http);
const result = await client.create({
name: "VIP Pass",
priceInRobux: 100,
universeId: "123",
});
expect(result.success).toBe(true);
assert(result.success);
expect(result.data.id).toBe("456");
expect(result.data.name).toBe("VIP Pass");
});
it("should handle rate limit errors", async () => {
expect.assertions(3);
const http = createFakeHttpClient();
http.mockError(new RateLimitError("Rate limited", 60));
const client = createTestClient(http);
const result = await client.create({
name: "VIP Pass",
priceInRobux: 100,
universeId: "123",
});
expect(result.success).toBe(false);
assert(!result.success);
expect(result.err).toBeInstanceOf(RateLimitError);
expect(result.err.retryAfterSeconds).toBe(60);
});
});100% coverage (statements, branches, functions, lines) required per ADR-003.
{
"dependencies": {},
"devDependencies": {
"@bedrock-rbx/typescript-config": "workspace:*",
"@bedrock-rbx/vitest-config": "workspace:*",
"@types/bun": "catalog:types",
"tsdown": "catalog:build",
"typescript": "catalog:tsc",
"vitest": "catalog:test"
},
"engines": {
"node": ">=24.12.0",
"bun": ">=1.3.0"
}
}Dual Runtime Support: Node.js LTS (24.12+) and Bun 1.3+
Node.js LTS (24.12+) requirements:
- Native
fetch()API (no polyfills) FormDataand Web Streams APITextEncoder/TextDecoderbuilt-in
API Strategy: Use standard web APIs exclusively:
- ✅
fetch()- HTTP requests - ✅
Uint8Array- Binary data (not Node.js Buffer) - ✅
TextEncoder/TextDecoder- Text encoding - ✅
FormData- Multipart encoding
No Bun-specific APIs to ensure Node.js compatibility.
Storage:
- ✅ API keys should be stored in environment variables, not hardcoded
- ✅ Use
.envfiles with.gitignorefor local development - ✅ Use secure secret management (Vault, AWS Secrets Manager) in production
- ❌ Never commit API keys to version control
- ❌ Never log API keys (even in debug mode)
SDK Behavior:
- SDK does not persist or cache API keys beyond client instance lifetime
- SDK does not log API keys in error messages or observability hooks
- SDK does not validate key format (API is source of truth)
- Keys are passed in request headers over HTTPS only
User Guidance (for SDK documentation):
// ❌ BAD: Hardcoded key
const client = new GamePassesClient({ apiKey: "abc123..." });
// ✅ GOOD: Environment variable
const client = new GamePassesClient({
apiKey: process.env.ROBLOX_API_KEY ?? "",
});
// ✅ BETTER: Validate environment variable exists
const apiKey = process.env.ROBLOX_API_KEY;
if (!apiKey) {
throw new Error("ROBLOX_API_KEY environment variable is required");
}
const client = new GamePassesClient({ apiKey });Recommended practices for users:
- Use different API keys for different permission levels
- Rotate keys periodically (e.g., every 90 days)
- Revoke compromised keys immediately via Roblox Creator Dashboard
- Use separate keys for CI/CD vs local development
SDK support:
- Per-request key override enables gradual key migration
- No client restart needed - just override key in request options
// Migrate to new key without client restart
const result = await client.create(params, {
apiKey: rotatedApiKey,
});Best practices for users:
- Create API keys with minimal required permissions (principle of least privilege)
- Use separate keys for asset uploads (moderation risk isolation)
- Audit key permissions regularly
Example permission strategy:
Key 1 (Read-only): List/get operations only
Key 2 (Deploy): Create/update universe settings, places
Key 3 (Assets): Upload game icons, thumbnails (high moderation risk)
Key 4 (Products): Manage game passes, developer products
SDK behavior:
- All requests use HTTPS by default
- No option to disable HTTPS (security by design)
baseUrloverride must usehttps://protocol- HTTP URLs rejected with ValidationError
Zero runtime dependencies:
- Eliminates supply chain attack surface
- No transitive dependency vulnerabilities
- Faster security audits (only SDK code to review)
Development dependencies:
- Use Bun's catalog for version management
- Run
pnpm audit/bun auditregularly - Update dependencies via Dependabot or Renovate
Principle: Error messages should not leak sensitive information.
Implementation:
- API keys never included in error messages or stack traces
- Request URLs sanitized to remove query parameters with sensitive data
- Response bodies only included if non-sensitive (never full auth errors)
// ❌ BAD: Leaks API key
throw new Error(`Request failed: GET ${url}?api_key=${apiKey}`);
// ✅ GOOD: Sanitized
throw new NetworkError("Request failed: GET /cloud/v2/universes/123");| Approach | Decision | Rationale |
|---|---|---|
| Class | ✅ Chosen | Natural encapsulation of config state |
| Factory Fn | ❌ Rejected | No practical benefit over classes for this use case |
| Approach | Decision | Rationale |
|---|---|---|
| Result Type | ✅ Chosen | Explicit errors, functional, type-safe |
| Exceptions | ❌ Rejected | Hidden control flow, not visible in types |
| Approach | Decision | Rationale |
|---|---|---|
| Subpath Exports | ✅ Chosen | Single install, optimal tree-shaking |
| Multiple Packages | ❌ Rejected | Installation friction, version management |
| Monolithic Package | ❌ Rejected | Poor tree-shaking, bundles unused code |
| Approach | Decision | Rationale |
|---|---|---|
| Simplified | ✅ Chosen | SDK is I/O layer, not application with business logic |
| FCIS (Strict) | ❌ Rejected | Over-engineering for HTTP client library |
Clarification: ADR-011 formalized this decision. ADR-002 prescribes FCIS
for the Bedrock CLI (application) and is the default for the monorepo;
ADR-011 carves out library packages that satisfy its five-criteria rubric.
@bedrock-rbx/open-cloud satisfies all five and uses the simplified pattern
described in this plan.
- Set up package structure with subpath exports
- Implement shared utilities:
tryCatchhelper (from functional skill)Resulttype- Error classes
- HTTP client with Result types
- Implement one service end-to-end (Game Passes):
- Client class
- Builders (pure)
- Parsers (pure)
- Unit tests
- Integration tests
- Validate pattern before replicating to other services
RED → GREEN → REFACTOR for every feature:
- RED: Write failing test
- GREEN: Write minimal code to pass
- REFACTOR: Improve code (commit before refactoring)
Git history must show TDD compliance.
Package name: @bedrock-rbx/open-cloud
Scope availability: Must verify @bedrock scope is available on npm or
create organization account.
Alternative: If @bedrock unavailable, consider @bedrock-cli/open-cloud
or @roblox-bedrock/open-cloud.
Strategy: Changesets
Why changesets:
- Automated changelog generation
- Semantic versioning enforcement
- Monorepo-friendly (future packages)
- Consumer-focused release notes
Workflow:
- Developer creates changeset:
pnpm changeset - CI validates changeset exists for PR
- Merge to main triggers changeset action
- Bot creates "Version Packages" PR with bumped versions + changelog
- Merge version PR → automated npm publish
Before publishing to npm:
- ✅ All tests pass (
pnpm test) - ✅ Build succeeds (
pnpm build) - ✅ Type checking passes (
pnpm typecheck) - ✅ Linting passes (
pnpm lint) - ✅ No uncommitted changes
- ✅ CHANGELOG.md updated
- ✅ Version bumped in package.json
Optional (manual testing):
- Test in consuming project via
pnpm link - Test E2E with real Roblox API (requires API key)
- Verify bundle size meets targets
CI/CD Pipeline (GitHub Actions):
name: Publish
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun test
- run: bun run build
- env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bunx @changesets/action publishProtection:
- Require passing CI before merge
- Require changeset for all PRs
- Use npm automation tokens (not personal tokens)
Alpha/Beta testing:
# Create pre-release
pnpm changeset pre enter alpha
pnpm changeset version
pnpm publish --tag alpha
# Exit pre-release mode
pnpm changeset pre exitUse cases:
- Alpha: Breaking changes, experimental features
- Beta: Stable but not battle-tested
- RC (release candidate): Final testing before stable
For every release:
- All tests passing on CI
- Changelog generated and reviewed
- Version follows semver (breaking = major, feature = minor, fix = patch)
- Documentation updated (if API changed)
- Migration guide (if breaking changes)
- GitHub release created with notes
- npm publish succeeded
- Verify installation:
npm install @bedrock-rbx/open-cloud@latest
Strategy: Auto-generate TypeScript docs with TypeDoc
Workflow:
- Generate docs:
pnpm typedoc - Publish to GitHub Pages via CI
- Link from README:
https://bedrock.github.io/open-cloud/
Alternative: Use npm package docs if GitHub Pages unavailable.
Tools: bundlephobia or size-limit
Targets:
- Minimal import (errors + types): < 1KB gzipped
- Single resource client: < 5KB gzipped
- Full SDK: < 15KB gzipped
CI check:
{
"devDependencies": {
"size-limit": "^11.0.0"
},
"size-limit": [
{
"path": "dist/index.js",
"limit": "1 KB"
},
{
"path": "dist/resources/game-passes/index.js",
"limit": "5 KB"
}
]
}When deprecating features:
- Add
@deprecatedJSDoc tag with migration path - Log deprecation warning at runtime (once per client instance)
- Document in changelog under "Deprecated" section
- Wait 2+ minor versions before removal
- Remove in next major version
Example:
/**
* Creates a game pass (deprecated).
*
* @deprecated Use `create()` instead. Will be removed in v2.0.0.
* @param parameters - Parameters for creating a game pass.
* @returns Result containing game pass or error.
*/
export async function createGamePass(
...parameters: Parameters<typeof this.create>
): Promise<Result<GamePass>> {
console.warn(
"createGamePass() is deprecated. Use create() instead. " +
"This method will be removed in v2.0.0.",
);
return this.create(...parameters);
}- ✅ Simplified architecture (pure builders/parsers, clear structure)
- ✅ Open Cloud only (ADR-007)
- ✅ TDD with 100% coverage (ADR-003)
- ✅ TypeScript with Bun
- ✅ ES modules only
- ✅ Standalone/publishable to npm
- ✅ Result types (functional skill)
- ✅ Subpath exports (tree-shaking)
- ✅ Rate limiting (SDK-managed queuing)
- ✅ Automatic retries with exponential backoff
- ✅ Observability hooks (onRequest, onRetry, onRateLimit)
- Rich error handling with typed errors
- Multipart file upload support
- Pagination support
- Per-request configuration override
- OAuth 2.0 support (API keys only)
- Additional services beyond v0.1 scope
- Avatar settings (Studio-only)
- Idempotency support (requires Roblox API support verification)
- ADR-001: TypeScript with Bun - runtime and tooling
- ADR-002: FCIS for CLI - refined by ADR-011 for library packages
- ADR-003: Testing strategy - TDD, 100% coverage
- ADR-007: Open Cloud only - no legacy APIs
- ADR-008: Zero runtime dependencies in
@bedrock-rbx/open-cloud- formalizes the emptydependenciesfield and native-web-API-only constraint - ADR-009: Result types over exceptions - formalizes the discriminated union
Result<T, E>used at every public method boundary - ADR-010: SDK-managed rate limiting and retry - formalizes operation-
differentiated retry policy,
x-ratelimit-resetadaptive backoff, and per-API-key queue semantics - ADR-011: Simplified architecture for library packages - formalizes the
FCIS opt-out and the five-criteria rubric;
@bedrock-rbx/open-cloudsatisfies all five and uses the simplified pattern described in this plan - ADR-012: Class-based clients with per-request config overrides -
formalizes
Object.freezed config, the(params, options?)method shape, andRequestOptionsas a subset derived fromOpenCloudClientOptions
CLAUDE.md Constraint: "Maintain Mantle migration path"
Scope: This constraint applies to the Bedrock CLI, not this SDK package.
The Open Cloud SDK is a low-level HTTP client library that provides access to Roblox APIs. Migration from Mantle to Bedrock is a CLI-layer concern involving:
- State format conversion (Mantle YAML → Bedrock state backend)
- Configuration migration (Mantle config → Bedrock c12 config)
- Command equivalence (Mantle CLI → Bedrock CLI)
- Resource mapping (Mantle resource IDs → Open Cloud resource paths)
SDK's Role: The SDK provides the API layer that the Bedrock CLI uses to interact with Roblox. It doesn't need Mantle-specific compatibility because:
- Mantle used Rust with different HTTP patterns - no API compatibility needed
- Migration happens at CLI level (state + config transformation)
- SDK just needs to support Open Cloud APIs correctly
- CLI will map Mantle concepts → Open Cloud API calls via this SDK
Conclusion: No Mantle-specific design required in SDK. Migration path is maintained at CLI layer using this SDK as the API foundation.