@playcademy/vite-plugin 0.2.24-beta.1 → 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 +478 -392
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -25335,7 +25335,7 @@ var package_default;
25335
25335
  var init_package = __esm(() => {
25336
25336
  package_default = {
25337
25337
  name: "@playcademy/sandbox",
25338
- version: "0.3.17-beta.4",
25338
+ version: "0.3.17-beta.5",
25339
25339
  description: "Local development server for Playcademy game development",
25340
25340
  type: "module",
25341
25341
  exports: {
@@ -50498,453 +50498,539 @@ var init_developer_service = __esm(() => {
50498
50498
  init_errors();
50499
50499
  logger4 = log.scope("DeveloperService");
50500
50500
  });
50501
-
50502
- class GameService {
50503
- deps;
50504
- static MANIFEST_FETCH_TIMEOUT_MS = 5000;
50505
- static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
50506
- constructor(deps) {
50507
- this.deps = deps;
50508
- }
50509
- static getManifestHost(manifestUrl) {
50510
- try {
50511
- return new URL(manifestUrl).host;
50512
- } catch {
50513
- return manifestUrl;
50514
- }
50501
+ function sleep(ms) {
50502
+ if (ms <= 0) {
50503
+ return Promise.resolve();
50515
50504
  }
50516
- static getFetchErrorMessage(error) {
50517
- let raw;
50518
- if (error instanceof Error) {
50519
- raw = error.message;
50520
- } else if (typeof error === "string") {
50521
- raw = error;
50522
- }
50523
- if (!raw) {
50524
- 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
+ }
50525
50535
  }
50526
- const normalized = raw.replace(/\s+/g, " ").trim();
50527
- if (!normalized) {
50528
- 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);
50529
50551
  }
50530
- return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
50531
- }
50532
- static isRetryableStatus(status) {
50533
- return status === 429 || status >= 500;
50534
- }
50535
- async list(caller) {
50536
- const db2 = this.deps.db;
50537
- const isAdmin = caller?.role === "admin";
50538
- const isDeveloper = caller?.role === "developer";
50539
- let whereClause;
50540
- if (isAdmin) {
50541
- whereClause = undefined;
50542
- } else if (isDeveloper && caller?.id) {
50543
- whereClause = or(ne(games.visibility, "internal"), eq(games.developerId, caller.id));
50544
- } else {
50545
- whereClause = ne(games.visibility, "internal");
50552
+ static isRetryableStatus(status) {
50553
+ return status === 429 || status >= 500;
50546
50554
  }
50547
- return db2.query.games.findMany({
50548
- where: whereClause,
50549
- orderBy: [desc(games.createdAt)]
50550
- });
50551
- }
50552
- async listManageable(user) {
50553
- this.validateDeveloperStatus(user);
50554
- const db2 = this.deps.db;
50555
- return db2.query.games.findMany({
50556
- where: user.role === "admin" ? undefined : eq(games.developerId, user.id),
50557
- orderBy: [desc(games.createdAt)]
50558
- });
50559
- }
50560
- async getSubjects() {
50561
- const db2 = this.deps.db;
50562
- const integrations = await db2.query.gameTimebackIntegrations.findMany({
50563
- columns: { gameId: true, subject: true },
50564
- orderBy: [asc(gameTimebackIntegrations.createdAt)]
50565
- });
50566
- const subjectMap = {};
50567
- for (const integration of integrations) {
50568
- if (!(integration.gameId in subjectMap)) {
50569
- 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;
50570
50559
  }
50560
+ return backoff[Math.min(attemptIndex, backoff.length - 1)] ?? 0;
50571
50561
  }
50572
- return subjectMap;
50573
- }
50574
- async getById(gameId, caller) {
50575
- const db2 = this.deps.db;
50576
- const game = await db2.query.games.findFirst({
50577
- where: eq(games.id, gameId)
50578
- });
50579
- if (!game) {
50580
- throw new NotFoundError("Game", gameId);
50562
+ static normalizeDeploymentUrl(deploymentUrl) {
50563
+ return deploymentUrl.replace(/\/$/, "");
50581
50564
  }
50582
- this.enforceVisibility(game, caller, gameId);
50583
- return game;
50584
- }
50585
- async getBySlug(slug, caller) {
50586
- const db2 = this.deps.db;
50587
- const game = await db2.query.games.findFirst({
50588
- where: eq(games.slug, slug)
50589
- });
50590
- if (!game) {
50591
- throw new NotFoundError("Game", slug);
50565
+ static getManifestCacheKey(deploymentUrl) {
50566
+ return `${GameService2.MANIFEST_CACHE_KEY_PREFIX}:${deploymentUrl}`;
50592
50567
  }
50593
- this.enforceVisibility(game, caller, slug);
50594
- return game;
50595
- }
50596
- async getManifest(gameId, caller) {
50597
- const game = await this.getById(gameId, caller);
50598
- if (game.gameType !== "hosted" || !game.deploymentUrl) {
50599
- throw new BadRequestError("Game does not have a deployment manifest");
50600
- }
50601
- const deploymentUrl = game.deploymentUrl;
50602
- const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
50603
- const manifestHost = GameService.getManifestHost(manifestUrl);
50604
- const startedAt = Date.now();
50605
- const controller = new AbortController;
50606
- const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
50607
- function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
50608
- return {
50609
- manifestUrl,
50610
- manifestHost,
50611
- deploymentUrl,
50612
- fetchOutcome,
50613
- retryCount: 0,
50614
- durationMs: Date.now() - startedAt,
50615
- manifestErrorKind,
50616
- ...extra
50617
- };
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
+ });
50618
50584
  }
50619
- let response;
50620
- try {
50621
- response = await fetch(manifestUrl, {
50622
- method: "GET",
50623
- headers: {
50624
- Accept: "application/json"
50625
- },
50626
- 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)]
50627
50591
  });
50628
- } catch (error) {
50629
- clearTimeout(timeout);
50630
- const fetchErrorMessage = GameService.getFetchErrorMessage(error);
50631
- const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
50632
- logger5.error("Failed to fetch game manifest", {
50633
- gameId,
50634
- manifestUrl,
50635
- error,
50636
- 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)]
50637
50598
  });
50638
- if (error instanceof Error && error.name === "AbortError") {
50639
- 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
+ }
50640
50604
  }
50641
- throw new ServiceUnavailableError("Failed to load game manifest", details);
50642
- } finally {
50643
- clearTimeout(timeout);
50605
+ return subjectMap;
50644
50606
  }
50645
- if (!response.ok) {
50646
- const resolvedManifestUrl = response.url || manifestUrl;
50647
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50648
- const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
50649
- const details = buildDetails("bad_status", manifestErrorKind, {
50650
- manifestUrl: resolvedManifestUrl,
50651
- manifestHost: resolvedManifestHost,
50652
- status: response.status,
50653
- contentType: response.headers.get("content-type") ?? undefined,
50654
- cfRay: response.headers.get("cf-ray") ?? undefined,
50655
- redirected: response.redirected,
50656
- ...response.redirected ? {
50657
- originalManifestUrl: manifestUrl,
50658
- originalManifestHost: manifestHost
50659
- } : {}
50660
- });
50661
- const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
50662
- logger5.error("Game manifest returned non-ok response", {
50663
- gameId,
50664
- manifestUrl,
50665
- status: response.status,
50666
- 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)
50667
50611
  });
50668
- if (manifestErrorKind === "temporary") {
50669
- throw new ServiceUnavailableError(message, details);
50612
+ if (!game) {
50613
+ throw new NotFoundError("Game", gameId);
50670
50614
  }
50671
- throw new BadRequestError(message, details);
50615
+ this.enforceVisibility(game, caller, gameId);
50616
+ return game;
50672
50617
  }
50673
- try {
50674
- return await response.json();
50675
- } catch (error) {
50676
- const resolvedManifestUrl = response.url || manifestUrl;
50677
- const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
50678
- const details = buildDetails("invalid_body", "permanent", {
50679
- manifestUrl: resolvedManifestUrl,
50680
- manifestHost: resolvedManifestHost,
50681
- status: response.status,
50682
- contentType: response.headers.get("content-type") ?? undefined,
50683
- cfRay: response.headers.get("cf-ray") ?? undefined,
50684
- redirected: response.redirected,
50685
- ...response.redirected ? {
50686
- originalManifestUrl: manifestUrl,
50687
- originalManifestHost: manifestHost
50688
- } : {}
50689
- });
50690
- logger5.error("Failed to parse game manifest", {
50691
- gameId,
50692
- manifestUrl,
50693
- error,
50694
- 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)
50695
50622
  });
50696
- throw new BadRequestError("Failed to parse game manifest", details);
50697
- }
50698
- }
50699
- enforceVisibility(game, caller, lookupIdentifier) {
50700
- if (game.visibility !== "internal") {
50701
- return;
50702
- }
50703
- const isAdmin = caller?.role === "admin";
50704
- const isOwner = caller?.id != null && caller.id === game.developerId;
50705
- if (!isAdmin && !isOwner) {
50706
- throw new NotFoundError("Game", lookupIdentifier);
50707
- }
50708
- }
50709
- async upsertBySlug(slug, data, user) {
50710
- const db2 = this.deps.db;
50711
- const existingGame = await db2.query.games.findFirst({
50712
- where: eq(games.slug, slug)
50713
- });
50714
- const isUpdate = Boolean(existingGame);
50715
- const gameId = existingGame?.id ?? crypto.randomUUID();
50716
- if (isUpdate) {
50717
- await this.validateDeveloperAccess(user, gameId);
50718
- } else {
50719
- this.validateDeveloperStatus(user);
50720
- }
50721
- const gameDataForDb = {
50722
- displayName: data.displayName,
50723
- platform: data.platform,
50724
- metadata: data.metadata,
50725
- mapElementId: data.mapElementId,
50726
- gameType: data.gameType,
50727
- ...data.visibility && { visibility: data.visibility },
50728
- externalUrl: data.externalUrl || null,
50729
- updatedAt: new Date
50730
- };
50731
- let gameResponse;
50732
- if (isUpdate) {
50733
- const [updatedGame] = await db2.update(games).set(gameDataForDb).where(eq(games.id, gameId)).returning();
50734
- if (!updatedGame) {
50735
- logger5.error("Game update returned no rows", { gameId, slug });
50736
- throw new InternalError("DB update failed to return result for existing game");
50737
- }
50738
- gameResponse = updatedGame;
50739
- } else {
50740
- const insertData = {
50741
- ...gameDataForDb,
50742
- id: gameId,
50743
- slug,
50744
- developerId: user.id,
50745
- metadata: data.metadata || {},
50746
- version: data.gameType === "external" ? "external" : "",
50747
- deploymentUrl: null,
50748
- createdAt: new Date
50749
- };
50750
- const [createdGame] = await db2.insert(games).values(insertData).returning();
50751
- if (!createdGame) {
50752
- logger5.error("Game insert returned no rows", { slug, developerId: user.id });
50753
- throw new InternalError("DB insert failed to return result for new game");
50623
+ if (!game) {
50624
+ throw new NotFoundError("Game", slug);
50754
50625
  }
50755
- gameResponse = createdGame;
50626
+ this.enforceVisibility(game, caller, slug);
50627
+ return game;
50756
50628
  }
50757
- if (data.mapElementId) {
50758
- try {
50759
- await db2.update(mapElements).set({
50760
- interactionType: "game_entry",
50761
- gameId: gameResponse.id
50762
- }).where(eq(mapElements.id, data.mapElementId));
50763
- } catch (mapError) {
50764
- logger5.warn("Failed to update map element", {
50765
- mapElementId: data.mapElementId,
50766
- 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
50767
50702
  });
50703
+ await sleep(backoffMs);
50768
50704
  }
50705
+ throw new InternalError("Exhausted manifest fetch retries without result");
50769
50706
  }
50770
- logger5.info("Upserted game", {
50771
- gameId: gameResponse.id,
50772
- slug: gameResponse.slug,
50773
- operation: isUpdate ? "update" : "create",
50774
- displayName: gameResponse.displayName
50775
- });
50776
- return gameResponse;
50777
- }
50778
- async delete(gameId, user) {
50779
- await this.validateDeveloperAccess(user, gameId);
50780
- const db2 = this.deps.db;
50781
- const gameToDelete = await db2.query.games.findFirst({
50782
- where: eq(games.id, gameId),
50783
- columns: { id: true, slug: true, displayName: true }
50784
- });
50785
- if (!gameToDelete?.slug) {
50786
- throw new NotFoundError("Game", gameId);
50787
- }
50788
- const activeDeployment = await db2.query.gameDeployments.findFirst({
50789
- where: and(eq(gameDeployments.gameId, gameId), eq(gameDeployments.isActive, true)),
50790
- columns: { deploymentId: true, provider: true, resources: true }
50791
- });
50792
- const customHostnames = await db2.select({
50793
- hostname: gameCustomHostnames.hostname,
50794
- cloudflareId: gameCustomHostnames.cloudflareId,
50795
- environment: gameCustomHostnames.environment
50796
- }).from(gameCustomHostnames).where(eq(gameCustomHostnames.gameId, gameId));
50797
- const result = await db2.delete(games).where(eq(games.id, gameId)).returning({ id: games.id });
50798
- if (result.length === 0) {
50799
- throw new NotFoundError("Game", gameId);
50800
- }
50801
- logger5.info("Deleted game", {
50802
- gameId: result[0].id,
50803
- slug: gameToDelete.slug,
50804
- hadActiveDeployment: Boolean(activeDeployment),
50805
- customDomainsCount: customHostnames.length
50806
- });
50807
- this.deps.alerts.notifyGameDeletion({
50808
- slug: gameToDelete.slug,
50809
- displayName: gameToDelete.displayName,
50810
- developer: { id: user.id, email: user.email }
50811
- }).catch((error) => {
50812
- logger5.warn("Failed to send deletion alert", { error });
50813
- });
50814
- 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;
50815
50724
  try {
50816
- await this.deps.cloudflare.delete(activeDeployment.deploymentId, {
50817
- deleteBindings: true,
50818
- resources: activeDeployment.resources ?? undefined,
50819
- customDomains: customHostnames.length > 0 ? customHostnames : undefined,
50820
- gameSlug: gameToDelete.slug
50821
- });
50822
- logger5.info("Cleaned up Cloudflare resources", {
50823
- gameId,
50824
- deploymentId: activeDeployment.deploymentId,
50825
- customDomainsDeleted: customHostnames.length
50725
+ response = await fetch(manifestUrl, {
50726
+ method: "GET",
50727
+ headers: {
50728
+ Accept: "application/json"
50729
+ },
50730
+ signal: controller.signal
50826
50731
  });
50827
- } catch (cfError) {
50828
- logger5.warn("Failed to cleanup Cloudflare resources", {
50829
- gameId,
50830
- deploymentId: activeDeployment.deploymentId,
50831
- 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
+ } : {}
50832
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
+ };
50833
50764
  }
50834
50765
  try {
50835
- const deletedKeyId = await this.deps.deleteApiKeyByName(getGameWorkerApiKeyName(gameToDelete.slug), user.id);
50836
- if (deletedKeyId) {
50837
- logger5.info("Cleaned up API key for deleted game", {
50838
- gameId,
50839
- slug: gameToDelete.slug,
50840
- 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
50841
50860
  });
50842
50861
  }
50843
- } catch (keyError) {
50844
- logger5.warn("Failed to cleanup API key", { gameId, error: keyError });
50845
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;
50846
50870
  }
50847
- return {
50848
- slug: gameToDelete.slug,
50849
- displayName: gameToDelete.displayName
50850
- };
50851
- }
50852
- async validateOwnership(user, gameId) {
50853
- if (user.role === "admin") {
50854
- 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({
50855
50875
  where: eq(games.id, gameId),
50856
- columns: { id: true }
50876
+ columns: { id: true, slug: true, displayName: true }
50857
50877
  });
50858
- if (!gameExists) {
50878
+ if (!gameToDelete?.slug) {
50859
50879
  throw new NotFoundError("Game", gameId);
50860
50880
  }
50861
- 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
+ };
50862
50944
  }
50863
- const db2 = this.deps.db;
50864
- const gameOwnership = await db2.query.games.findFirst({
50865
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50866
- columns: { id: true }
50867
- });
50868
- if (!gameOwnership) {
50869
- const gameExists = await db2.query.games.findFirst({
50870
- 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)),
50871
50959
  columns: { id: true }
50872
50960
  });
50873
- if (!gameExists) {
50874
- 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");
50875
50970
  }
50876
- throw new AccessDeniedError("You do not own this game");
50877
50971
  }
50878
- }
50879
- async validateDeveloperAccess(user, gameId) {
50880
- this.validateDeveloperStatus(user);
50881
- if (user.role === "admin") {
50882
- const gameExists = await this.deps.db.query.games.findFirst({
50883
- 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)),
50884
50987
  columns: { id: true }
50885
50988
  });
50886
- if (!gameExists) {
50989
+ if (!existingGame) {
50887
50990
  throw new NotFoundError("Game", gameId);
50888
50991
  }
50889
- return;
50890
- }
50891
- const db2 = this.deps.db;
50892
- const existingGame = await db2.query.games.findFirst({
50893
- where: and(eq(games.id, gameId), eq(games.developerId, user.id)),
50894
- columns: { id: true }
50895
- });
50896
- if (!existingGame) {
50897
- throw new NotFoundError("Game", gameId);
50898
50992
  }
50899
- }
50900
- async validateDeveloperAccessBySlug(user, slug) {
50901
- this.validateDeveloperStatus(user);
50902
- const db2 = this.deps.db;
50903
- if (user.role === "admin") {
50904
- const game2 = await db2.query.games.findFirst({
50905
- 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))
50906
51007
  });
50907
- if (!game2) {
51008
+ if (!game) {
50908
51009
  throw new NotFoundError("Game", slug);
50909
51010
  }
50910
- return game2;
50911
- }
50912
- const game = await db2.query.games.findFirst({
50913
- where: and(eq(games.slug, slug), eq(games.developerId, user.id))
50914
- });
50915
- if (!game) {
50916
- throw new NotFoundError("Game", slug);
50917
- }
50918
- return game;
50919
- }
50920
- validateDeveloperStatus(user) {
50921
- if (user.role === "admin") {
50922
- return;
51011
+ return game;
50923
51012
  }
50924
- if (user.developerStatus !== "approved") {
50925
- const status = user.developerStatus || "none";
50926
- if (status === "pending") {
50927
- throw new AccessDeniedError("Developer application is pending approval. You will be notified when approved.");
50928
- } else {
50929
- 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
+ }
50930
51024
  }
50931
51025
  }
50932
- }
50933
- }
50934
- var logger5;
50935
- var init_game_service = __esm(() => {
50936
- init_drizzle_orm();
50937
- init_tables_index();
50938
- init_src2();
50939
- init_errors();
50940
- init_deployment_util();
50941
- logger5 = log.scope("GameService");
51026
+ };
50942
51027
  });
50943
51028
  function createGameServices(deps) {
50944
51029
  const { db: db2, config: config2, cloudflare, auth: auth2, storage, cache, alerts } = deps;
50945
51030
  const game = new GameService({
50946
51031
  db: db2,
50947
51032
  alerts,
51033
+ cache,
50948
51034
  cloudflare,
50949
51035
  deleteApiKeyByName: auth2.deleteApiKeyByName.bind(auth2)
50950
51036
  });
@@ -125200,7 +125286,7 @@ var import_picocolors12 = __toESM(require_picocolors(), 1);
125200
125286
  // package.json
125201
125287
  var package_default2 = {
125202
125288
  name: "@playcademy/vite-plugin",
125203
- version: "0.2.24-beta.1",
125289
+ version: "0.2.24-beta.2",
125204
125290
  type: "module",
125205
125291
  exports: {
125206
125292
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/vite-plugin",
3
- "version": "0.2.24-beta.1",
3
+ "version": "0.2.24-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {