@playcademy/sdk 0.3.3 → 0.3.5

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,124 @@ 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, {
1984
+ mode: "no-cors",
1985
+ cache: "no-store",
1986
+ signal: controller.signal
1987
+ });
1988
+ return "reachable";
1989
+ } catch {
1990
+ return "unreachable";
1991
+ } finally {
1992
+ clearTimeout(timer);
1993
+ }
1994
+ }
1995
+ var MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
1996
+ function getFetchErrorMessage(error) {
1997
+ let raw;
1998
+ if (error instanceof Error) {
1999
+ raw = error.message;
2000
+ } else if (typeof error === "string") {
2001
+ raw = error;
2002
+ }
2003
+ if (!raw) {
2004
+ return;
2005
+ }
2006
+ const normalized = raw.replace(/\s+/g, " ").trim();
2007
+ if (!normalized) {
2008
+ return;
2009
+ }
2010
+ return normalized.slice(0, MAX_FETCH_ERROR_MESSAGE_LENGTH);
2011
+ }
2012
+ async function fetchManifest(deploymentUrl) {
2013
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
2014
+ const result = await fetchWithRetry(manifestUrl, { method: "GET" });
2015
+ let manifestHost;
2016
+ try {
2017
+ manifestHost = new URL(manifestUrl).host;
2018
+ } catch {
2019
+ manifestHost = manifestUrl;
2020
+ }
2021
+ const base = {
2022
+ manifestUrl,
2023
+ manifestHost,
2024
+ deploymentUrl,
2025
+ retryCount: result.retryCount,
2026
+ durationMs: result.durationMs
2027
+ };
2028
+ if (result.error || !result.response) {
2029
+ log.error(`[Playcademy SDK] Error fetching manifest from ${manifestUrl}:`, {
2030
+ error: result.error
2031
+ });
2032
+ throw new ManifestError("Failed to load game manifest", "temporary", {
2033
+ ...base,
2034
+ fetchOutcome: "network_error",
2035
+ ...result.error ? { fetchErrorMessage: getFetchErrorMessage(result.error) } : {}
2036
+ });
2037
+ }
2038
+ const response = result.response;
2039
+ if (!response.ok) {
2040
+ log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
2041
+ throw new ManifestError(`Failed to fetch manifest: ${response.status} ${response.statusText}`, isRetryableStatus(response.status) ? "temporary" : "permanent", {
2042
+ ...base,
2043
+ fetchOutcome: "bad_status",
2044
+ status: response.status,
2045
+ contentType: response.headers.get("content-type") ?? undefined,
2046
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2047
+ redirected: response.redirected
2048
+ });
2049
+ }
2050
+ try {
2051
+ return await response.json();
2052
+ } catch (error) {
2053
+ log.error(`[Playcademy SDK] Error parsing manifest from ${manifestUrl}:`, {
2054
+ error
2055
+ });
2056
+ throw new ManifestError("Failed to parse game manifest", "permanent", {
2057
+ ...base,
2058
+ fetchOutcome: "invalid_body",
2059
+ status: response.status,
2060
+ contentType: response.headers.get("content-type") ?? undefined,
2061
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2062
+ redirected: response.redirected
2063
+ });
2064
+ }
1957
2065
  }
2066
+ // src/core/transport/request.ts
1958
2067
  function prepareRequestBody(body, headers) {
1959
2068
  if (body instanceof FormData) {
1960
2069
  return body;
@@ -2012,12 +2121,12 @@ async function request({
2012
2121
  const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
2013
2122
  const headers = { ...extraHeaders };
2014
2123
  const payload = prepareRequestBody(body, headers);
2015
- const res = await fetchWithRetry(url, {
2124
+ const res = unwrapFetchResult(await fetchWithRetry(url, {
2016
2125
  method,
2017
2126
  headers,
2018
2127
  body: payload,
2019
2128
  credentials: "omit"
2020
- });
2129
+ }));
2021
2130
  if (raw) {
2022
2131
  return res;
2023
2132
  }
@@ -2047,31 +2156,6 @@ async function request({
2047
2156
  const rawText = await res.text().catch(() => "");
2048
2157
  return rawText && rawText.length > 0 ? rawText : undefined;
2049
2158
  }
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
2159
  // src/clients/base.ts
2076
2160
  class PlaycademyBaseClient {
2077
2161
  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,124 @@ 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, {
2095
+ mode: "no-cors",
2096
+ cache: "no-store",
2097
+ signal: controller.signal
2098
+ });
2099
+ return "reachable";
2100
+ } catch {
2101
+ return "unreachable";
2102
+ } finally {
2103
+ clearTimeout(timer);
2104
+ }
2105
+ }
2106
+ var MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
2107
+ function getFetchErrorMessage(error) {
2108
+ let raw;
2109
+ if (error instanceof Error) {
2110
+ raw = error.message;
2111
+ } else if (typeof error === "string") {
2112
+ raw = error;
2113
+ }
2114
+ if (!raw) {
2115
+ return;
2116
+ }
2117
+ const normalized = raw.replace(/\s+/g, " ").trim();
2118
+ if (!normalized) {
2119
+ return;
2120
+ }
2121
+ return normalized.slice(0, MAX_FETCH_ERROR_MESSAGE_LENGTH);
2122
+ }
2123
+ async function fetchManifest(deploymentUrl) {
2124
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
2125
+ const result = await fetchWithRetry(manifestUrl, { method: "GET" });
2126
+ let manifestHost;
2127
+ try {
2128
+ manifestHost = new URL(manifestUrl).host;
2129
+ } catch {
2130
+ manifestHost = manifestUrl;
2131
+ }
2132
+ const base = {
2133
+ manifestUrl,
2134
+ manifestHost,
2135
+ deploymentUrl,
2136
+ retryCount: result.retryCount,
2137
+ durationMs: result.durationMs
2138
+ };
2139
+ if (result.error || !result.response) {
2140
+ log.error(`[Playcademy SDK] Error fetching manifest from ${manifestUrl}:`, {
2141
+ error: result.error
2142
+ });
2143
+ throw new ManifestError("Failed to load game manifest", "temporary", {
2144
+ ...base,
2145
+ fetchOutcome: "network_error",
2146
+ ...result.error ? { fetchErrorMessage: getFetchErrorMessage(result.error) } : {}
2147
+ });
2148
+ }
2149
+ const response = result.response;
2150
+ if (!response.ok) {
2151
+ log.error(`[fetchManifest] Failed to fetch manifest from ${manifestUrl}. Status: ${response.status}`);
2152
+ throw new ManifestError(`Failed to fetch manifest: ${response.status} ${response.statusText}`, isRetryableStatus(response.status) ? "temporary" : "permanent", {
2153
+ ...base,
2154
+ fetchOutcome: "bad_status",
2155
+ status: response.status,
2156
+ contentType: response.headers.get("content-type") ?? undefined,
2157
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2158
+ redirected: response.redirected
2159
+ });
2160
+ }
2161
+ try {
2162
+ return await response.json();
2163
+ } catch (error) {
2164
+ log.error(`[Playcademy SDK] Error parsing manifest from ${manifestUrl}:`, {
2165
+ error
2166
+ });
2167
+ throw new ManifestError("Failed to parse game manifest", "permanent", {
2168
+ ...base,
2169
+ fetchOutcome: "invalid_body",
2170
+ status: response.status,
2171
+ contentType: response.headers.get("content-type") ?? undefined,
2172
+ cfRay: response.headers.get("cf-ray") ?? undefined,
2173
+ redirected: response.redirected
2174
+ });
2175
+ }
2068
2176
  }
2177
+ // src/core/transport/request.ts
2069
2178
  function prepareRequestBody(body, headers) {
2070
2179
  if (body instanceof FormData) {
2071
2180
  return body;
@@ -2123,12 +2232,12 @@ async function request({
2123
2232
  const url = baseUrl.replace(/\/$/, "") + (path.startsWith("/") ? path : `/${path}`);
2124
2233
  const headers = { ...extraHeaders };
2125
2234
  const payload = prepareRequestBody(body, headers);
2126
- const res = await fetchWithRetry(url, {
2235
+ const res = unwrapFetchResult(await fetchWithRetry(url, {
2127
2236
  method,
2128
2237
  headers,
2129
2238
  body: payload,
2130
2239
  credentials: "omit"
2131
- });
2240
+ }));
2132
2241
  if (raw) {
2133
2242
  return res;
2134
2243
  }
@@ -2158,31 +2267,6 @@ async function request({
2158
2267
  const rawText = await res.text().catch(() => "");
2159
2268
  return rawText && rawText.length > 0 ? rawText : undefined;
2160
2269
  }
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
2270
  // src/namespaces/platform/games.ts
2187
2271
  function createGamesNamespace(client) {
2188
2272
  const gamesListCache = createTTLCache({
@@ -3204,6 +3288,7 @@ class PlaycademyInternalClient extends PlaycademyBaseClient {
3204
3288
  static identity = identity;
3205
3289
  }
3206
3290
  export {
3291
+ probeCorsReachability,
3207
3292
  messaging,
3208
3293
  extractApiErrorInfo,
3209
3294
  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.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {