@playcademy/sdk 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -7,6 +7,21 @@ import { AUTH_PROVIDER_IDS } from '@playcademy/constants';
7
7
  declare class PlaycademyError extends Error {
8
8
  constructor(message: string);
9
9
  }
10
+ type CorsProbeResult = 'reachable' | 'unreachable' | 'skipped';
11
+ interface ManifestErrorDetails {
12
+ manifestUrl: string;
13
+ manifestHost: string;
14
+ deploymentUrl: string;
15
+ fetchOutcome: 'network_error' | 'bad_status' | 'invalid_body';
16
+ retryCount: number;
17
+ durationMs: number;
18
+ fetchErrorMessage?: string;
19
+ corsProbe?: CorsProbeResult;
20
+ status?: number;
21
+ contentType?: string;
22
+ cfRay?: string;
23
+ redirected?: boolean;
24
+ }
10
25
  /**
11
26
  * Error codes returned by the API.
12
27
  * These map to specific error types and HTTP status codes.
@@ -2304,4 +2319,4 @@ declare class PlaycademyMessaging {
2304
2319
  declare const messaging: PlaycademyMessaging;
2305
2320
 
2306
2321
  export { ApiError, ConnectionManager, ConnectionMonitor, MessageEvents, PlaycademyClient, PlaycademyError, extractApiErrorInfo, messaging };
2307
- export type { ApiErrorCode, ApiErrorInfo, ConnectionMonitorConfig, ConnectionState, ConnectionStatePayload, DevUploadEvent, DevUploadHooks, DisconnectContext, DisconnectHandler, DisplayAlertPayload, ErrorResponseBody };
2322
+ export type { ApiErrorCode, ApiErrorInfo, ConnectionMonitorConfig, ConnectionState, ConnectionStatePayload, DevUploadEvent, DevUploadHooks, DisconnectContext, DisconnectHandler, DisplayAlertPayload, ErrorResponseBody, ManifestErrorDetails };
package/dist/index.js CHANGED
@@ -409,10 +409,12 @@ class PlaycademyError extends Error {
409
409
 
410
410
  class ManifestError extends PlaycademyError {
411
411
  kind;
412
- constructor(message, kind) {
412
+ details;
413
+ constructor(message, kind, details) {
413
414
  super(message);
414
415
  this.name = "ManifestError";
415
416
  this.kind = kind;
417
+ this.details = details;
416
418
  Object.setPrototypeOf(this, ManifestError.prototype);
417
419
  }
418
420
  }
@@ -1925,7 +1927,7 @@ class ConnectionManager {
1925
1927
  }
1926
1928
  }
1927
1929
  }
1928
- // src/core/request.ts
1930
+ // src/core/transport/retry.ts
1929
1931
  var RETRY_DELAYS_MS = [500, 1500];
1930
1932
  function wait(ms) {
1931
1933
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1935,6 +1937,7 @@ function isRetryableStatus(status) {
1935
1937
  return status === 429 || status >= 500;
1936
1938
  }
1937
1939
  async function fetchWithRetry(url, init2) {
1940
+ const startedAt = Date.now();
1938
1941
  for (let attempt = 0;attempt <= RETRY_DELAYS_MS.length; attempt++) {
1939
1942
  const retryDelayMs = RETRY_DELAYS_MS[attempt];
1940
1943
  const canRetry = init2.method === "GET" && retryDelayMs !== undefined;
@@ -1943,18 +1946,120 @@ async function fetchWithRetry(url, init2) {
1943
1946
  if (canRetry && isRetryableStatus(response.status)) {
1944
1947
  await retryRuntime.wait(retryDelayMs);
1945
1948
  } else {
1946
- return response;
1949
+ return { response, retryCount: attempt, durationMs: Date.now() - startedAt };
1947
1950
  }
1948
1951
  } catch (error) {
1949
1952
  if (canRetry && error instanceof TypeError) {
1950
1953
  await retryRuntime.wait(retryDelayMs);
1951
1954
  } else {
1952
- throw error;
1955
+ return { error, retryCount: attempt, durationMs: Date.now() - startedAt };
1953
1956
  }
1954
1957
  }
1955
1958
  }
1956
- throw new PlaycademyError("Request failed after exhausting retries");
1959
+ return {
1960
+ error: new PlaycademyError("Request failed after exhausting retries"),
1961
+ retryCount: RETRY_DELAYS_MS.length,
1962
+ durationMs: Date.now() - startedAt
1963
+ };
1964
+ }
1965
+ function unwrapFetchResult(result) {
1966
+ if (result.error) {
1967
+ throw result.error;
1968
+ }
1969
+ if (!result.response) {
1970
+ throw new PlaycademyError("Request failed after exhausting retries");
1971
+ }
1972
+ return result.response;
1973
+ }
1974
+ // src/core/transport/manifest.ts
1975
+ var CORS_PROBE_TIMEOUT_MS = 3000;
1976
+ async function probeCorsReachability(url) {
1977
+ if (typeof globalThis.AbortController === "undefined") {
1978
+ return "skipped";
1979
+ }
1980
+ const controller = new AbortController;
1981
+ const timer = setTimeout(() => controller.abort(), CORS_PROBE_TIMEOUT_MS);
1982
+ try {
1983
+ await fetch(url, { mode: "no-cors", signal: controller.signal });
1984
+ return "reachable";
1985
+ } catch {
1986
+ return "unreachable";
1987
+ } finally {
1988
+ clearTimeout(timer);
1989
+ }
1990
+ }
1991
+ var MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
1992
+ function getFetchErrorMessage(error) {
1993
+ let raw;
1994
+ if (error instanceof Error) {
1995
+ raw = error.message;
1996
+ } else if (typeof error === "string") {
1997
+ raw = error;
1998
+ }
1999
+ if (!raw) {
2000
+ return;
2001
+ }
2002
+ const normalized = raw.replace(/\s+/g, " ").trim();
2003
+ if (!normalized) {
2004
+ return;
2005
+ }
2006
+ return normalized.slice(0, MAX_FETCH_ERROR_MESSAGE_LENGTH);
1957
2007
  }
2008
+ async function fetchManifest(deploymentUrl) {
2009
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
2010
+ const result = await fetchWithRetry(manifestUrl, { method: "GET" });
2011
+ let manifestHost;
2012
+ try {
2013
+ manifestHost = new URL(manifestUrl).host;
2014
+ } catch {
2015
+ manifestHost = manifestUrl;
2016
+ }
2017
+ const base = {
2018
+ manifestUrl,
2019
+ manifestHost,
2020
+ deploymentUrl,
2021
+ retryCount: result.retryCount,
2022
+ durationMs: result.durationMs
2023
+ };
2024
+ if (result.error || !result.response) {
2025
+ log.error(`[Playcademy SDK] Error fetching manifest from ${manifestUrl}:`, {
2026
+ error: result.error
2027
+ });
2028
+ throw new ManifestError("Failed to load game manifest", "temporary", {
2029
+ ...base,
2030
+ fetchOutcome: "network_error",
2031
+ ...result.error ? { fetchErrorMessage: getFetchErrorMessage(result.error) } : {}
2032
+ });
2033
+ }
2034
+ const response = result.response;
2035
+ if (!response.ok) {
2036
+ log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
2037
+ throw new ManifestError(`Failed to fetch manifest: ${response.status} ${response.statusText}`, isRetryableStatus(response.status) ? "temporary" : "permanent", {
2038
+ ...base,
2039
+ fetchOutcome: "bad_status",
2040
+ status: response.status,
2041
+ contentType: response.headers.get("content-type") ?? undefined,
2042
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2043
+ redirected: response.redirected
2044
+ });
2045
+ }
2046
+ try {
2047
+ return await response.json();
2048
+ } catch (error) {
2049
+ log.error(`[Playcademy SDK] Error parsing manifest from ${manifestUrl}:`, {
2050
+ error
2051
+ });
2052
+ throw new ManifestError("Failed to parse game manifest", "permanent", {
2053
+ ...base,
2054
+ fetchOutcome: "invalid_body",
2055
+ status: response.status,
2056
+ contentType: response.headers.get("content-type") ?? undefined,
2057
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2058
+ redirected: response.redirected
2059
+ });
2060
+ }
2061
+ }
2062
+ // src/core/transport/request.ts
1958
2063
  function prepareRequestBody(body, headers) {
1959
2064
  if (body instanceof FormData) {
1960
2065
  return body;
@@ -2012,12 +2117,12 @@ async function request({
2012
2117
  const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
2013
2118
  const headers = { ...extraHeaders };
2014
2119
  const payload = prepareRequestBody(body, headers);
2015
- const res = await fetchWithRetry(url, {
2120
+ const res = unwrapFetchResult(await fetchWithRetry(url, {
2016
2121
  method,
2017
2122
  headers,
2018
2123
  body: payload,
2019
2124
  credentials: "omit"
2020
- });
2125
+ }));
2021
2126
  if (raw) {
2022
2127
  return res;
2023
2128
  }
@@ -2047,31 +2152,6 @@ async function request({
2047
2152
  const rawText = await res.text().catch(() => "");
2048
2153
  return rawText && rawText.length > 0 ? rawText : undefined;
2049
2154
  }
2050
- async function fetchManifest(deploymentUrl) {
2051
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
2052
- let response;
2053
- try {
2054
- response = await fetchWithRetry(manifestUrl, { method: "GET" });
2055
- } catch (error) {
2056
- log.error(`[Playcademy SDK] Error fetching manifest from ${manifestUrl}:`, {
2057
- error
2058
- });
2059
- throw new ManifestError("Failed to load game manifest", "temporary");
2060
- }
2061
- if (!response.ok) {
2062
- log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
2063
- throw new ManifestError(`Failed to fetch manifest: ${response.status} ${response.statusText}`, isRetryableStatus(response.status) ? "temporary" : "permanent");
2064
- }
2065
- try {
2066
- return await response.json();
2067
- } catch (error) {
2068
- log.error(`[Playcademy SDK] Error parsing manifest from ${manifestUrl}:`, {
2069
- error
2070
- });
2071
- throw new ManifestError("Failed to parse game manifest", "permanent");
2072
- }
2073
- }
2074
-
2075
2155
  // src/clients/base.ts
2076
2156
  class PlaycademyBaseClient {
2077
2157
  baseUrl;
@@ -5,6 +5,9 @@ import { z } from 'zod';
5
5
  import { SchemaInfo } from '@playcademy/cloudflare';
6
6
  import { AUTH_PROVIDER_IDS } from '@playcademy/constants';
7
7
 
8
+ /** Permitted HTTP verbs */
9
+ type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
10
+
8
11
  /**
9
12
  * Game Types
10
13
  *
@@ -82,8 +85,181 @@ interface DomainValidationRecords {
82
85
  }[];
83
86
  }
84
87
 
85
- /** Permitted HTTP verbs */
86
- type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
88
+ /**
89
+ * Base error class for Cademy SDK specific errors.
90
+ */
91
+ declare class PlaycademyError extends Error {
92
+ constructor(message: string);
93
+ }
94
+ type ManifestErrorKind = 'temporary' | 'permanent';
95
+ type CorsProbeResult = 'reachable' | 'unreachable' | 'skipped';
96
+ interface ManifestErrorDetails {
97
+ manifestUrl: string;
98
+ manifestHost: string;
99
+ deploymentUrl: string;
100
+ fetchOutcome: 'network_error' | 'bad_status' | 'invalid_body';
101
+ retryCount: number;
102
+ durationMs: number;
103
+ fetchErrorMessage?: string;
104
+ corsProbe?: CorsProbeResult;
105
+ status?: number;
106
+ contentType?: string;
107
+ cfRay?: string;
108
+ redirected?: boolean;
109
+ }
110
+ declare class ManifestError extends PlaycademyError {
111
+ readonly kind: ManifestErrorKind;
112
+ readonly details: ManifestErrorDetails | undefined;
113
+ constructor(message: string, kind: ManifestErrorKind, details?: ManifestErrorDetails);
114
+ }
115
+ /**
116
+ * Error codes returned by the API.
117
+ * These map to specific error types and HTTP status codes.
118
+ */
119
+ type ApiErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'ACCESS_DENIED' | 'NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'CONFLICT' | 'ALREADY_EXISTS' | 'GONE' | 'PRECONDITION_FAILED' | 'PAYLOAD_TOO_LARGE' | 'VALIDATION_FAILED' | 'TOO_MANY_REQUESTS' | 'RATE_LIMITED' | 'EXPIRED' | 'INTERNAL' | 'INTERNAL_ERROR' | 'NOT_IMPLEMENTED' | 'SERVICE_UNAVAILABLE' | 'TIMEOUT' | string;
120
+ /**
121
+ * Structure of error response bodies returned by API endpoints.
122
+ *
123
+ * @example
124
+ * ```json
125
+ * {
126
+ * "error": {
127
+ * "code": "NOT_FOUND",
128
+ * "message": "Item not found",
129
+ * "details": { "identifier": "abc123" }
130
+ * }
131
+ * }
132
+ * ```
133
+ */
134
+ interface ErrorResponseBody {
135
+ error?: {
136
+ code?: string;
137
+ message?: string;
138
+ details?: unknown;
139
+ };
140
+ }
141
+ /**
142
+ * API error thrown when a request fails.
143
+ *
144
+ * Contains structured error information from the API response:
145
+ * - `status` - HTTP status code (e.g., 404)
146
+ * - `code` - API error code (e.g., "NOT_FOUND")
147
+ * - `message` - Human-readable error message
148
+ * - `details` - Optional additional error context
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * try {
153
+ * await client.games.get('nonexistent')
154
+ * } catch (error) {
155
+ * if (error instanceof ApiError) {
156
+ * console.log(error.status) // 404
157
+ * console.log(error.code) // "NOT_FOUND"
158
+ * console.log(error.message) // "Game not found"
159
+ * console.log(error.details) // { identifier: "nonexistent" }
160
+ * }
161
+ * }
162
+ * ```
163
+ */
164
+ declare class ApiError extends Error {
165
+ /**
166
+ * API error code (e.g., "NOT_FOUND", "VALIDATION_FAILED").
167
+ * Use this for programmatic error handling.
168
+ */
169
+ readonly code: ApiErrorCode;
170
+ /**
171
+ * Additional error context from the API.
172
+ * Structure varies by error type (e.g., validation errors include field details).
173
+ */
174
+ readonly details: unknown;
175
+ /**
176
+ * Raw response body for debugging.
177
+ * @internal
178
+ */
179
+ readonly rawBody: unknown;
180
+ readonly status: number;
181
+ constructor(
182
+ /** HTTP status code */
183
+ status: number,
184
+ /** API error code */
185
+ code: ApiErrorCode,
186
+ /** Human-readable error message */
187
+ message: string,
188
+ /** Additional error context */
189
+ details?: unknown,
190
+ /** Raw response body */
191
+ rawBody?: unknown);
192
+ /**
193
+ * Create an ApiError from an HTTP response.
194
+ * Parses the structured error response from the API.
195
+ *
196
+ * @internal
197
+ */
198
+ static fromResponse(status: number, statusText: string, body: unknown): ApiError;
199
+ /**
200
+ * Check if this is a specific error type.
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * if (error.is('NOT_FOUND')) {
205
+ * // Handle not found
206
+ * } else if (error.is('VALIDATION_FAILED')) {
207
+ * // Handle validation error
208
+ * }
209
+ * ```
210
+ */
211
+ is(code: ApiErrorCode): boolean;
212
+ /**
213
+ * Check if this is a client error (4xx).
214
+ */
215
+ isClientError(): boolean;
216
+ /**
217
+ * Check if this is a server error (5xx).
218
+ */
219
+ isServerError(): boolean;
220
+ /**
221
+ * Check if this error is retryable.
222
+ * Server errors and rate limits are typically retryable.
223
+ */
224
+ isRetryable(): boolean;
225
+ }
226
+ /**
227
+ * Extracted error information for display purposes.
228
+ */
229
+ interface ApiErrorInfo {
230
+ /** HTTP status code */
231
+ status: number;
232
+ /** API error code */
233
+ code: ApiErrorCode;
234
+ /** Human-readable error message */
235
+ message: string;
236
+ /** Additional error context */
237
+ details?: unknown;
238
+ }
239
+ /**
240
+ * Extract useful error information from an API error.
241
+ * Useful for displaying errors to users in a friendly way.
242
+ *
243
+ * @example
244
+ * ```typescript
245
+ * try {
246
+ * await client.shop.purchase(itemId)
247
+ * } catch (error) {
248
+ * const info = extractApiErrorInfo(error)
249
+ * if (info) {
250
+ * showToast(`Error: ${info.message}`)
251
+ * }
252
+ * }
253
+ * ```
254
+ */
255
+ declare function extractApiErrorInfo(error: unknown): ApiErrorInfo | null;
256
+
257
+ /**
258
+ * Fire-and-forget reachability check using `no-cors` mode.
259
+ * Exported for use by the app telemetry layer — not awaited inside
260
+ * fetchManifest so it doesn't block the error screen.
261
+ */
262
+ declare function probeCorsReachability(url: string): Promise<CorsProbeResult>;
87
263
 
88
264
  /**
89
265
  * User Types
@@ -935,159 +1111,6 @@ interface SpriteConfigWithDimensions {
935
1111
  animations: Record<string, SpriteAnimationFrame>;
936
1112
  }
937
1113
 
938
- /**
939
- * Base error class for Cademy SDK specific errors.
940
- */
941
- declare class PlaycademyError extends Error {
942
- constructor(message: string);
943
- }
944
- type ManifestErrorKind = 'temporary' | 'permanent';
945
- declare class ManifestError extends PlaycademyError {
946
- readonly kind: ManifestErrorKind;
947
- constructor(message: string, kind: ManifestErrorKind);
948
- }
949
- /**
950
- * Error codes returned by the API.
951
- * These map to specific error types and HTTP status codes.
952
- */
953
- type ApiErrorCode = 'BAD_REQUEST' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'ACCESS_DENIED' | 'NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'CONFLICT' | 'ALREADY_EXISTS' | 'GONE' | 'PRECONDITION_FAILED' | 'PAYLOAD_TOO_LARGE' | 'VALIDATION_FAILED' | 'TOO_MANY_REQUESTS' | 'RATE_LIMITED' | 'EXPIRED' | 'INTERNAL' | 'INTERNAL_ERROR' | 'NOT_IMPLEMENTED' | 'SERVICE_UNAVAILABLE' | 'TIMEOUT' | string;
954
- /**
955
- * Structure of error response bodies returned by API endpoints.
956
- *
957
- * @example
958
- * ```json
959
- * {
960
- * "error": {
961
- * "code": "NOT_FOUND",
962
- * "message": "Item not found",
963
- * "details": { "identifier": "abc123" }
964
- * }
965
- * }
966
- * ```
967
- */
968
- interface ErrorResponseBody {
969
- error?: {
970
- code?: string;
971
- message?: string;
972
- details?: unknown;
973
- };
974
- }
975
- /**
976
- * API error thrown when a request fails.
977
- *
978
- * Contains structured error information from the API response:
979
- * - `status` - HTTP status code (e.g., 404)
980
- * - `code` - API error code (e.g., "NOT_FOUND")
981
- * - `message` - Human-readable error message
982
- * - `details` - Optional additional error context
983
- *
984
- * @example
985
- * ```typescript
986
- * try {
987
- * await client.games.get('nonexistent')
988
- * } catch (error) {
989
- * if (error instanceof ApiError) {
990
- * console.log(error.status) // 404
991
- * console.log(error.code) // "NOT_FOUND"
992
- * console.log(error.message) // "Game not found"
993
- * console.log(error.details) // { identifier: "nonexistent" }
994
- * }
995
- * }
996
- * ```
997
- */
998
- declare class ApiError extends Error {
999
- /**
1000
- * API error code (e.g., "NOT_FOUND", "VALIDATION_FAILED").
1001
- * Use this for programmatic error handling.
1002
- */
1003
- readonly code: ApiErrorCode;
1004
- /**
1005
- * Additional error context from the API.
1006
- * Structure varies by error type (e.g., validation errors include field details).
1007
- */
1008
- readonly details: unknown;
1009
- /**
1010
- * Raw response body for debugging.
1011
- * @internal
1012
- */
1013
- readonly rawBody: unknown;
1014
- readonly status: number;
1015
- constructor(
1016
- /** HTTP status code */
1017
- status: number,
1018
- /** API error code */
1019
- code: ApiErrorCode,
1020
- /** Human-readable error message */
1021
- message: string,
1022
- /** Additional error context */
1023
- details?: unknown,
1024
- /** Raw response body */
1025
- rawBody?: unknown);
1026
- /**
1027
- * Create an ApiError from an HTTP response.
1028
- * Parses the structured error response from the API.
1029
- *
1030
- * @internal
1031
- */
1032
- static fromResponse(status: number, statusText: string, body: unknown): ApiError;
1033
- /**
1034
- * Check if this is a specific error type.
1035
- *
1036
- * @example
1037
- * ```typescript
1038
- * if (error.is('NOT_FOUND')) {
1039
- * // Handle not found
1040
- * } else if (error.is('VALIDATION_FAILED')) {
1041
- * // Handle validation error
1042
- * }
1043
- * ```
1044
- */
1045
- is(code: ApiErrorCode): boolean;
1046
- /**
1047
- * Check if this is a client error (4xx).
1048
- */
1049
- isClientError(): boolean;
1050
- /**
1051
- * Check if this is a server error (5xx).
1052
- */
1053
- isServerError(): boolean;
1054
- /**
1055
- * Check if this error is retryable.
1056
- * Server errors and rate limits are typically retryable.
1057
- */
1058
- isRetryable(): boolean;
1059
- }
1060
- /**
1061
- * Extracted error information for display purposes.
1062
- */
1063
- interface ApiErrorInfo {
1064
- /** HTTP status code */
1065
- status: number;
1066
- /** API error code */
1067
- code: ApiErrorCode;
1068
- /** Human-readable error message */
1069
- message: string;
1070
- /** Additional error context */
1071
- details?: unknown;
1072
- }
1073
- /**
1074
- * Extract useful error information from an API error.
1075
- * Useful for displaying errors to users in a friendly way.
1076
- *
1077
- * @example
1078
- * ```typescript
1079
- * try {
1080
- * await client.shop.purchase(itemId)
1081
- * } catch (error) {
1082
- * const info = extractApiErrorInfo(error)
1083
- * if (info) {
1084
- * showToast(`Error: ${info.message}`)
1085
- * }
1086
- * }
1087
- * ```
1088
- */
1089
- declare function extractApiErrorInfo(error: unknown): ApiErrorInfo | null;
1090
-
1091
1114
  /**
1092
1115
  * Connection monitoring types
1093
1116
  *
@@ -7637,5 +7660,5 @@ declare class PlaycademyInternalClient extends PlaycademyBaseClient {
7637
7660
  };
7638
7661
  }
7639
7662
 
7640
- export { AchievementCompletionType, ApiError, ConnectionManager, ConnectionMonitor, ManifestError, MessageEvents, NotificationStatus, NotificationType, PlaycademyInternalClient as PlaycademyClient, PlaycademyError, PlaycademyInternalClient, extractApiErrorInfo, messaging };
7641
- export type { AchievementCurrent, AchievementHistoryEntry, AchievementProgressResponse, AchievementScopeType, AchievementWithStatus, ApiErrorCode, ApiErrorInfo, AuthCallbackPayload, AuthOptions, AuthProviderType, AuthResult, AuthServerMessage, AuthStateChangePayload, AuthStateUpdate, AuthenticatedUser, BetterAuthApiKey, BetterAuthApiKeyResponse, BetterAuthSignInResponse, BucketFile, CharacterComponentRow as CharacterComponent, CharacterComponentType, CharacterComponentWithSpriteUrl, CharacterComponentsOptions, ClientConfig, ClientEvents, ConnectionMonitorConfig, ConnectionState, ConnectionStatePayload, CourseXp, CreateCharacterData, CreateMapObjectData, CurrencyRow as Currency, DevUploadEvent, DevUploadHooks, DeveloperStatusEnumType, DeveloperStatusResponse, DeveloperStatusValue, DisconnectContext, DisconnectHandler, DisplayAlertPayload, ErrorResponseBody, EventListeners, ExternalGame, FetchedGame, Game, GameContextPayload, GameCustomHostname, GameInitUser, GameLeaderboardEntry, MapRow as GameMap, GamePlatform, GameRow as GameRecord, GameSessionRow as GameSession, GameTimebackIntegration, GameTokenResponse, GameType, GameUser, GetXpOptions, HostedGame, InitPayload, InsertCurrencyInput, InsertItemInput, InsertShopListingInput, InteractionType, InventoryItemRow as InventoryItem, InventoryItemWithItem, InventoryMutationResponse, ItemRow as Item, ItemType, KVKeyEntry, KVKeyMetadata, KVSeedEntry, KVStatsResponse, KeyEventPayload, LeaderboardEntry, LeaderboardOptions, LeaderboardTimeframe, LevelConfigRow as LevelConfig, LevelProgressResponse, LevelUpCheckResult, LoginResponse, ManifestV1, MapData, MapElementRow as MapElement, MapElementMetadata, MapElementWithGame, MapObjectRow as MapObject, MapObjectWithItem, NotificationRow as Notification, NotificationStats, PlaceableItemMetadata, PlatformTimebackUser, PlatformTimebackUserContext, PlaycademyServerClientConfig, PlaycademyServerClientState, PlayerCharacterRow as PlayerCharacter, PlayerCharacterAccessoryRow as PlayerCharacterAccessory, PlayerCurrency, PlayerInventoryItem, PlayerProfile, PlayerSessionPayload, PopulateStudentResponse, RealtimeTokenResponse, ScoreSubmission, ShopCurrency, ShopDisplayItem, ShopListingRow as ShopListing, ShopViewResponse, SpriteAnimationFrame, SpriteConfigWithDimensions, SpriteTemplateRow as SpriteTemplate, SpriteTemplateData, StartSessionResponse, TelemetryPayload, TimebackEnrollment, TimebackInitContext, TimebackOrganization, TimebackUser, TimebackUserContext, TimebackUserXp, TodayXpResponse, TokenRefreshPayload, TokenType, TotalXpResponse, UpdateCharacterData, UpdateCurrencyInput, UpdateItemInput, UpdateShopListingInput, UpsertGameMetadataInput, UserRow as User, UserEnrollment, UserInfo, UserLevelRow as UserLevel, UserLevelWithConfig, UserOrganization, UserRank, UserRankResponse, UserRoleEnumType, UserScore, UserTimebackData, XPAddResult, XpHistoryResponse, XpResponse, XpSummaryResponse };
7663
+ export { AchievementCompletionType, ApiError, ConnectionManager, ConnectionMonitor, ManifestError, MessageEvents, NotificationStatus, NotificationType, PlaycademyInternalClient as PlaycademyClient, PlaycademyError, PlaycademyInternalClient, extractApiErrorInfo, messaging, probeCorsReachability };
7664
+ export type { AchievementCurrent, AchievementHistoryEntry, AchievementProgressResponse, AchievementScopeType, AchievementWithStatus, ApiErrorCode, ApiErrorInfo, AuthCallbackPayload, AuthOptions, AuthProviderType, AuthResult, AuthServerMessage, AuthStateChangePayload, AuthStateUpdate, AuthenticatedUser, BetterAuthApiKey, BetterAuthApiKeyResponse, BetterAuthSignInResponse, BucketFile, CharacterComponentRow as CharacterComponent, CharacterComponentType, CharacterComponentWithSpriteUrl, CharacterComponentsOptions, ClientConfig, ClientEvents, ConnectionMonitorConfig, ConnectionState, ConnectionStatePayload, CorsProbeResult, CourseXp, CreateCharacterData, CreateMapObjectData, CurrencyRow as Currency, DevUploadEvent, DevUploadHooks, DeveloperStatusEnumType, DeveloperStatusResponse, DeveloperStatusValue, DisconnectContext, DisconnectHandler, DisplayAlertPayload, ErrorResponseBody, EventListeners, ExternalGame, FetchedGame, Game, GameContextPayload, GameCustomHostname, GameInitUser, GameLeaderboardEntry, MapRow as GameMap, GamePlatform, GameRow as GameRecord, GameSessionRow as GameSession, GameTimebackIntegration, GameTokenResponse, GameType, GameUser, GetXpOptions, HostedGame, InitPayload, InsertCurrencyInput, InsertItemInput, InsertShopListingInput, InteractionType, InventoryItemRow as InventoryItem, InventoryItemWithItem, InventoryMutationResponse, ItemRow as Item, ItemType, KVKeyEntry, KVKeyMetadata, KVSeedEntry, KVStatsResponse, KeyEventPayload, LeaderboardEntry, LeaderboardOptions, LeaderboardTimeframe, LevelConfigRow as LevelConfig, LevelProgressResponse, LevelUpCheckResult, LoginResponse, ManifestErrorDetails, ManifestV1, MapData, MapElementRow as MapElement, MapElementMetadata, MapElementWithGame, MapObjectRow as MapObject, MapObjectWithItem, NotificationRow as Notification, NotificationStats, PlaceableItemMetadata, PlatformTimebackUser, PlatformTimebackUserContext, PlaycademyServerClientConfig, PlaycademyServerClientState, PlayerCharacterRow as PlayerCharacter, PlayerCharacterAccessoryRow as PlayerCharacterAccessory, PlayerCurrency, PlayerInventoryItem, PlayerProfile, PlayerSessionPayload, PopulateStudentResponse, RealtimeTokenResponse, ScoreSubmission, ShopCurrency, ShopDisplayItem, ShopListingRow as ShopListing, ShopViewResponse, SpriteAnimationFrame, SpriteConfigWithDimensions, SpriteTemplateRow as SpriteTemplate, SpriteTemplateData, StartSessionResponse, TelemetryPayload, TimebackEnrollment, TimebackInitContext, TimebackOrganization, TimebackUser, TimebackUserContext, TimebackUserXp, TodayXpResponse, TokenRefreshPayload, TokenType, TotalXpResponse, UpdateCharacterData, UpdateCurrencyInput, UpdateItemInput, UpdateShopListingInput, UpsertGameMetadataInput, UserRow as User, UserEnrollment, UserInfo, UserLevelRow as UserLevel, UserLevelWithConfig, UserOrganization, UserRank, UserRankResponse, UserRoleEnumType, UserScore, UserTimebackData, XPAddResult, XpHistoryResponse, XpResponse, XpSummaryResponse };
package/dist/internal.js CHANGED
@@ -409,10 +409,12 @@ class PlaycademyError extends Error {
409
409
 
410
410
  class ManifestError extends PlaycademyError {
411
411
  kind;
412
- constructor(message, kind) {
412
+ details;
413
+ constructor(message, kind, details) {
413
414
  super(message);
414
415
  this.name = "ManifestError";
415
416
  this.kind = kind;
417
+ this.details = details;
416
418
  Object.setPrototypeOf(this, ManifestError.prototype);
417
419
  }
418
420
  }
@@ -2036,7 +2038,7 @@ function createDevNamespace(client) {
2036
2038
  }
2037
2039
  };
2038
2040
  }
2039
- // src/core/request.ts
2041
+ // src/core/transport/retry.ts
2040
2042
  var RETRY_DELAYS_MS = [500, 1500];
2041
2043
  function wait(ms) {
2042
2044
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -2046,6 +2048,7 @@ function isRetryableStatus(status) {
2046
2048
  return status === 429 || status >= 500;
2047
2049
  }
2048
2050
  async function fetchWithRetry(url, init2) {
2051
+ const startedAt = Date.now();
2049
2052
  for (let attempt = 0;attempt <= RETRY_DELAYS_MS.length; attempt++) {
2050
2053
  const retryDelayMs = RETRY_DELAYS_MS[attempt];
2051
2054
  const canRetry = init2.method === "GET" && retryDelayMs !== undefined;
@@ -2054,18 +2057,120 @@ async function fetchWithRetry(url, init2) {
2054
2057
  if (canRetry && isRetryableStatus(response.status)) {
2055
2058
  await retryRuntime.wait(retryDelayMs);
2056
2059
  } else {
2057
- return response;
2060
+ return { response, retryCount: attempt, durationMs: Date.now() - startedAt };
2058
2061
  }
2059
2062
  } catch (error) {
2060
2063
  if (canRetry && error instanceof TypeError) {
2061
2064
  await retryRuntime.wait(retryDelayMs);
2062
2065
  } else {
2063
- throw error;
2066
+ return { error, retryCount: attempt, durationMs: Date.now() - startedAt };
2064
2067
  }
2065
2068
  }
2066
2069
  }
2067
- throw new PlaycademyError("Request failed after exhausting retries");
2070
+ return {
2071
+ error: new PlaycademyError("Request failed after exhausting retries"),
2072
+ retryCount: RETRY_DELAYS_MS.length,
2073
+ durationMs: Date.now() - startedAt
2074
+ };
2075
+ }
2076
+ function unwrapFetchResult(result) {
2077
+ if (result.error) {
2078
+ throw result.error;
2079
+ }
2080
+ if (!result.response) {
2081
+ throw new PlaycademyError("Request failed after exhausting retries");
2082
+ }
2083
+ return result.response;
2084
+ }
2085
+ // src/core/transport/manifest.ts
2086
+ var CORS_PROBE_TIMEOUT_MS = 3000;
2087
+ async function probeCorsReachability(url) {
2088
+ if (typeof globalThis.AbortController === "undefined") {
2089
+ return "skipped";
2090
+ }
2091
+ const controller = new AbortController;
2092
+ const timer = setTimeout(() => controller.abort(), CORS_PROBE_TIMEOUT_MS);
2093
+ try {
2094
+ await fetch(url, { mode: "no-cors", signal: controller.signal });
2095
+ return "reachable";
2096
+ } catch {
2097
+ return "unreachable";
2098
+ } finally {
2099
+ clearTimeout(timer);
2100
+ }
2101
+ }
2102
+ var MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
2103
+ function getFetchErrorMessage(error) {
2104
+ let raw;
2105
+ if (error instanceof Error) {
2106
+ raw = error.message;
2107
+ } else if (typeof error === "string") {
2108
+ raw = error;
2109
+ }
2110
+ if (!raw) {
2111
+ return;
2112
+ }
2113
+ const normalized = raw.replace(/\s+/g, " ").trim();
2114
+ if (!normalized) {
2115
+ return;
2116
+ }
2117
+ return normalized.slice(0, MAX_FETCH_ERROR_MESSAGE_LENGTH);
2068
2118
  }
2119
+ async function fetchManifest(deploymentUrl) {
2120
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
2121
+ const result = await fetchWithRetry(manifestUrl, { method: "GET" });
2122
+ let manifestHost;
2123
+ try {
2124
+ manifestHost = new URL(manifestUrl).host;
2125
+ } catch {
2126
+ manifestHost = manifestUrl;
2127
+ }
2128
+ const base = {
2129
+ manifestUrl,
2130
+ manifestHost,
2131
+ deploymentUrl,
2132
+ retryCount: result.retryCount,
2133
+ durationMs: result.durationMs
2134
+ };
2135
+ if (result.error || !result.response) {
2136
+ log.error(`[Playcademy SDK] Error fetching manifest from ${manifestUrl}:`, {
2137
+ error: result.error
2138
+ });
2139
+ throw new ManifestError("Failed to load game manifest", "temporary", {
2140
+ ...base,
2141
+ fetchOutcome: "network_error",
2142
+ ...result.error ? { fetchErrorMessage: getFetchErrorMessage(result.error) } : {}
2143
+ });
2144
+ }
2145
+ const response = result.response;
2146
+ if (!response.ok) {
2147
+ log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
2148
+ throw new ManifestError(`Failed to fetch manifest: ${response.status} ${response.statusText}`, isRetryableStatus(response.status) ? "temporary" : "permanent", {
2149
+ ...base,
2150
+ fetchOutcome: "bad_status",
2151
+ status: response.status,
2152
+ contentType: response.headers.get("content-type") ?? undefined,
2153
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2154
+ redirected: response.redirected
2155
+ });
2156
+ }
2157
+ try {
2158
+ return await response.json();
2159
+ } catch (error) {
2160
+ log.error(`[Playcademy SDK] Error parsing manifest from ${manifestUrl}:`, {
2161
+ error
2162
+ });
2163
+ throw new ManifestError("Failed to parse game manifest", "permanent", {
2164
+ ...base,
2165
+ fetchOutcome: "invalid_body",
2166
+ status: response.status,
2167
+ contentType: response.headers.get("content-type") ?? undefined,
2168
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2169
+ redirected: response.redirected
2170
+ });
2171
+ }
2172
+ }
2173
+ // src/core/transport/request.ts
2069
2174
  function prepareRequestBody(body, headers) {
2070
2175
  if (body instanceof FormData) {
2071
2176
  return body;
@@ -2123,12 +2228,12 @@ async function request({
2123
2228
  const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
2124
2229
  const headers = { ...extraHeaders };
2125
2230
  const payload = prepareRequestBody(body, headers);
2126
- const res = await fetchWithRetry(url, {
2231
+ const res = unwrapFetchResult(await fetchWithRetry(url, {
2127
2232
  method,
2128
2233
  headers,
2129
2234
  body: payload,
2130
2235
  credentials: "omit"
2131
- });
2236
+ }));
2132
2237
  if (raw) {
2133
2238
  return res;
2134
2239
  }
@@ -2158,31 +2263,6 @@ async function request({
2158
2263
  const rawText = await res.text().catch(() => "");
2159
2264
  return rawText && rawText.length > 0 ? rawText : undefined;
2160
2265
  }
2161
- async function fetchManifest(deploymentUrl) {
2162
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
2163
- let response;
2164
- try {
2165
- response = await fetchWithRetry(manifestUrl, { method: "GET" });
2166
- } catch (error) {
2167
- log.error(`[Playcademy SDK] Error fetching manifest from ${manifestUrl}:`, {
2168
- error
2169
- });
2170
- throw new ManifestError("Failed to load game manifest", "temporary");
2171
- }
2172
- if (!response.ok) {
2173
- log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
2174
- throw new ManifestError(`Failed to fetch manifest: ${response.status} ${response.statusText}`, isRetryableStatus(response.status) ? "temporary" : "permanent");
2175
- }
2176
- try {
2177
- return await response.json();
2178
- } catch (error) {
2179
- log.error(`[Playcademy SDK] Error parsing manifest from ${manifestUrl}:`, {
2180
- error
2181
- });
2182
- throw new ManifestError("Failed to parse game manifest", "permanent");
2183
- }
2184
- }
2185
-
2186
2266
  // src/namespaces/platform/games.ts
2187
2267
  function createGamesNamespace(client) {
2188
2268
  const gamesListCache = createTTLCache({
@@ -3204,6 +3284,7 @@ class PlaycademyInternalClient extends PlaycademyBaseClient {
3204
3284
  static identity = identity;
3205
3285
  }
3206
3286
  export {
3287
+ probeCorsReachability,
3207
3288
  messaging,
3208
3289
  extractApiErrorInfo,
3209
3290
  PlaycademyInternalClient,
package/dist/server.js CHANGED
@@ -83,10 +83,12 @@ class PlaycademyError extends Error {
83
83
 
84
84
  class ManifestError extends PlaycademyError {
85
85
  kind;
86
- constructor(message, kind) {
86
+ details;
87
+ constructor(message, kind, details) {
87
88
  super(message);
88
89
  this.name = "ManifestError";
89
90
  this.kind = kind;
91
+ this.details = details;
90
92
  Object.setPrototypeOf(this, ManifestError.prototype);
91
93
  }
92
94
  }
package/dist/types.d.ts CHANGED
@@ -19,6 +19,9 @@ declare function parseOAuthState(state: string): {
19
19
  data?: Record<string, string>;
20
20
  };
21
21
 
22
+ /** Permitted HTTP verbs */
23
+ type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
24
+
22
25
  /**
23
26
  * Game Types
24
27
  *
@@ -49,9 +52,6 @@ interface DomainValidationRecords {
49
52
  }[];
50
53
  }
51
54
 
52
- /** Permitted HTTP verbs */
53
- type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
54
-
55
55
  /**
56
56
  * User Types
57
57
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sdk",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {