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