@playcademy/vite-plugin 0.2.23 → 0.2.24-beta.2

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.
Files changed (2) hide show
  1. package/dist/index.js +485 -397
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24447,6 +24447,7 @@ var init_timeback2 = __esm(() => {
24447
24447
  });
24448
24448
  var WORKER_NAMING;
24449
24449
  var SECRETS_PREFIX = "secrets_";
24450
+ var CLOUDFLARE_COMPATIBILITY_DATE = "2025-10-11";
24450
24451
  var init_workers = __esm(() => {
24451
24452
  WORKER_NAMING = {
24452
24453
  STAGING_PREFIX: "staging-",
@@ -25334,7 +25335,7 @@ var package_default;
25334
25335
  var init_package = __esm(() => {
25335
25336
  package_default = {
25336
25337
  name: "@playcademy/sandbox",
25337
- version: "0.3.16",
25338
+ version: "0.3.17-beta.5",
25338
25339
  description: "Local development server for Playcademy game development",
25339
25340
  type: "module",
25340
25341
  exports: {
@@ -47627,13 +47628,11 @@ var dedent;
47627
47628
  var init_dedent = __esm(() => {
47628
47629
  dedent = createDedent({});
47629
47630
  });
47630
- var DEFAULT_COMPATIBILITY_DATE;
47631
47631
  var init_workers2 = __esm(() => {
47632
47632
  init_dedent();
47633
47633
  init_src2();
47634
47634
  init_assets();
47635
47635
  init_multipart();
47636
- DEFAULT_COMPATIBILITY_DATE = new Date().toISOString().slice(0, 10);
47637
47636
  });
47638
47637
  var init_namespaces = __esm(() => {
47639
47638
  init_d1();
@@ -47650,12 +47649,10 @@ var init_core = __esm(() => {
47650
47649
  });
47651
47650
  var CUSTOM_DOMAINS_KV_NAME = "cademy-custom-domains";
47652
47651
  var QUEUE_NAME_PREFIX = "playcademy";
47653
- var DEFAULT_COMPATIBILITY_DATE2;
47654
47652
  var GAME_WORKER_DOMAIN_PRODUCTION;
47655
47653
  var GAME_WORKER_DOMAIN_STAGING;
47656
47654
  var init_constants2 = __esm(() => {
47657
47655
  init_src();
47658
- DEFAULT_COMPATIBILITY_DATE2 = new Date().toISOString().slice(0, 10);
47659
47656
  GAME_WORKER_DOMAIN_PRODUCTION = GAME_WORKER_DOMAINS.production;
47660
47657
  GAME_WORKER_DOMAIN_STAGING = GAME_WORKER_DOMAINS.staging;
47661
47658
  });
@@ -50332,6 +50329,7 @@ class DeployService {
50332
50329
  try {
50333
50330
  result = await this.timeStep("Cloudflare deploy", () => cf.deploy(deploymentId, request.code, env, {
50334
50331
  ...deploymentOptions,
50332
+ compatibilityDate: request.compatibilityDate ?? CLOUDFLARE_COMPATIBILITY_DATE,
50335
50333
  compatibilityFlags: request.compatibilityFlags,
50336
50334
  existingResources: activeDeployment?.resources ?? undefined,
50337
50335
  assetsPath: frontendAssetsPath,
@@ -50433,6 +50431,7 @@ var logger3;
50433
50431
  var init_deploy_service = __esm(() => {
50434
50432
  init_drizzle_orm();
50435
50433
  init_playcademy();
50434
+ init_src();
50436
50435
  init_tables_index();
50437
50436
  init_src2();
50438
50437
  init_config2();
@@ -50499,453 +50498,539 @@ var init_developer_service = __esm(() => {
50499
50498
  init_errors();
50500
50499
  logger4 = log.scope("DeveloperService");
50501
50500
  });
50502
-
50503
- class GameService {
50504
- deps;
50505
- static MANIFEST_FETCH_TIMEOUT_MS = 5000;
50506
- static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
50507
- constructor(deps) {
50508
- this.deps = deps;
50509
- }
50510
- static getManifestHost(manifestUrl) {
50511
- try {
50512
- return new URL(manifestUrl).host;
50513
- } catch {
50514
- return manifestUrl;
50515
- }
50501
+ function sleep(ms) {
50502
+ if (ms <= 0) {
50503
+ return Promise.resolve();
50516
50504
  }
50517
- static getFetchErrorMessage(error) {
50518
- let raw;
50519
- if (error instanceof Error) {
50520
- raw = error.message;
50521
- } else if (typeof error === "string") {
50522
- raw = error;
50523
- }
50524
- if (!raw) {
50525
- return;
50505
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
50506
+ }
50507
+ var logger5;
50508
+ var inFlightManifestFetches;
50509
+ var GameService;
50510
+ var init_game_service = __esm(() => {
50511
+ init_drizzle_orm();
50512
+ init_tables_index();
50513
+ init_src2();
50514
+ init_errors();
50515
+ init_deployment_util();
50516
+ logger5 = log.scope("GameService");
50517
+ inFlightManifestFetches = new Map;
50518
+ GameService = class GameService2 {
50519
+ deps;
50520
+ static MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS = 1e4;
50521
+ static MANIFEST_FETCH_MAX_RETRIES = 2;
50522
+ static MANIFEST_FETCH_RETRY_BACKOFF_MS = [250, 750];
50523
+ static MANIFEST_CACHE_TTL_SECONDS = 60;
50524
+ static MANIFEST_CACHE_KEY_PREFIX = "game:manifest";
50525
+ static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
50526
+ constructor(deps) {
50527
+ this.deps = deps;
50528
+ }
50529
+ static getManifestHost(manifestUrl) {
50530
+ try {
50531
+ return new URL(manifestUrl).host;
50532
+ } catch {
50533
+ return manifestUrl;
50534
+ }
50526
50535
  }
50527
- const normalized = raw.replace(/\s+/g, " ").trim();
50528
- if (!normalized) {
50529
- return;
50536
+ static getFetchErrorMessage(error) {
50537
+ let raw;
50538
+ if (error instanceof Error) {
50539
+ raw = error.message;
50540
+ } else if (typeof error === "string") {
50541
+ raw = error;
50542
+ }
50543
+ if (!raw) {
50544
+ return;
50545
+ }
50546
+ const normalized = raw.replace(/\s+/g, " ").trim();
50547
+ if (!normalized) {
50548
+ return;
50549
+ }
50550
+ return normalized.slice(0, GameService2.MAX_FETCH_ERROR_MESSAGE_LENGTH);
50530
50551
  }
50531
- return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
50532
- }
50533
- static isRetryableStatus(status) {
50534
- return status === 429 || status >= 500;
50535
- }
50536
- async list(caller) {
50537
- const db2 = this.deps.db;
50538
- const isAdmin = caller?.role === "admin";
50539
- const isDeveloper = caller?.role === "developer";
50540
- let whereClause;
50541
- if (isAdmin) {
50542
- whereClause = undefined;
50543
- } else if (isDeveloper && caller?.id) {
50544
- whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
50545
- } else {
50546
- whereClause = ne(games.visibility, "internal");
50552
+ static isRetryableStatus(status) {
50553
+ return status === 429 || status >= 500;
50547
50554
  }
50548
- return db2.query.games.findMany({
50549
- where: whereClause,
50550
- orderBy: [desc(games.createdAt)]
50551
- });
50552
- }
50553
- async listManageable(user) {
50554
- this.validateDeveloperStatus(user);
50555
- const db2 = this.deps.db;
50556
- return db2.query.games.findMany({
50557
- where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
50558
- orderBy: [desc(games.createdAt)]
50559
- });
50560
- }
50561
- async getSubjects() {
50562
- const db2 = this.deps.db;
50563
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
50564
- columns: { gameId: true, subject: true },
50565
- orderBy: [asc(gameTimebackIntegrations.createdAt)]
50566
- });
50567
- const subjectMap = {};
50568
- for (const integration of integrations) {
50569
- if (!(integration.gameId in subjectMap)) {
50570
- subjectMap[integration.gameId] = integration.subject;
50555
+ static getRetryBackoffMs(attemptIndex) {
50556
+ const backoff = GameService2.MANIFEST_FETCH_RETRY_BACKOFF_MS;
50557
+ if (backoff.length === 0) {
50558
+ return 0;
50571
50559
  }
50560
+ return backoff[Math.min(attemptIndex, backoff.length - 1)] ?? 0;
50572
50561
  }
50573
- return subjectMap;
50574
- }
50575
- async getById(gameId, caller) {
50576
- const db2 = this.deps.db;
50577
- const game = await db2.query.games.findFirst({
50578
- where: eq(games.id, gameId)
50579
- });
50580
- if (!game) {
50581
- throw new NotFoundError("Game", gameId);
50562
+ static normalizeDeploymentUrl(deploymentUrl) {
50563
+ return deploymentUrl.replace(/\/$/, "");
50582
50564
  }
50583
- this.enforceVisibility(game, caller, gameId);
50584
- return game;
50585
- }
50586
- async getBySlug(slug, caller) {
50587
- const db2 = this.deps.db;
50588
- const game = await db2.query.games.findFirst({
50589
- where: eq(games.slug, slug)
50590
- });
50591
- if (!game) {
50592
- throw new NotFoundError("Game", slug);
50565
+ static getManifestCacheKey(deploymentUrl) {
50566
+ return `${GameService2.MANIFEST_CACHE_KEY_PREFIX}:${deploymentUrl}`;
50593
50567
  }
50594
- this.enforceVisibility(game, caller, slug);
50595
- return game;
50596
- }
50597
- async getManifest(gameId, caller) {
50598
- const game = await this.getById(gameId, caller);
50599
- if (game.gameType !== "hosted" || !game.deploymentUrl) {
50600
- throw new BadRequestError("Game does not have a deployment manifest");
50601
- }
50602
- const deploymentUrl = game.deploymentUrl;
50603
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
50604
- const manifestHost = GameService.getManifestHost(manifestUrl);
50605
- const startedAt = Date.now();
50606
- const controller = new AbortController;
50607
- const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
50608
- function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
50609
- return {
50610
- manifestUrl,
50611
- manifestHost,
50612
- deploymentUrl,
50613
- fetchOutcome,
50614
- retryCount: 0,
50615
- durationMs: Date.now() - startedAt,
50616
- manifestErrorKind,
50617
- ...extra
50618
- };
50568
+ async list(caller) {
50569
+ const db2 = this.deps.db;
50570
+ const isAdmin = caller?.role === "admin";
50571
+ const isDeveloper = caller?.role === "developer";
50572
+ let whereClause;
50573
+ if (isAdmin) {
50574
+ whereClause = undefined;
50575
+ } else if (isDeveloper && caller?.id) {
50576
+ whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
50577
+ } else {
50578
+ whereClause = ne(games.visibility, "internal");
50579
+ }
50580
+ return db2.query.games.findMany({
50581
+ where: whereClause,
50582
+ orderBy: [desc(games.createdAt)]
50583
+ });
50619
50584
  }
50620
- let response;
50621
- try {
50622
- response = await fetch(manifestUrl, {
50623
- method: "GET",
50624
- headers: {
50625
- Accept: "application/json"
50626
- },
50627
- signal: controller.signal
50585
+ async listManageable(user) {
50586
+ this.validateDeveloperStatus(user);
50587
+ const db2 = this.deps.db;
50588
+ return db2.query.games.findMany({
50589
+ where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
50590
+ orderBy: [desc(games.createdAt)]
50628
50591
  });
50629
- } catch (error) {
50630
- clearTimeout(timeout);
50631
- const fetchErrorMessage = GameService.getFetchErrorMessage(error);
50632
- const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
50633
- logger5.error("Failed to fetch game manifest", {
50634
- gameId,
50635
- manifestUrl,
50636
- error,
50637
- details
50592
+ }
50593
+ async getSubjects() {
50594
+ const db2 = this.deps.db;
50595
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
50596
+ columns: { gameId: true, subject: true },
50597
+ orderBy: [asc(gameTimebackIntegrations.createdAt)]
50638
50598
  });
50639
- if (error instanceof Error && error.name === "AbortError") {
50640
- throw new TimeoutError("Timed out loading game manifest", details);
50599
+ const subjectMap = {};
50600
+ for (const integration of integrations) {
50601
+ if (!(integration.gameId in subjectMap)) {
50602
+ subjectMap[integration.gameId] = integration.subject;
50603
+ }
50641
50604
  }
50642
- throw new ServiceUnavailableError("Failed to load game manifest", details);
50643
- } finally {
50644
- clearTimeout(timeout);
50605
+ return subjectMap;
50645
50606
  }
50646
- if (!response.ok) {
50647
- const resolvedManifestUrl = response.url || manifestUrl;
50648
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50649
- const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
50650
- const details = buildDetails("bad_status", manifestErrorKind, {
50651
- manifestUrl: resolvedManifestUrl,
50652
- manifestHost: resolvedManifestHost,
50653
- status: response.status,
50654
- contentType: response.headers.get("content-type") ?? undefined,
50655
- cfRay: response.headers.get("cf-ray") ?? undefined,
50656
- redirected: response.redirected,
50657
- ...response.redirected ? {
50658
- originalManifestUrl: manifestUrl,
50659
- originalManifestHost: manifestHost
50660
- } : {}
50661
- });
50662
- const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
50663
- logger5.error("Game manifest returned non-ok response", {
50664
- gameId,
50665
- manifestUrl,
50666
- status: response.status,
50667
- details
50607
+ async getById(gameId, caller) {
50608
+ const db2 = this.deps.db;
50609
+ const game = await db2.query.games.findFirst({
50610
+ where: eq(games.id, gameId)
50668
50611
  });
50669
- if (manifestErrorKind === "temporary") {
50670
- throw new ServiceUnavailableError(message, details);
50612
+ if (!game) {
50613
+ throw new NotFoundError("Game", gameId);
50671
50614
  }
50672
- throw new BadRequestError(message, details);
50615
+ this.enforceVisibility(game, caller, gameId);
50616
+ return game;
50673
50617
  }
50674
- try {
50675
- return await response.json();
50676
- } catch (error) {
50677
- const resolvedManifestUrl = response.url || manifestUrl;
50678
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50679
- const details = buildDetails("invalid_body", "permanent", {
50680
- manifestUrl: resolvedManifestUrl,
50681
- manifestHost: resolvedManifestHost,
50682
- status: response.status,
50683
- contentType: response.headers.get("content-type") ?? undefined,
50684
- cfRay: response.headers.get("cf-ray") ?? undefined,
50685
- redirected: response.redirected,
50686
- ...response.redirected ? {
50687
- originalManifestUrl: manifestUrl,
50688
- originalManifestHost: manifestHost
50689
- } : {}
50690
- });
50691
- logger5.error("Failed to parse game manifest", {
50692
- gameId,
50693
- manifestUrl,
50694
- error,
50695
- details
50618
+ async getBySlug(slug, caller) {
50619
+ const db2 = this.deps.db;
50620
+ const game = await db2.query.games.findFirst({
50621
+ where: eq(games.slug, slug)
50696
50622
  });
50697
- throw new BadRequestError("Failed to parse game manifest", details);
50698
- }
50699
- }
50700
- enforceVisibility(game, caller, lookupIdentifier) {
50701
- if (game.visibility !== "internal") {
50702
- return;
50703
- }
50704
- const isAdmin = caller?.role === "admin";
50705
- const isOwner = caller?.id != null && caller.id === game.developerId;
50706
- if (!isAdmin && !isOwner) {
50707
- throw new NotFoundError("Game", lookupIdentifier);
50708
- }
50709
- }
50710
- async upsertBySlug(slug, data, user) {
50711
- const db2 = this.deps.db;
50712
- const existingGame = await db2.query.games.findFirst({
50713
- where: eq(games.slug, slug)
50714
- });
50715
- const isUpdate = Boolean(existingGame);
50716
- const gameId = existingGame?.id ?? crypto.randomUUID();
50717
- if (isUpdate) {
50718
- await this.validateDeveloperAccess(user, gameId);
50719
- } else {
50720
- this.validateDeveloperStatus(user);
50721
- }
50722
- const gameDataForDb = {
50723
- displayName: data.displayName,
50724
- platform: data.platform,
50725
- metadata: data.metadata,
50726
- mapElementId: data.mapElementId,
50727
- gameType: data.gameType,
50728
- ...data.visibility && { visibility: data.visibility },
50729
- externalUrl: data.externalUrl || null,
50730
- updatedAt: new Date
50731
- };
50732
- let gameResponse;
50733
- if (isUpdate) {
50734
- const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
50735
- if (!updatedGame) {
50736
- logger5.error("Game update returned no rows", { gameId, slug });
50737
- throw new InternalError("DB update failed to return result for existing game");
50738
- }
50739
- gameResponse = updatedGame;
50740
- } else {
50741
- const insertData = {
50742
- ...gameDataForDb,
50743
- id: gameId,
50744
- slug,
50745
- developerId: user.id,
50746
- metadata: data.metadata || {},
50747
- version: data.gameType === "external" ? "external" : "",
50748
- deploymentUrl: null,
50749
- createdAt: new Date
50750
- };
50751
- const [createdGame] = await db2.insert(games).values(insertData).returning();
50752
- if (!createdGame) {
50753
- logger5.error("Game insert returned no rows", { slug, developerId: user.id });
50754
- throw new InternalError("DB insert failed to return result for new game");
50623
+ if (!game) {
50624
+ throw new NotFoundError("Game", slug);
50755
50625
  }
50756
- gameResponse = createdGame;
50626
+ this.enforceVisibility(game, caller, slug);
50627
+ return game;
50757
50628
  }
50758
- if (data.mapElementId) {
50759
- try {
50760
- await db2.update(mapElements).set({
50761
- interactionType: "game_entry",
50762
- gameId: gameResponse.id
50763
- }).where(eq(mapElements.id, data.mapElementId));
50764
- } catch (mapError) {
50765
- logger5.warn("Failed to update map element", {
50766
- mapElementId: data.mapElementId,
50767
- error: mapError
50629
+ async getManifest(gameId, caller) {
50630
+ const game = await this.getById(gameId, caller);
50631
+ if (game.gameType !== "hosted" || !game.deploymentUrl) {
50632
+ throw new BadRequestError("Game does not have a deployment manifest");
50633
+ }
50634
+ const deploymentUrl = GameService2.normalizeDeploymentUrl(game.deploymentUrl);
50635
+ const cacheKey2 = GameService2.getManifestCacheKey(deploymentUrl);
50636
+ const cached = await this.deps.cache.get(cacheKey2);
50637
+ if (cached) {
50638
+ return cached;
50639
+ }
50640
+ const inFlight = inFlightManifestFetches.get(deploymentUrl);
50641
+ if (inFlight) {
50642
+ return inFlight;
50643
+ }
50644
+ const promise = this.fetchManifestFromOrigin({ gameId, deploymentUrl }).then(async (manifest) => {
50645
+ try {
50646
+ await this.deps.cache.set(cacheKey2, manifest, GameService2.MANIFEST_CACHE_TTL_SECONDS);
50647
+ } catch (cacheError) {
50648
+ logger5.warn("Failed to cache game manifest", {
50649
+ gameId,
50650
+ deploymentUrl,
50651
+ cacheKey: cacheKey2,
50652
+ error: cacheError
50653
+ });
50654
+ }
50655
+ return manifest;
50656
+ }).finally(() => {
50657
+ inFlightManifestFetches.delete(deploymentUrl);
50658
+ });
50659
+ inFlightManifestFetches.set(deploymentUrl, promise);
50660
+ return promise;
50661
+ }
50662
+ async fetchManifestFromOrigin(args2) {
50663
+ const { gameId, deploymentUrl } = args2;
50664
+ const manifestUrl = `${deploymentUrl}/playcademy.manifest.json`;
50665
+ const manifestHost = GameService2.getManifestHost(manifestUrl);
50666
+ const startedAt = Date.now();
50667
+ const maxAttempts = GameService2.MANIFEST_FETCH_MAX_RETRIES + 1;
50668
+ for (let attempt = 0;attempt < maxAttempts; attempt++) {
50669
+ const isLastAttempt = attempt === maxAttempts - 1;
50670
+ const outcome = await this.attemptManifestFetch({
50671
+ manifestUrl,
50672
+ manifestHost,
50673
+ deploymentUrl,
50674
+ startedAt,
50675
+ retryCount: attempt
50676
+ });
50677
+ if (outcome.kind === "success") {
50678
+ return outcome.manifest;
50679
+ }
50680
+ if (!outcome.retryable || isLastAttempt) {
50681
+ logger5.error("Failed to fetch game manifest", {
50682
+ gameId,
50683
+ manifestUrl,
50684
+ attempt: attempt + 1,
50685
+ maxAttempts,
50686
+ retryable: outcome.retryable,
50687
+ details: outcome.details,
50688
+ throwable: outcome.throwable,
50689
+ cause: outcome.cause
50690
+ });
50691
+ throw outcome.throwable;
50692
+ }
50693
+ const backoffMs = GameService2.getRetryBackoffMs(attempt);
50694
+ logger5.warn("Retrying game manifest fetch after transient failure", {
50695
+ gameId,
50696
+ manifestUrl,
50697
+ attempt: attempt + 1,
50698
+ maxAttempts,
50699
+ backoffMs,
50700
+ details: outcome.details,
50701
+ cause: outcome.cause
50768
50702
  });
50703
+ await sleep(backoffMs);
50769
50704
  }
50705
+ throw new InternalError("Exhausted manifest fetch retries without result");
50770
50706
  }
50771
- logger5.info("Upserted game", {
50772
- gameId: gameResponse.id,
50773
- slug: gameResponse.slug,
50774
- operation: isUpdate ? "update" : "create",
50775
- displayName: gameResponse.displayName
50776
- });
50777
- return gameResponse;
50778
- }
50779
- async delete(gameId, user) {
50780
- await this.validateDeveloperAccess(user, gameId);
50781
- const db2 = this.deps.db;
50782
- const gameToDelete = await db2.query.games.findFirst({
50783
- where: eq(games.id, gameId),
50784
- columns: { id: true, slug: true, displayName: true }
50785
- });
50786
- if (!gameToDelete?.slug) {
50787
- throw new NotFoundError("Game", gameId);
50788
- }
50789
- const activeDeployment = await db2.query.gameDeployments.findFirst({
50790
- where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
50791
- columns: { deploymentId: true, provider: true, resources: true }
50792
- });
50793
- const customHostnames = await db2.select({
50794
- hostname: gameCustomHostnames.hostname,
50795
- cloudflareId: gameCustomHostnames.cloudflareId,
50796
- environment: gameCustomHostnames.environment
50797
- }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
50798
- const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
50799
- if (result.length === 0) {
50800
- throw new NotFoundError("Game", gameId);
50801
- }
50802
- logger5.info("Deleted game", {
50803
- gameId: result[0].id,
50804
- slug: gameToDelete.slug,
50805
- hadActiveDeployment: Boolean(activeDeployment),
50806
- customDomainsCount: customHostnames.length
50807
- });
50808
- this.deps.alerts.notifyGameDeletion({
50809
- slug: gameToDelete.slug,
50810
- displayName: gameToDelete.displayName,
50811
- developer: { id: user.id, email: user.email }
50812
- }).catch((error) => {
50813
- logger5.warn("Failed to send deletion alert", { error });
50814
- });
50815
- if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
50707
+ async attemptManifestFetch(args2) {
50708
+ const { manifestUrl, manifestHost, deploymentUrl, startedAt, retryCount } = args2;
50709
+ function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
50710
+ return {
50711
+ manifestUrl,
50712
+ manifestHost,
50713
+ deploymentUrl,
50714
+ fetchOutcome,
50715
+ retryCount,
50716
+ durationMs: Date.now() - startedAt,
50717
+ manifestErrorKind,
50718
+ ...extra
50719
+ };
50720
+ }
50721
+ const controller = new AbortController;
50722
+ const timeout = setTimeout(() => controller.abort(), GameService2.MANIFEST_FETCH_ATTEMPT_TIMEOUT_MS);
50723
+ let response;
50816
50724
  try {
50817
- await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
50818
- deleteBindings: true,
50819
- resources: activeDeployment.resources ?? undefined,
50820
- customDomains: customHostnames.length > 0 ? customHostnames : undefined,
50821
- gameSlug: gameToDelete.slug
50822
- });
50823
- logger5.info("Cleaned up Cloudflare resources", {
50824
- gameId,
50825
- deploymentId: activeDeployment.deploymentId,
50826
- customDomainsDeleted: customHostnames.length
50725
+ response = await fetch(manifestUrl, {
50726
+ method: "GET",
50727
+ headers: {
50728
+ Accept: "application/json"
50729
+ },
50730
+ signal: controller.signal
50827
50731
  });
50828
- } catch (cfError) {
50829
- logger5.warn("Failed to cleanup Cloudflare resources", {
50830
- gameId,
50831
- deploymentId: activeDeployment.deploymentId,
50832
- error: cfError
50732
+ } catch (error) {
50733
+ const fetchErrorMessage = GameService2.getFetchErrorMessage(error);
50734
+ const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
50735
+ const throwable = error instanceof Error && error.name === "AbortError" ? new TimeoutError("Timed out loading game manifest", details) : new ServiceUnavailableError("Failed to load game manifest", details);
50736
+ return { kind: "failure", retryable: true, throwable, details, cause: error };
50737
+ } finally {
50738
+ clearTimeout(timeout);
50739
+ }
50740
+ if (!response.ok) {
50741
+ const resolvedManifestUrl = response.url || manifestUrl;
50742
+ const resolvedManifestHost = GameService2.getManifestHost(resolvedManifestUrl);
50743
+ const manifestErrorKind = GameService2.isRetryableStatus(response.status) ? "temporary" : "permanent";
50744
+ const details = buildDetails("bad_status", manifestErrorKind, {
50745
+ manifestUrl: resolvedManifestUrl,
50746
+ manifestHost: resolvedManifestHost,
50747
+ status: response.status,
50748
+ contentType: response.headers.get("content-type") ?? undefined,
50749
+ cfRay: response.headers.get("cf-ray") ?? undefined,
50750
+ redirected: response.redirected,
50751
+ ...response.redirected ? {
50752
+ originalManifestUrl: manifestUrl,
50753
+ originalManifestHost: manifestHost
50754
+ } : {}
50833
50755
  });
50756
+ const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
50757
+ const throwable = manifestErrorKind === "temporary" ? new ServiceUnavailableError(message, details) : new BadRequestError(message, details);
50758
+ return {
50759
+ kind: "failure",
50760
+ retryable: manifestErrorKind === "temporary",
50761
+ throwable,
50762
+ details
50763
+ };
50834
50764
  }
50835
50765
  try {
50836
- const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
50837
- if (deletedKeyId) {
50838
- logger5.info("Cleaned up API key for deleted game", {
50839
- gameId,
50840
- slug: gameToDelete.slug,
50841
- keyId: deletedKeyId
50766
+ const manifest = await response.json();
50767
+ return { kind: "success", manifest };
50768
+ } catch (error) {
50769
+ const resolvedManifestUrl = response.url || manifestUrl;
50770
+ const resolvedManifestHost = GameService2.getManifestHost(resolvedManifestUrl);
50771
+ const details = buildDetails("invalid_body", "permanent", {
50772
+ manifestUrl: resolvedManifestUrl,
50773
+ manifestHost: resolvedManifestHost,
50774
+ status: response.status,
50775
+ contentType: response.headers.get("content-type") ?? undefined,
50776
+ cfRay: response.headers.get("cf-ray") ?? undefined,
50777
+ redirected: response.redirected,
50778
+ ...response.redirected ? {
50779
+ originalManifestUrl: manifestUrl,
50780
+ originalManifestHost: manifestHost
50781
+ } : {}
50782
+ });
50783
+ return {
50784
+ kind: "failure",
50785
+ retryable: false,
50786
+ throwable: new BadRequestError("Failed to parse game manifest", details),
50787
+ details,
50788
+ cause: error
50789
+ };
50790
+ }
50791
+ }
50792
+ enforceVisibility(game, caller, lookupIdentifier) {
50793
+ if (game.visibility !== "internal") {
50794
+ return;
50795
+ }
50796
+ const isAdmin = caller?.role === "admin";
50797
+ const isOwner = caller?.id != null && caller.id === game.developerId;
50798
+ if (!isAdmin && !isOwner) {
50799
+ throw new NotFoundError("Game", lookupIdentifier);
50800
+ }
50801
+ }
50802
+ async upsertBySlug(slug, data, user) {
50803
+ const db2 = this.deps.db;
50804
+ const existingGame = await db2.query.games.findFirst({
50805
+ where: eq(games.slug, slug)
50806
+ });
50807
+ const isUpdate = Boolean(existingGame);
50808
+ const gameId = existingGame?.id ?? crypto.randomUUID();
50809
+ if (isUpdate) {
50810
+ await this.validateDeveloperAccess(user, gameId);
50811
+ } else {
50812
+ this.validateDeveloperStatus(user);
50813
+ }
50814
+ const gameDataForDb = {
50815
+ displayName: data.displayName,
50816
+ platform: data.platform,
50817
+ metadata: data.metadata,
50818
+ mapElementId: data.mapElementId,
50819
+ gameType: data.gameType,
50820
+ ...data.visibility && { visibility: data.visibility },
50821
+ externalUrl: data.externalUrl || null,
50822
+ updatedAt: new Date
50823
+ };
50824
+ let gameResponse;
50825
+ if (isUpdate) {
50826
+ const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
50827
+ if (!updatedGame) {
50828
+ logger5.error("Game update returned no rows", { gameId, slug });
50829
+ throw new InternalError("DB update failed to return result for existing game");
50830
+ }
50831
+ gameResponse = updatedGame;
50832
+ } else {
50833
+ const insertData = {
50834
+ ...gameDataForDb,
50835
+ id: gameId,
50836
+ slug,
50837
+ developerId: user.id,
50838
+ metadata: data.metadata || {},
50839
+ version: data.gameType === "external" ? "external" : "",
50840
+ deploymentUrl: null,
50841
+ createdAt: new Date
50842
+ };
50843
+ const [createdGame] = await db2.insert(games).values(insertData).returning();
50844
+ if (!createdGame) {
50845
+ logger5.error("Game insert returned no rows", { slug, developerId: user.id });
50846
+ throw new InternalError("DB insert failed to return result for new game");
50847
+ }
50848
+ gameResponse = createdGame;
50849
+ }
50850
+ if (data.mapElementId) {
50851
+ try {
50852
+ await db2.update(mapElements).set({
50853
+ interactionType: "game_entry",
50854
+ gameId: gameResponse.id
50855
+ }).where(eq(mapElements.id, data.mapElementId));
50856
+ } catch (mapError) {
50857
+ logger5.warn("Failed to update map element", {
50858
+ mapElementId: data.mapElementId,
50859
+ error: mapError
50842
50860
  });
50843
50861
  }
50844
- } catch (keyError) {
50845
- logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
50846
50862
  }
50863
+ logger5.info("Upserted game", {
50864
+ gameId: gameResponse.id,
50865
+ slug: gameResponse.slug,
50866
+ operation: isUpdate ? "update" : "create",
50867
+ displayName: gameResponse.displayName
50868
+ });
50869
+ return gameResponse;
50847
50870
  }
50848
- return {
50849
- slug: gameToDelete.slug,
50850
- displayName: gameToDelete.displayName
50851
- };
50852
- }
50853
- async validateOwnership(user, gameId) {
50854
- if (user.role === "admin") {
50855
- const gameExists = await this.deps.db.query.games.findFirst({
50871
+ async delete(gameId, user) {
50872
+ await this.validateDeveloperAccess(user, gameId);
50873
+ const db2 = this.deps.db;
50874
+ const gameToDelete = await db2.query.games.findFirst({
50856
50875
  where: eq(games.id, gameId),
50857
- columns: { id: true }
50876
+ columns: { id: true, slug: true, displayName: true }
50858
50877
  });
50859
- if (!gameExists) {
50878
+ if (!gameToDelete?.slug) {
50860
50879
  throw new NotFoundError("Game", gameId);
50861
50880
  }
50862
- return;
50881
+ const activeDeployment = await db2.query.gameDeployments.findFirst({
50882
+ where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
50883
+ columns: { deploymentId: true, provider: true, resources: true }
50884
+ });
50885
+ const customHostnames = await db2.select({
50886
+ hostname: gameCustomHostnames.hostname,
50887
+ cloudflareId: gameCustomHostnames.cloudflareId,
50888
+ environment: gameCustomHostnames.environment
50889
+ }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
50890
+ const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
50891
+ if (result.length === 0) {
50892
+ throw new NotFoundError("Game", gameId);
50893
+ }
50894
+ logger5.info("Deleted game", {
50895
+ gameId: result[0].id,
50896
+ slug: gameToDelete.slug,
50897
+ hadActiveDeployment: Boolean(activeDeployment),
50898
+ customDomainsCount: customHostnames.length
50899
+ });
50900
+ this.deps.alerts.notifyGameDeletion({
50901
+ slug: gameToDelete.slug,
50902
+ displayName: gameToDelete.displayName,
50903
+ developer: { id: user.id, email: user.email }
50904
+ }).catch((error) => {
50905
+ logger5.warn("Failed to send deletion alert", { error });
50906
+ });
50907
+ if (activeDeployment?.provider === "cloudflare" && this.deps.cloudflare) {
50908
+ try {
50909
+ await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
50910
+ deleteBindings: true,
50911
+ resources: activeDeployment.resources ?? undefined,
50912
+ customDomains: customHostnames.length > 0 ? customHostnames : undefined,
50913
+ gameSlug: gameToDelete.slug
50914
+ });
50915
+ logger5.info("Cleaned up Cloudflare resources", {
50916
+ gameId,
50917
+ deploymentId: activeDeployment.deploymentId,
50918
+ customDomainsDeleted: customHostnames.length
50919
+ });
50920
+ } catch (cfError) {
50921
+ logger5.warn("Failed to cleanup Cloudflare resources", {
50922
+ gameId,
50923
+ deploymentId: activeDeployment.deploymentId,
50924
+ error: cfError
50925
+ });
50926
+ }
50927
+ try {
50928
+ const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
50929
+ if (deletedKeyId) {
50930
+ logger5.info("Cleaned up API key for deleted game", {
50931
+ gameId,
50932
+ slug: gameToDelete.slug,
50933
+ keyId: deletedKeyId
50934
+ });
50935
+ }
50936
+ } catch (keyError) {
50937
+ logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
50938
+ }
50939
+ }
50940
+ return {
50941
+ slug: gameToDelete.slug,
50942
+ displayName: gameToDelete.displayName
50943
+ };
50863
50944
  }
50864
- const db2 = this.deps.db;
50865
- const gameOwnership = await db2.query.games.findFirst({
50866
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50867
- columns: { id: true }
50868
- });
50869
- if (!gameOwnership) {
50870
- const gameExists = await db2.query.games.findFirst({
50871
- where: eq(games.id, gameId),
50945
+ async validateOwnership(user, gameId) {
50946
+ if (user.role === "admin") {
50947
+ const gameExists = await this.deps.db.query.games.findFirst({
50948
+ where: eq(games.id, gameId),
50949
+ columns: { id: true }
50950
+ });
50951
+ if (!gameExists) {
50952
+ throw new NotFoundError("Game", gameId);
50953
+ }
50954
+ return;
50955
+ }
50956
+ const db2 = this.deps.db;
50957
+ const gameOwnership = await db2.query.games.findFirst({
50958
+ where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50872
50959
  columns: { id: true }
50873
50960
  });
50874
- if (!gameExists) {
50875
- throw new NotFoundError("Game", gameId);
50961
+ if (!gameOwnership) {
50962
+ const gameExists = await db2.query.games.findFirst({
50963
+ where: eq(games.id, gameId),
50964
+ columns: { id: true }
50965
+ });
50966
+ if (!gameExists) {
50967
+ throw new NotFoundError("Game", gameId);
50968
+ }
50969
+ throw new AccessDeniedError("You do not own this game");
50876
50970
  }
50877
- throw new AccessDeniedError("You do not own this game");
50878
50971
  }
50879
- }
50880
- async validateDeveloperAccess(user, gameId) {
50881
- this.validateDeveloperStatus(user);
50882
- if (user.role === "admin") {
50883
- const gameExists = await this.deps.db.query.games.findFirst({
50884
- where: eq(games.id, gameId),
50972
+ async validateDeveloperAccess(user, gameId) {
50973
+ this.validateDeveloperStatus(user);
50974
+ if (user.role === "admin") {
50975
+ const gameExists = await this.deps.db.query.games.findFirst({
50976
+ where: eq(games.id, gameId),
50977
+ columns: { id: true }
50978
+ });
50979
+ if (!gameExists) {
50980
+ throw new NotFoundError("Game", gameId);
50981
+ }
50982
+ return;
50983
+ }
50984
+ const db2 = this.deps.db;
50985
+ const existingGame = await db2.query.games.findFirst({
50986
+ where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50885
50987
  columns: { id: true }
50886
50988
  });
50887
- if (!gameExists) {
50989
+ if (!existingGame) {
50888
50990
  throw new NotFoundError("Game", gameId);
50889
50991
  }
50890
- return;
50891
- }
50892
- const db2 = this.deps.db;
50893
- const existingGame = await db2.query.games.findFirst({
50894
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50895
- columns: { id: true }
50896
- });
50897
- if (!existingGame) {
50898
- throw new NotFoundError("Game", gameId);
50899
50992
  }
50900
- }
50901
- async validateDeveloperAccessBySlug(user, slug) {
50902
- this.validateDeveloperStatus(user);
50903
- const db2 = this.deps.db;
50904
- if (user.role === "admin") {
50905
- const game2 = await db2.query.games.findFirst({
50906
- where: eq(games.slug, slug)
50993
+ async validateDeveloperAccessBySlug(user, slug) {
50994
+ this.validateDeveloperStatus(user);
50995
+ const db2 = this.deps.db;
50996
+ if (user.role === "admin") {
50997
+ const game2 = await db2.query.games.findFirst({
50998
+ where: eq(games.slug, slug)
50999
+ });
51000
+ if (!game2) {
51001
+ throw new NotFoundError("Game", slug);
51002
+ }
51003
+ return game2;
51004
+ }
51005
+ const game = await db2.query.games.findFirst({
51006
+ where: and(eq(games.slug, slug), eq(games.developerId, user.id))
50907
51007
  });
50908
- if (!game2) {
51008
+ if (!game) {
50909
51009
  throw new NotFoundError("Game", slug);
50910
51010
  }
50911
- return game2;
51011
+ return game;
50912
51012
  }
50913
- const game = await db2.query.games.findFirst({
50914
- where: and(eq(games.slug, slug), eq(games.developerId, user.id))
50915
- });
50916
- if (!game) {
50917
- throw new NotFoundError("Game", slug);
50918
- }
50919
- return game;
50920
- }
50921
- validateDeveloperStatus(user) {
50922
- if (user.role === "admin") {
50923
- return;
50924
- }
50925
- if (user.developerStatus !== "approved") {
50926
- const status = user.developerStatus || "none";
50927
- if (status === "pending") {
50928
- throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
50929
- } else {
50930
- throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
51013
+ validateDeveloperStatus(user) {
51014
+ if (user.role === "admin") {
51015
+ return;
51016
+ }
51017
+ if (user.developerStatus !== "approved") {
51018
+ const status = user.developerStatus || "none";
51019
+ if (status === "pending") {
51020
+ throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
51021
+ } else {
51022
+ throw new AccessDeniedError("Developer status required. Apply for developer access to create and manage games.");
51023
+ }
50931
51024
  }
50932
51025
  }
50933
- }
50934
- }
50935
- var logger5;
50936
- var init_game_service = __esm(() => {
50937
- init_drizzle_orm();
50938
- init_tables_index();
50939
- init_src2();
50940
- init_errors();
50941
- init_deployment_util();
50942
- logger5 = log.scope("GameService");
51026
+ };
50943
51027
  });
50944
51028
  function createGameServices(deps) {
50945
51029
  const { db: db2, config: config2, cloudflare, auth: auth2, storage, cache, alerts } = deps;
50946
51030
  const game = new GameService({
50947
51031
  db: db2,
50948
51032
  alerts,
51033
+ cache,
50949
51034
  cloudflare,
50950
51035
  deleteApiKeyByName: auth2.deleteApiKeyByName.bind(auth2)
50951
51036
  });
@@ -52736,7 +52821,8 @@ class SeedService {
52736
52821
  PLAYCADEMY_BASE_URL: ""
52737
52822
  }, {
52738
52823
  bindings: { d1: [deploymentId], r2: [], kv: [] },
52739
- keepAssets: false
52824
+ keepAssets: false,
52825
+ compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE
52740
52826
  });
52741
52827
  logger14.info("Worker deployed", { seedDeploymentId, url: result.url });
52742
52828
  if (secrets && Object.keys(secrets).length > 0) {
@@ -52866,6 +52952,7 @@ class SeedService {
52866
52952
  }
52867
52953
  var logger14;
52868
52954
  var init_seed_service = __esm(() => {
52955
+ init_src();
52869
52956
  init_setup2();
52870
52957
  init_src2();
52871
52958
  init_config2();
@@ -119670,6 +119757,7 @@ var init_schemas2 = __esm(() => {
119670
119757
  code: exports_external.string().optional(),
119671
119758
  codeUploadToken: exports_external.string().optional(),
119672
119759
  config: exports_external.unknown().optional(),
119760
+ compatibilityDate: exports_external.string().optional(),
119673
119761
  compatibilityFlags: exports_external.array(exports_external.string()).optional(),
119674
119762
  bindings: exports_external.object({
119675
119763
  database: exports_external.array(exports_external.string()).optional(),
@@ -125198,7 +125286,7 @@ var import_picocolors12 = __toESM(require_picocolors(), 1);
125198
125286
  // package.json
125199
125287
  var package_default2 = {
125200
125288
  name: "@playcademy/vite-plugin",
125201
- version: "0.2.23",
125289
+ version: "0.2.24-beta.2",
125202
125290
  type: "module",
125203
125291
  exports: {
125204
125292
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/vite-plugin",
3
- "version": "0.2.23",
3
+ "version": "0.2.24-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {