@playcademy/sandbox 0.3.17-beta.3 → 0.3.17-beta.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/server.js CHANGED
@@ -439,7 +439,7 @@ var init_timeback2 = __esm(() => {
439
439
  });
440
440
 
441
441
  // ../constants/src/workers.ts
442
- var WORKER_NAMING, SECRETS_PREFIX = "secrets_";
442
+ var WORKER_NAMING, SECRETS_PREFIX = "secrets_", CLOUDFLARE_COMPATIBILITY_DATE = "2025-10-11";
443
443
  var init_workers = __esm(() => {
444
444
  WORKER_NAMING = {
445
445
  STAGING_PREFIX: "staging-",
@@ -1309,7 +1309,7 @@ var package_default;
1309
1309
  var init_package = __esm(() => {
1310
1310
  package_default = {
1311
1311
  name: "@playcademy/sandbox",
1312
- version: "0.3.17-beta.3",
1312
+ version: "0.3.17-beta.5",
1313
1313
  description: "Local development server for Playcademy game development",
1314
1314
  type: "module",
1315
1315
  exports: {
@@ -23743,13 +23743,11 @@ var init_dedent = __esm(() => {
23743
23743
  });
23744
23744
 
23745
23745
  // ../cloudflare/src/core/namespaces/workers.ts
23746
- var DEFAULT_COMPATIBILITY_DATE;
23747
23746
  var init_workers2 = __esm(() => {
23748
23747
  init_dedent();
23749
23748
  init_src2();
23750
23749
  init_assets();
23751
23750
  init_multipart();
23752
- DEFAULT_COMPATIBILITY_DATE = new Date().toISOString().slice(0, 10);
23753
23751
  });
23754
23752
 
23755
23753
  // ../cloudflare/src/core/namespaces/index.ts
@@ -23771,10 +23769,9 @@ var init_core = __esm(() => {
23771
23769
  });
23772
23770
 
23773
23771
  // ../cloudflare/src/playcademy/constants.ts
23774
- var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains", QUEUE_NAME_PREFIX = "playcademy", DEFAULT_COMPATIBILITY_DATE2, GAME_WORKER_DOMAIN_PRODUCTION, GAME_WORKER_DOMAIN_STAGING;
23772
+ var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains", QUEUE_NAME_PREFIX = "playcademy", GAME_WORKER_DOMAIN_PRODUCTION, GAME_WORKER_DOMAIN_STAGING;
23775
23773
  var init_constants2 = __esm(() => {
23776
23774
  init_src();
23777
- DEFAULT_COMPATIBILITY_DATE2 = new Date().toISOString().slice(0, 10);
23778
23775
  GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
23779
23776
  GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
23780
23777
  });
@@ -26478,6 +26475,7 @@ class DeployService {
26478
26475
  try {
26479
26476
  result = await this.timeStep("Cloudflare deploy", () => cf.deploy(deploymentId, request.code, env, {
26480
26477
  ...deploymentOptions,
26478
+ compatibilityDate: request.compatibilityDate ?? CLOUDFLARE_COMPATIBILITY_DATE,
26481
26479
  compatibilityFlags: request.compatibilityFlags,
26482
26480
  existingResources: activeDeployment?.resources ?? undefined,
26483
26481
  assetsPath: frontendAssetsPath,
@@ -26579,6 +26577,7 @@ var logger3;
26579
26577
  var init_deploy_service = __esm(() => {
26580
26578
  init_drizzle_orm();
26581
26579
  init_playcademy();
26580
+ init_src();
26582
26581
  init_tables_index();
26583
26582
  init_src2();
26584
26583
  init_config2();
@@ -26647,447 +26646,533 @@ var init_developer_service = __esm(() => {
26647
26646
  logger4 = log.scope("DeveloperService");
26648
26647
  });
26649
26648
 
26650
- // ../api-core/src/services/game.service.ts
26651
- class GameService {
26652
- deps;
26653
- static MANIFEST_FETCH_TIMEOUT_MS = 5000;
26654
- static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
26655
- constructor(deps) {
26656
- this.deps = deps;
26657
- }
26658
- static getManifestHost(manifestUrl) {
26659
- try {
26660
- return new URL(manifestUrl).host;
26661
- } catch {
26662
- return manifestUrl;
26663
- }
26649
+ // ../utils/src/fns.ts
26650
+ function sleep(ms) {
26651
+ if (ms <= 0) {
26652
+ return Promise.resolve();
26664
26653
  }
26665
- static getFetchErrorMessage(error) {
26666
- let raw;
26667
- if (error instanceof Error) {
26668
- raw = error.message;
26669
- } else if (typeof error === "string") {
26670
- raw = error;
26671
- }
26672
- if (!raw) {
26673
- return;
26654
+ return new Promise((resolve) => setTimeout(resolve, ms));
26655
+ }
26656
+
26657
+ // ../api-core/src/services/game.service.ts
26658
+ var logger5, inFlightManifestFetches, GameService;
26659
+ var init_game_service = __esm(() => {
26660
+ init_drizzle_orm();
26661
+ init_tables_index();
26662
+ init_src2();
26663
+ init_errors();
26664
+ init_deployment_util();
26665
+ logger5 = log.scope("GameService");
26666
+ inFlightManifestFetches = new Map;
26667
+ GameService = class GameService {
26668
+ deps;
26669
+ static MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS = 1e4;
26670
+ static MANIFEST_FETCH_MAX_RETRIES = 2;
26671
+ static MANIFEST_FETCH_RETRY_BACKOFF_MS = [250, 750];
26672
+ static MANIFEST_CACHE_TTL_SECONDS = 60;
26673
+ static MANIFEST_CACHE_KEY_PREFIX = "game:manifest";
26674
+ static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
26675
+ constructor(deps) {
26676
+ this.deps = deps;
26677
+ }
26678
+ static getManifestHost(manifestUrl) {
26679
+ try {
26680
+ return new URL(manifestUrl).host;
26681
+ } catch {
26682
+ return manifestUrl;
26683
+ }
26674
26684
  }
26675
- const normalized = raw.replace(/\s+/g, " ").trim();
26676
- if (!normalized) {
26677
- return;
26685
+ static getFetchErrorMessage(error) {
26686
+ let raw;
26687
+ if (error instanceof Error) {
26688
+ raw = error.message;
26689
+ } else if (typeof error === "string") {
26690
+ raw = error;
26691
+ }
26692
+ if (!raw) {
26693
+ return;
26694
+ }
26695
+ const normalized = raw.replace(/\s+/g, " ").trim();
26696
+ if (!normalized) {
26697
+ return;
26698
+ }
26699
+ return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
26678
26700
  }
26679
- return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
26680
- }
26681
- static isRetryableStatus(status) {
26682
- return status === 429 || status >= 500;
26683
- }
26684
- async list(caller) {
26685
- const db2 = this.deps.db;
26686
- const isAdmin = caller?.role === "admin";
26687
- const isDeveloper = caller?.role === "developer";
26688
- let whereClause;
26689
- if (isAdmin) {
26690
- whereClause = undefined;
26691
- } else if (isDeveloper && caller?.id) {
26692
- whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
26693
- } else {
26694
- whereClause = ne(games.visibility, "internal");
26701
+ static isRetryableStatus(status) {
26702
+ return status === 429 || status >= 500;
26695
26703
  }
26696
- return db2.query.games.findMany({
26697
- where: whereClause,
26698
- orderBy: [desc(games.createdAt)]
26699
- });
26700
- }
26701
- async listManageable(user) {
26702
- this.validateDeveloperStatus(user);
26703
- const db2 = this.deps.db;
26704
- return db2.query.games.findMany({
26705
- where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
26706
- orderBy: [desc(games.createdAt)]
26707
- });
26708
- }
26709
- async getSubjects() {
26710
- const db2 = this.deps.db;
26711
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
26712
- columns: { gameId: true, subject: true },
26713
- orderBy: [asc(gameTimebackIntegrations.createdAt)]
26714
- });
26715
- const subjectMap = {};
26716
- for (const integration of integrations) {
26717
- if (!(integration.gameId in subjectMap)) {
26718
- subjectMap[integration.gameId] = integration.subject;
26704
+ static getRetryBackoffMs(attemptIndex) {
26705
+ const backoff = GameService.MANIFEST_FETCH_RETRY_BACKOFF_MS;
26706
+ if (backoff.length === 0) {
26707
+ return 0;
26719
26708
  }
26709
+ return backoff[Math.min(attemptIndex, backoff.length - 1)] ?? 0;
26720
26710
  }
26721
- return subjectMap;
26722
- }
26723
- async getById(gameId, caller) {
26724
- const db2 = this.deps.db;
26725
- const game = await db2.query.games.findFirst({
26726
- where: eq(games.id, gameId)
26727
- });
26728
- if (!game) {
26729
- throw new NotFoundError("Game", gameId);
26711
+ static normalizeDeploymentUrl(deploymentUrl) {
26712
+ return deploymentUrl.replace(/\/$/, "");
26730
26713
  }
26731
- this.enforceVisibility(game, caller, gameId);
26732
- return game;
26733
- }
26734
- async getBySlug(slug, caller) {
26735
- const db2 = this.deps.db;
26736
- const game = await db2.query.games.findFirst({
26737
- where: eq(games.slug, slug)
26738
- });
26739
- if (!game) {
26740
- throw new NotFoundError("Game", slug);
26714
+ static getManifestCacheKey(deploymentUrl) {
26715
+ return `${GameService.MANIFEST_CACHE_KEY_PREFIX}:${deploymentUrl}`;
26741
26716
  }
26742
- this.enforceVisibility(game, caller, slug);
26743
- return game;
26744
- }
26745
- async getManifest(gameId, caller) {
26746
- const game = await this.getById(gameId, caller);
26747
- if (game.gameType !== "hosted" || !game.deploymentUrl) {
26748
- throw new BadRequestError("Game does not have a deployment manifest");
26749
- }
26750
- const deploymentUrl = game.deploymentUrl;
26751
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
26752
- const manifestHost = GameService.getManifestHost(manifestUrl);
26753
- const startedAt = Date.now();
26754
- const controller = new AbortController;
26755
- const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
26756
- function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
26757
- return {
26758
- manifestUrl,
26759
- manifestHost,
26760
- deploymentUrl,
26761
- fetchOutcome,
26762
- retryCount: 0,
26763
- durationMs: Date.now() - startedAt,
26764
- manifestErrorKind,
26765
- ...extra
26766
- };
26717
+ async list(caller) {
26718
+ const db2 = this.deps.db;
26719
+ const isAdmin = caller?.role === "admin";
26720
+ const isDeveloper = caller?.role === "developer";
26721
+ let whereClause;
26722
+ if (isAdmin) {
26723
+ whereClause = undefined;
26724
+ } else if (isDeveloper && caller?.id) {
26725
+ whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
26726
+ } else {
26727
+ whereClause = ne(games.visibility, "internal");
26728
+ }
26729
+ return db2.query.games.findMany({
26730
+ where: whereClause,
26731
+ orderBy: [desc(games.createdAt)]
26732
+ });
26767
26733
  }
26768
- let response;
26769
- try {
26770
- response = await fetch(manifestUrl, {
26771
- method: "GET",
26772
- headers: {
26773
- Accept: "application/json"
26774
- },
26775
- signal: controller.signal
26734
+ async listManageable(user) {
26735
+ this.validateDeveloperStatus(user);
26736
+ const db2 = this.deps.db;
26737
+ return db2.query.games.findMany({
26738
+ where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
26739
+ orderBy: [desc(games.createdAt)]
26776
26740
  });
26777
- } catch (error) {
26778
- clearTimeout(timeout);
26779
- const fetchErrorMessage = GameService.getFetchErrorMessage(error);
26780
- const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
26781
- logger5.error("Failed to fetch game manifest", {
26782
- gameId,
26783
- manifestUrl,
26784
- error,
26785
- details
26741
+ }
26742
+ async getSubjects() {
26743
+ const db2 = this.deps.db;
26744
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
26745
+ columns: { gameId: true, subject: true },
26746
+ orderBy: [asc(gameTimebackIntegrations.createdAt)]
26786
26747
  });
26787
- if (error instanceof Error && error.name === "AbortError") {
26788
- throw new TimeoutError("Timed out loading game manifest", details);
26748
+ const subjectMap = {};
26749
+ for (const integration of integrations) {
26750
+ if (!(integration.gameId in subjectMap)) {
26751
+ subjectMap[integration.gameId] = integration.subject;
26752
+ }
26789
26753
  }
26790
- throw new ServiceUnavailableError("Failed to load game manifest", details);
26791
- } finally {
26792
- clearTimeout(timeout);
26754
+ return subjectMap;
26793
26755
  }
26794
- if (!response.ok) {
26795
- const resolvedManifestUrl = response.url || manifestUrl;
26796
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26797
- const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
26798
- const details = buildDetails("bad_status", manifestErrorKind, {
26799
- manifestUrl: resolvedManifestUrl,
26800
- manifestHost: resolvedManifestHost,
26801
- status: response.status,
26802
- contentType: response.headers.get("content-type") ?? undefined,
26803
- cfRay: response.headers.get("cf-ray") ?? undefined,
26804
- redirected: response.redirected,
26805
- ...response.redirected ? {
26806
- originalManifestUrl: manifestUrl,
26807
- originalManifestHost: manifestHost
26808
- } : {}
26809
- });
26810
- const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
26811
- logger5.error("Game manifest returned non-ok response", {
26812
- gameId,
26813
- manifestUrl,
26814
- status: response.status,
26815
- details
26756
+ async getById(gameId, caller) {
26757
+ const db2 = this.deps.db;
26758
+ const game = await db2.query.games.findFirst({
26759
+ where: eq(games.id, gameId)
26816
26760
  });
26817
- if (manifestErrorKind === "temporary") {
26818
- throw new ServiceUnavailableError(message, details);
26761
+ if (!game) {
26762
+ throw new NotFoundError("Game", gameId);
26819
26763
  }
26820
- throw new BadRequestError(message, details);
26764
+ this.enforceVisibility(game, caller, gameId);
26765
+ return game;
26821
26766
  }
26822
- try {
26823
- return await response.json();
26824
- } catch (error) {
26825
- const resolvedManifestUrl = response.url || manifestUrl;
26826
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26827
- const details = buildDetails("invalid_body", "permanent", {
26828
- manifestUrl: resolvedManifestUrl,
26829
- manifestHost: resolvedManifestHost,
26830
- status: response.status,
26831
- contentType: response.headers.get("content-type") ?? undefined,
26832
- cfRay: response.headers.get("cf-ray") ?? undefined,
26833
- redirected: response.redirected,
26834
- ...response.redirected ? {
26835
- originalManifestUrl: manifestUrl,
26836
- originalManifestHost: manifestHost
26837
- } : {}
26838
- });
26839
- logger5.error("Failed to parse game manifest", {
26840
- gameId,
26841
- manifestUrl,
26842
- error,
26843
- details
26767
+ async getBySlug(slug, caller) {
26768
+ const db2 = this.deps.db;
26769
+ const game = await db2.query.games.findFirst({
26770
+ where: eq(games.slug, slug)
26844
26771
  });
26845
- throw new BadRequestError("Failed to parse game manifest", details);
26846
- }
26847
- }
26848
- enforceVisibility(game, caller, lookupIdentifier) {
26849
- if (game.visibility !== "internal") {
26850
- return;
26851
- }
26852
- const isAdmin = caller?.role === "admin";
26853
- const isOwner = caller?.id != null && caller.id === game.developerId;
26854
- if (!isAdmin && !isOwner) {
26855
- throw new NotFoundError("Game", lookupIdentifier);
26856
- }
26857
- }
26858
- async upsertBySlug(slug, data, user) {
26859
- const db2 = this.deps.db;
26860
- const existingGame = await db2.query.games.findFirst({
26861
- where: eq(games.slug, slug)
26862
- });
26863
- const isUpdate = Boolean(existingGame);
26864
- const gameId = existingGame?.id ?? crypto.randomUUID();
26865
- if (isUpdate) {
26866
- await this.validateDeveloperAccess(user, gameId);
26867
- } else {
26868
- this.validateDeveloperStatus(user);
26869
- }
26870
- const gameDataForDb = {
26871
- displayName: data.displayName,
26872
- platform: data.platform,
26873
- metadata: data.metadata,
26874
- mapElementId: data.mapElementId,
26875
- gameType: data.gameType,
26876
- ...data.visibility && { visibility: data.visibility },
26877
- externalUrl: data.externalUrl || null,
26878
- updatedAt: new Date
26879
- };
26880
- let gameResponse;
26881
- if (isUpdate) {
26882
- const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
26883
- if (!updatedGame) {
26884
- logger5.error("Game update returned no rows", { gameId, slug });
26885
- throw new InternalError("DB update failed to return result for existing game");
26886
- }
26887
- gameResponse = updatedGame;
26888
- } else {
26889
- const insertData = {
26890
- ...gameDataForDb,
26891
- id: gameId,
26892
- slug,
26893
- developerId: user.id,
26894
- metadata: data.metadata || {},
26895
- version: data.gameType === "external" ? "external" : "",
26896
- deploymentUrl: null,
26897
- createdAt: new Date
26898
- };
26899
- const [createdGame] = await db2.insert(games).values(insertData).returning();
26900
- if (!createdGame) {
26901
- logger5.error("Game insert returned no rows", { slug, developerId: user.id });
26902
- throw new InternalError("DB insert failed to return result for new game");
26772
+ if (!game) {
26773
+ throw new NotFoundError("Game", slug);
26903
26774
  }
26904
- gameResponse = createdGame;
26775
+ this.enforceVisibility(game, caller, slug);
26776
+ return game;
26905
26777
  }
26906
- if (data.mapElementId) {
26907
- try {
26908
- await db2.update(mapElements).set({
26909
- interactionType: "game_entry",
26910
- gameId: gameResponse.id
26911
- }).where(eq(mapElements.id, data.mapElementId));
26912
- } catch (mapError) {
26913
- logger5.warn("Failed to update map element", {
26914
- mapElementId: data.mapElementId,
26915
- error: mapError
26778
+ async getManifest(gameId, caller) {
26779
+ const game = await this.getById(gameId, caller);
26780
+ if (game.gameType !== "hosted" || !game.deploymentUrl) {
26781
+ throw new BadRequestError("Game does not have a deployment manifest");
26782
+ }
26783
+ const deploymentUrl = GameService.normalizeDeploymentUrl(game.deploymentUrl);
26784
+ const cacheKey2 = GameService.getManifestCacheKey(deploymentUrl);
26785
+ const cached = await this.deps.cache.get(cacheKey2);
26786
+ if (cached) {
26787
+ return cached;
26788
+ }
26789
+ const inFlight = inFlightManifestFetches.get(deploymentUrl);
26790
+ if (inFlight) {
26791
+ return inFlight;
26792
+ }
26793
+ const promise = this.fetchManifestFromOrigin({ gameId, deploymentUrl }).then(async (manifest) => {
26794
+ try {
26795
+ await this.deps.cache.set(cacheKey2, manifest, GameService.MANIFEST_CACHE_TTL_SECONDS);
26796
+ } catch (cacheError) {
26797
+ logger5.warn("Failed to cache game manifest", {
26798
+ gameId,
26799
+ deploymentUrl,
26800
+ cacheKey: cacheKey2,
26801
+ error: cacheError
26802
+ });
26803
+ }
26804
+ return manifest;
26805
+ }).finally(() => {
26806
+ inFlightManifestFetches.delete(deploymentUrl);
26807
+ });
26808
+ inFlightManifestFetches.set(deploymentUrl, promise);
26809
+ return promise;
26810
+ }
26811
+ async fetchManifestFromOrigin(args2) {
26812
+ const { gameId, deploymentUrl } = args2;
26813
+ const manifestUrl = `${deploymentUrl}/playcademy.manifest.json`;
26814
+ const manifestHost = GameService.getManifestHost(manifestUrl);
26815
+ const startedAt = Date.now();
26816
+ const maxAttempts = GameService.MANIFEST_FETCH_MAX_RETRIES + 1;
26817
+ for (let attempt = 0;attempt < maxAttempts; attempt++) {
26818
+ const isLastAttempt = attempt === maxAttempts - 1;
26819
+ const outcome = await this.attemptManifestFetch({
26820
+ manifestUrl,
26821
+ manifestHost,
26822
+ deploymentUrl,
26823
+ startedAt,
26824
+ retryCount: attempt
26825
+ });
26826
+ if (outcome.kind === "success") {
26827
+ return outcome.manifest;
26828
+ }
26829
+ if (!outcome.retryable || isLastAttempt) {
26830
+ logger5.error("Failed to fetch game manifest", {
26831
+ gameId,
26832
+ manifestUrl,
26833
+ attempt: attempt + 1,
26834
+ maxAttempts,
26835
+ retryable: outcome.retryable,
26836
+ details: outcome.details,
26837
+ throwable: outcome.throwable,
26838
+ cause: outcome.cause
26839
+ });
26840
+ throw outcome.throwable;
26841
+ }
26842
+ const backoffMs = GameService.getRetryBackoffMs(attempt);
26843
+ logger5.warn("Retrying game manifest fetch after transient failure", {
26844
+ gameId,
26845
+ manifestUrl,
26846
+ attempt: attempt + 1,
26847
+ maxAttempts,
26848
+ backoffMs,
26849
+ details: outcome.details,
26850
+ cause: outcome.cause
26916
26851
  });
26852
+ await sleep(backoffMs);
26917
26853
  }
26854
+ throw new InternalError("Exhausted manifest fetch retries without result");
26918
26855
  }
26919
- logger5.info("Upserted game", {
26920
- gameId: gameResponse.id,
26921
- slug: gameResponse.slug,
26922
- operation: isUpdate ? "update" : "create",
26923
- displayName: gameResponse.displayName
26924
- });
26925
- return gameResponse;
26926
- }
26927
- async delete(gameId, user) {
26928
- await this.validateDeveloperAccess(user, gameId);
26929
- const db2 = this.deps.db;
26930
- const gameToDelete = await db2.query.games.findFirst({
26931
- where: eq(games.id, gameId),
26932
- columns: { id: true, slug: true, displayName: true }
26933
- });
26934
- if (!gameToDelete?.slug) {
26935
- throw new NotFoundError("Game", gameId);
26936
- }
26937
- const activeDeployment = await db2.query.gameDeployments.findFirst({
26938
- where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
26939
- columns: { deploymentId: true, provider: true, resources: true }
26940
- });
26941
- const customHostnames = await db2.select({
26942
- hostname: gameCustomHostnames.hostname,
26943
- cloudflareId: gameCustomHostnames.cloudflareId,
26944
- environment: gameCustomHostnames.environment
26945
- }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
26946
- const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
26947
- if (result.length === 0) {
26948
- throw new NotFoundError("Game", gameId);
26949
- }
26950
- logger5.info("Deleted game", {
26951
- gameId: result[0].id,
26952
- slug: gameToDelete.slug,
26953
- hadActiveDeployment: Boolean(activeDeployment),
26954
- customDomainsCount: customHostnames.length
26955
- });
26956
- this.deps.alerts.notifyGameDeletion({
26957
- slug: gameToDelete.slug,
26958
- displayName: gameToDelete.displayName,
26959
- developer: { id: user.id, email: user.email }
26960
- }).catch((error) => {
26961
- logger5.warn("Failed to send deletion alert", { error });
26962
- });
26963
- if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
26856
+ async attemptManifestFetch(args2) {
26857
+ const { manifestUrl, manifestHost, deploymentUrl, startedAt, retryCount } = args2;
26858
+ function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
26859
+ return {
26860
+ manifestUrl,
26861
+ manifestHost,
26862
+ deploymentUrl,
26863
+ fetchOutcome,
26864
+ retryCount,
26865
+ durationMs: Date.now() - startedAt,
26866
+ manifestErrorKind,
26867
+ ...extra
26868
+ };
26869
+ }
26870
+ const controller = new AbortController;
26871
+ const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS);
26872
+ let response;
26964
26873
  try {
26965
- await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
26966
- deleteBindings: true,
26967
- resources: activeDeployment.resources ?? undefined,
26968
- customDomains: customHostnames.length > 0 ? customHostnames : undefined,
26969
- gameSlug: gameToDelete.slug
26970
- });
26971
- logger5.info("Cleaned up Cloudflare resources", {
26972
- gameId,
26973
- deploymentId: activeDeployment.deploymentId,
26974
- customDomainsDeleted: customHostnames.length
26874
+ response = await fetch(manifestUrl, {
26875
+ method: "GET",
26876
+ headers: {
26877
+ Accept: "application/json"
26878
+ },
26879
+ signal: controller.signal
26975
26880
  });
26976
- } catch (cfError) {
26977
- logger5.warn("Failed to cleanup Cloudflare resources", {
26978
- gameId,
26979
- deploymentId: activeDeployment.deploymentId,
26980
- error: cfError
26881
+ } catch (error) {
26882
+ const fetchErrorMessage = GameService.getFetchErrorMessage(error);
26883
+ const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
26884
+ const throwable = error instanceof Error && error.name === "AbortError" ? new TimeoutError("Timed out loading game manifest", details) : new ServiceUnavailableError("Failed to load game manifest", details);
26885
+ return { kind: "failure", retryable: true, throwable, details, cause: error };
26886
+ } finally {
26887
+ clearTimeout(timeout);
26888
+ }
26889
+ if (!response.ok) {
26890
+ const resolvedManifestUrl = response.url || manifestUrl;
26891
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26892
+ const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
26893
+ const details = buildDetails("bad_status", manifestErrorKind, {
26894
+ manifestUrl: resolvedManifestUrl,
26895
+ manifestHost: resolvedManifestHost,
26896
+ status: response.status,
26897
+ contentType: response.headers.get("content-type") ?? undefined,
26898
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26899
+ redirected: response.redirected,
26900
+ ...response.redirected ? {
26901
+ originalManifestUrl: manifestUrl,
26902
+ originalManifestHost: manifestHost
26903
+ } : {}
26981
26904
  });
26905
+ const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
26906
+ const throwable = manifestErrorKind === "temporary" ? new ServiceUnavailableError(message, details) : new BadRequestError(message, details);
26907
+ return {
26908
+ kind: "failure",
26909
+ retryable: manifestErrorKind === "temporary",
26910
+ throwable,
26911
+ details
26912
+ };
26982
26913
  }
26983
26914
  try {
26984
- const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
26985
- if (deletedKeyId) {
26986
- logger5.info("Cleaned up API key for deleted game", {
26987
- gameId,
26988
- slug: gameToDelete.slug,
26989
- keyId: deletedKeyId
26915
+ const manifest = await response.json();
26916
+ return { kind: "success", manifest };
26917
+ } catch (error) {
26918
+ const resolvedManifestUrl = response.url || manifestUrl;
26919
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26920
+ const details = buildDetails("invalid_body", "permanent", {
26921
+ manifestUrl: resolvedManifestUrl,
26922
+ manifestHost: resolvedManifestHost,
26923
+ status: response.status,
26924
+ contentType: response.headers.get("content-type") ?? undefined,
26925
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26926
+ redirected: response.redirected,
26927
+ ...response.redirected ? {
26928
+ originalManifestUrl: manifestUrl,
26929
+ originalManifestHost: manifestHost
26930
+ } : {}
26931
+ });
26932
+ return {
26933
+ kind: "failure",
26934
+ retryable: false,
26935
+ throwable: new BadRequestError("Failed to parse game manifest", details),
26936
+ details,
26937
+ cause: error
26938
+ };
26939
+ }
26940
+ }
26941
+ enforceVisibility(game, caller, lookupIdentifier) {
26942
+ if (game.visibility !== "internal") {
26943
+ return;
26944
+ }
26945
+ const isAdmin = caller?.role === "admin";
26946
+ const isOwner = caller?.id != null && caller.id === game.developerId;
26947
+ if (!isAdmin && !isOwner) {
26948
+ throw new NotFoundError("Game", lookupIdentifier);
26949
+ }
26950
+ }
26951
+ async upsertBySlug(slug, data, user) {
26952
+ const db2 = this.deps.db;
26953
+ const existingGame = await db2.query.games.findFirst({
26954
+ where: eq(games.slug, slug)
26955
+ });
26956
+ const isUpdate = Boolean(existingGame);
26957
+ const gameId = existingGame?.id ?? crypto.randomUUID();
26958
+ if (isUpdate) {
26959
+ await this.validateDeveloperAccess(user, gameId);
26960
+ } else {
26961
+ this.validateDeveloperStatus(user);
26962
+ }
26963
+ const gameDataForDb = {
26964
+ displayName: data.displayName,
26965
+ platform: data.platform,
26966
+ metadata: data.metadata,
26967
+ mapElementId: data.mapElementId,
26968
+ gameType: data.gameType,
26969
+ ...data.visibility && { visibility: data.visibility },
26970
+ externalUrl: data.externalUrl || null,
26971
+ updatedAt: new Date
26972
+ };
26973
+ let gameResponse;
26974
+ if (isUpdate) {
26975
+ const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
26976
+ if (!updatedGame) {
26977
+ logger5.error("Game update returned no rows", { gameId, slug });
26978
+ throw new InternalError("DB update failed to return result for existing game");
26979
+ }
26980
+ gameResponse = updatedGame;
26981
+ } else {
26982
+ const insertData = {
26983
+ ...gameDataForDb,
26984
+ id: gameId,
26985
+ slug,
26986
+ developerId: user.id,
26987
+ metadata: data.metadata || {},
26988
+ version: data.gameType === "external" ? "external" : "",
26989
+ deploymentUrl: null,
26990
+ createdAt: new Date
26991
+ };
26992
+ const [createdGame] = await db2.insert(games).values(insertData).returning();
26993
+ if (!createdGame) {
26994
+ logger5.error("Game insert returned no rows", { slug, developerId: user.id });
26995
+ throw new InternalError("DB insert failed to return result for new game");
26996
+ }
26997
+ gameResponse = createdGame;
26998
+ }
26999
+ if (data.mapElementId) {
27000
+ try {
27001
+ await db2.update(mapElements).set({
27002
+ interactionType: "game_entry",
27003
+ gameId: gameResponse.id
27004
+ }).where(eq(mapElements.id, data.mapElementId));
27005
+ } catch (mapError) {
27006
+ logger5.warn("Failed to update map element", {
27007
+ mapElementId: data.mapElementId,
27008
+ error: mapError
26990
27009
  });
26991
27010
  }
26992
- } catch (keyError) {
26993
- logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
26994
27011
  }
27012
+ logger5.info("Upserted game", {
27013
+ gameId: gameResponse.id,
27014
+ slug: gameResponse.slug,
27015
+ operation: isUpdate ? "update" : "create",
27016
+ displayName: gameResponse.displayName
27017
+ });
27018
+ return gameResponse;
26995
27019
  }
26996
- return {
26997
- slug: gameToDelete.slug,
26998
- displayName: gameToDelete.displayName
26999
- };
27000
- }
27001
- async validateOwnership(user, gameId) {
27002
- if (user.role === "admin") {
27003
- const gameExists = await this.deps.db.query.games.findFirst({
27020
+ async delete(gameId, user) {
27021
+ await this.validateDeveloperAccess(user, gameId);
27022
+ const db2 = this.deps.db;
27023
+ const gameToDelete = await db2.query.games.findFirst({
27004
27024
  where: eq(games.id, gameId),
27005
- columns: { id: true }
27025
+ columns: { id: true, slug: true, displayName: true }
27006
27026
  });
27007
- if (!gameExists) {
27027
+ if (!gameToDelete?.slug) {
27008
27028
  throw new NotFoundError("Game", gameId);
27009
27029
  }
27010
- return;
27030
+ const activeDeployment = await db2.query.gameDeployments.findFirst({
27031
+ where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
27032
+ columns: { deploymentId: true, provider: true, resources: true }
27033
+ });
27034
+ const customHostnames = await db2.select({
27035
+ hostname: gameCustomHostnames.hostname,
27036
+ cloudflareId: gameCustomHostnames.cloudflareId,
27037
+ environment: gameCustomHostnames.environment
27038
+ }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
27039
+ const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
27040
+ if (result.length === 0) {
27041
+ throw new NotFoundError("Game", gameId);
27042
+ }
27043
+ logger5.info("Deleted game", {
27044
+ gameId: result[0].id,
27045
+ slug: gameToDelete.slug,
27046
+ hadActiveDeployment: Boolean(activeDeployment),
27047
+ customDomainsCount: customHostnames.length
27048
+ });
27049
+ this.deps.alerts.notifyGameDeletion({
27050
+ slug: gameToDelete.slug,
27051
+ displayName: gameToDelete.displayName,
27052
+ developer: { id: user.id, email: user.email }
27053
+ }).catch((error) => {
27054
+ logger5.warn("Failed to send deletion alert", { error });
27055
+ });
27056
+ if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
27057
+ try {
27058
+ await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
27059
+ deleteBindings: true,
27060
+ resources: activeDeployment.resources ?? undefined,
27061
+ customDomains: customHostnames.length > 0 ? customHostnames : undefined,
27062
+ gameSlug: gameToDelete.slug
27063
+ });
27064
+ logger5.info("Cleaned up Cloudflare resources", {
27065
+ gameId,
27066
+ deploymentId: activeDeployment.deploymentId,
27067
+ customDomainsDeleted: customHostnames.length
27068
+ });
27069
+ } catch (cfError) {
27070
+ logger5.warn("Failed to cleanup Cloudflare resources", {
27071
+ gameId,
27072
+ deploymentId: activeDeployment.deploymentId,
27073
+ error: cfError
27074
+ });
27075
+ }
27076
+ try {
27077
+ const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
27078
+ if (deletedKeyId) {
27079
+ logger5.info("Cleaned up API key for deleted game", {
27080
+ gameId,
27081
+ slug: gameToDelete.slug,
27082
+ keyId: deletedKeyId
27083
+ });
27084
+ }
27085
+ } catch (keyError) {
27086
+ logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
27087
+ }
27088
+ }
27089
+ return {
27090
+ slug: gameToDelete.slug,
27091
+ displayName: gameToDelete.displayName
27092
+ };
27011
27093
  }
27012
- const db2 = this.deps.db;
27013
- const gameOwnership = await db2.query.games.findFirst({
27014
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27015
- columns: { id: true }
27016
- });
27017
- if (!gameOwnership) {
27018
- const gameExists = await db2.query.games.findFirst({
27019
- where: eq(games.id, gameId),
27094
+ async validateOwnership(user, gameId) {
27095
+ if (user.role === "admin") {
27096
+ const gameExists = await this.deps.db.query.games.findFirst({
27097
+ where: eq(games.id, gameId),
27098
+ columns: { id: true }
27099
+ });
27100
+ if (!gameExists) {
27101
+ throw new NotFoundError("Game", gameId);
27102
+ }
27103
+ return;
27104
+ }
27105
+ const db2 = this.deps.db;
27106
+ const gameOwnership = await db2.query.games.findFirst({
27107
+ where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27020
27108
  columns: { id: true }
27021
27109
  });
27022
- if (!gameExists) {
27023
- throw new NotFoundError("Game", gameId);
27110
+ if (!gameOwnership) {
27111
+ const gameExists = await db2.query.games.findFirst({
27112
+ where: eq(games.id, gameId),
27113
+ columns: { id: true }
27114
+ });
27115
+ if (!gameExists) {
27116
+ throw new NotFoundError("Game", gameId);
27117
+ }
27118
+ throw new AccessDeniedError("You do not own this game");
27024
27119
  }
27025
- throw new AccessDeniedError("You do not own this game");
27026
27120
  }
27027
- }
27028
- async validateDeveloperAccess(user, gameId) {
27029
- this.validateDeveloperStatus(user);
27030
- if (user.role === "admin") {
27031
- const gameExists = await this.deps.db.query.games.findFirst({
27032
- where: eq(games.id, gameId),
27121
+ async validateDeveloperAccess(user, gameId) {
27122
+ this.validateDeveloperStatus(user);
27123
+ if (user.role === "admin") {
27124
+ const gameExists = await this.deps.db.query.games.findFirst({
27125
+ where: eq(games.id, gameId),
27126
+ columns: { id: true }
27127
+ });
27128
+ if (!gameExists) {
27129
+ throw new NotFoundError("Game", gameId);
27130
+ }
27131
+ return;
27132
+ }
27133
+ const db2 = this.deps.db;
27134
+ const existingGame = await db2.query.games.findFirst({
27135
+ where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27033
27136
  columns: { id: true }
27034
27137
  });
27035
- if (!gameExists) {
27138
+ if (!existingGame) {
27036
27139
  throw new NotFoundError("Game", gameId);
27037
27140
  }
27038
- return;
27039
- }
27040
- const db2 = this.deps.db;
27041
- const existingGame = await db2.query.games.findFirst({
27042
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
27043
- columns: { id: true }
27044
- });
27045
- if (!existingGame) {
27046
- throw new NotFoundError("Game", gameId);
27047
27141
  }
27048
- }
27049
- async validateDeveloperAccessBySlug(user, slug) {
27050
- this.validateDeveloperStatus(user);
27051
- const db2 = this.deps.db;
27052
- if (user.role === "admin") {
27053
- const game2 = await db2.query.games.findFirst({
27054
- where: eq(games.slug, slug)
27142
+ async validateDeveloperAccessBySlug(user, slug) {
27143
+ this.validateDeveloperStatus(user);
27144
+ const db2 = this.deps.db;
27145
+ if (user.role === "admin") {
27146
+ const game2 = await db2.query.games.findFirst({
27147
+ where: eq(games.slug, slug)
27148
+ });
27149
+ if (!game2) {
27150
+ throw new NotFoundError("Game", slug);
27151
+ }
27152
+ return game2;
27153
+ }
27154
+ const game = await db2.query.games.findFirst({
27155
+ where: and(eq(games.slug, slug), eq(games.developerId, user.id))
27055
27156
  });
27056
- if (!game2) {
27157
+ if (!game) {
27057
27158
  throw new NotFoundError("Game", slug);
27058
27159
  }
27059
- return game2;
27060
- }
27061
- const game = await db2.query.games.findFirst({
27062
- where: and(eq(games.slug, slug), eq(games.developerId, user.id))
27063
- });
27064
- if (!game) {
27065
- throw new NotFoundError("Game", slug);
27066
- }
27067
- return game;
27068
- }
27069
- validateDeveloperStatus(user) {
27070
- if (user.role === "admin") {
27071
- return;
27160
+ return game;
27072
27161
  }
27073
- if (user.developerStatus !== "approved") {
27074
- const status = user.developerStatus || "none";
27075
- if (status === "pending") {
27076
- throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
27077
- } else {
27078
- throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
27162
+ validateDeveloperStatus(user) {
27163
+ if (user.role === "admin") {
27164
+ return;
27165
+ }
27166
+ if (user.developerStatus !== "approved") {
27167
+ const status = user.developerStatus || "none";
27168
+ if (status === "pending") {
27169
+ throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
27170
+ } else {
27171
+ throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
27172
+ }
27079
27173
  }
27080
27174
  }
27081
- }
27082
- }
27083
- var logger5;
27084
- var init_game_service = __esm(() => {
27085
- init_drizzle_orm();
27086
- init_tables_index();
27087
- init_src2();
27088
- init_errors();
27089
- init_deployment_util();
27090
- logger5 = log.scope("GameService");
27175
+ };
27091
27176
  });
27092
27177
 
27093
27178
  // ../api-core/src/services/factory/game.ts
@@ -27096,6 +27181,7 @@ function createGameServices(deps) {
27096
27181
  const game = new GameService({
27097
27182
  db: db2,
27098
27183
  alerts,
27184
+ cache,
27099
27185
  cloudflare,
27100
27186
  deleteApiKeyByName: auth2.deleteApiKeyByName.bind(auth2)
27101
27187
  });
@@ -28913,7 +28999,8 @@ class SeedService {
28913
28999
  PLAYCADEMY_BASE_URL: ""
28914
29000
  }, {
28915
29001
  bindings: { d1: [deploymentId], r2: [], kv: [] },
28916
- keepAssets: false
29002
+ keepAssets: false,
29003
+ compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE
28917
29004
  });
28918
29005
  logger14.info("Worker deployed", { seedDeploymentId, url: result.url });
28919
29006
  if (secrets && Object.keys(secrets).length > 0) {
@@ -29043,6 +29130,7 @@ class SeedService {
29043
29130
  }
29044
29131
  var logger14;
29045
29132
  var init_seed_service = __esm(() => {
29133
+ init_src();
29046
29134
  init_setup2();
29047
29135
  init_src2();
29048
29136
  init_config2();
@@ -93151,6 +93239,7 @@ var init_schemas2 = __esm(() => {
93151
93239
  code: exports_external.string().optional(),
93152
93240
  codeUploadToken: exports_external.string().optional(),
93153
93241
  config: exports_external.unknown().optional(),
93242
+ compatibilityDate: exports_external.string().optional(),
93154
93243
  compatibilityFlags: exports_external.array(exports_external.string()).optional(),
93155
93244
  bindings: exports_external.object({
93156
93245
  database: exports_external.array(exports_external.string()).optional(),