@playcademy/sandbox 0.3.16-beta.4 → 0.3.16-beta.6

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 (3) hide show
  1. package/dist/cli.js +152 -4
  2. package/dist/server.js +152 -4
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -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.16-beta.4",
1313
+ version: "0.3.16-beta.6",
1314
1314
  description: "Local development server for Playcademy game development",
1315
1315
  type: "module",
1316
1316
  exports: {
@@ -26650,9 +26650,37 @@ var init_developer_service = __esm(() => {
26650
26650
  // ../api-core/src/services/game.service.ts
26651
26651
  class GameService {
26652
26652
  deps;
26653
+ static MANIFEST_FETCH_TIMEOUT_MS = 5000;
26654
+ static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
26653
26655
  constructor(deps) {
26654
26656
  this.deps = deps;
26655
26657
  }
26658
+ static getManifestHost(manifestUrl) {
26659
+ try {
26660
+ return new URL(manifestUrl).host;
26661
+ } catch {
26662
+ return manifestUrl;
26663
+ }
26664
+ }
26665
+ static getFetchErrorMessage(error) {
26666
+ let raw;
26667
+ if (error instanceof Error) {
26668
+ raw = error.message;
26669
+ } else if (typeof error === "string") {
26670
+ raw = error;
26671
+ }
26672
+ if (!raw) {
26673
+ return;
26674
+ }
26675
+ const normalized = raw.replace(/\s+/g, " ").trim();
26676
+ if (!normalized) {
26677
+ return;
26678
+ }
26679
+ return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
26680
+ }
26681
+ static isRetryableStatus(status) {
26682
+ return status === 429 || status >= 500;
26683
+ }
26656
26684
  async list(caller) {
26657
26685
  const db2 = this.deps.db;
26658
26686
  const isAdmin = caller?.role === "admin";
@@ -26714,6 +26742,109 @@ class GameService {
26714
26742
  this.enforceVisibility(game, caller, slug);
26715
26743
  return game;
26716
26744
  }
26745
+ async getManifest(gameId, caller) {
26746
+ const game = await this.getById(gameId, caller);
26747
+ if (game.gameType !== "hosted" || !game.deploymentUrl) {
26748
+ throw new BadRequestError("Game does not have a deployment manifest");
26749
+ }
26750
+ const deploymentUrl = game.deploymentUrl;
26751
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
26752
+ const manifestHost = GameService.getManifestHost(manifestUrl);
26753
+ const startedAt = Date.now();
26754
+ const controller = new AbortController;
26755
+ const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
26756
+ function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
26757
+ return {
26758
+ manifestUrl,
26759
+ manifestHost,
26760
+ deploymentUrl,
26761
+ fetchOutcome,
26762
+ retryCount: 0,
26763
+ durationMs: Date.now() - startedAt,
26764
+ manifestErrorKind,
26765
+ ...extra
26766
+ };
26767
+ }
26768
+ let response;
26769
+ try {
26770
+ response = await fetch(manifestUrl, {
26771
+ method: "GET",
26772
+ headers: {
26773
+ Accept: "application/json"
26774
+ },
26775
+ signal: controller.signal
26776
+ });
26777
+ } catch (error) {
26778
+ clearTimeout(timeout);
26779
+ const fetchErrorMessage = GameService.getFetchErrorMessage(error);
26780
+ const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
26781
+ logger5.error("Failed to fetch game manifest", {
26782
+ gameId,
26783
+ manifestUrl,
26784
+ error,
26785
+ details
26786
+ });
26787
+ if (error instanceof Error && error.name === "AbortError") {
26788
+ throw new TimeoutError("Timed out loading game manifest", details);
26789
+ }
26790
+ throw new ServiceUnavailableError("Failed to load game manifest", details);
26791
+ } finally {
26792
+ clearTimeout(timeout);
26793
+ }
26794
+ if (!response.ok) {
26795
+ const resolvedManifestUrl = response.url || manifestUrl;
26796
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26797
+ const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
26798
+ const details = buildDetails("bad_status", manifestErrorKind, {
26799
+ manifestUrl: resolvedManifestUrl,
26800
+ manifestHost: resolvedManifestHost,
26801
+ status: response.status,
26802
+ contentType: response.headers.get("content-type") ?? undefined,
26803
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26804
+ redirected: response.redirected,
26805
+ ...response.redirected ? {
26806
+ originalManifestUrl: manifestUrl,
26807
+ originalManifestHost: manifestHost
26808
+ } : {}
26809
+ });
26810
+ const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
26811
+ logger5.error("Game manifest returned non-ok response", {
26812
+ gameId,
26813
+ manifestUrl,
26814
+ status: response.status,
26815
+ details
26816
+ });
26817
+ if (manifestErrorKind === "temporary") {
26818
+ throw new ServiceUnavailableError(message, details);
26819
+ }
26820
+ throw new BadRequestError(message, details);
26821
+ }
26822
+ try {
26823
+ return await response.json();
26824
+ } catch (error) {
26825
+ const resolvedManifestUrl = response.url || manifestUrl;
26826
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26827
+ const details = buildDetails("invalid_body", "permanent", {
26828
+ manifestUrl: resolvedManifestUrl,
26829
+ manifestHost: resolvedManifestHost,
26830
+ status: response.status,
26831
+ contentType: response.headers.get("content-type") ?? undefined,
26832
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26833
+ redirected: response.redirected,
26834
+ ...response.redirected ? {
26835
+ originalManifestUrl: manifestUrl,
26836
+ originalManifestHost: manifestHost
26837
+ } : {}
26838
+ });
26839
+ logger5.error("Failed to parse game manifest", {
26840
+ gameId,
26841
+ manifestUrl,
26842
+ error,
26843
+ details
26844
+ });
26845
+ throw new BadRequestError("Failed to parse game manifest", details);
26846
+ }
26847
+ }
26717
26848
  enforceVisibility(game, caller, lookupIdentifier) {
26718
26849
  if (game.visibility !== "internal") {
26719
26850
  return;
@@ -35041,7 +35172,7 @@ function createOneRosterNamespace(client) {
35041
35172
  limit: options?.limit,
35042
35173
  offset: options?.offset,
35043
35174
  fields: options?.fields,
35044
- filter: `student.sourcedId='${studentSourcedId}'`
35175
+ filter: `student.sourcedId='${escapeFilterValue2(studentSourcedId)}'`
35045
35176
  });
35046
35177
  } catch (error) {
35047
35178
  logTimebackError("list assessment results for student", error, {
@@ -94178,7 +94309,7 @@ var init_domain_controller = __esm(() => {
94178
94309
  });
94179
94310
 
94180
94311
  // ../api-core/src/controllers/game.controller.ts
94181
- var logger46, list3, listManageable, getSubjects, getById2, getBySlug, upsertBySlug, remove3, games2;
94312
+ var logger46, list3, listManageable, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
94182
94313
  var init_game_controller = __esm(() => {
94183
94314
  init_esm();
94184
94315
  init_schemas_index();
@@ -94218,6 +94349,21 @@ var init_game_controller = __esm(() => {
94218
94349
  logger46.debug("Getting game by slug", { userId: ctx.user.id, slug: slug2, launchId: ctx.launchId });
94219
94350
  return ctx.services.game.getBySlug(slug2, ctx.user);
94220
94351
  });
94352
+ getManifest = requireAuth(async (ctx) => {
94353
+ const gameId = ctx.params.gameId;
94354
+ if (!gameId) {
94355
+ throw ApiError.badRequest("Missing game ID");
94356
+ }
94357
+ if (!isValidUUID(gameId)) {
94358
+ throw ApiError.unprocessableEntity("gameId must be a valid UUID format");
94359
+ }
94360
+ logger46.debug("Getting game manifest by ID", {
94361
+ userId: ctx.user.id,
94362
+ gameId,
94363
+ launchId: ctx.launchId
94364
+ });
94365
+ return ctx.services.game.getManifest(gameId, ctx.user);
94366
+ });
94221
94367
  upsertBySlug = requireAuth(async (ctx) => {
94222
94368
  const slug2 = ctx.params.slug;
94223
94369
  if (!slug2) {
@@ -94254,6 +94400,7 @@ var init_game_controller = __esm(() => {
94254
94400
  listManageable,
94255
94401
  getSubjects,
94256
94402
  getById: getById2,
94403
+ getManifest,
94257
94404
  getBySlug,
94258
94405
  upsertBySlug,
94259
94406
  remove: remove3
@@ -96081,7 +96228,8 @@ var init_crud = __esm(() => {
96081
96228
  init_api();
96082
96229
  gameCrudRouter = new Hono2;
96083
96230
  gameCrudRouter.get("/", handle2(games2.list));
96084
- gameCrudRouter.get("/:gameId{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}", handle2(games2.getById));
96231
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}/manifest", handle2(games2.getManifest));
96232
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}", handle2(games2.getById));
96085
96233
  gameCrudRouter.get("/:slug", handle2(games2.getBySlug));
96086
96234
  gameCrudRouter.put("/:slug", handle2(games2.upsertBySlug));
96087
96235
  gameCrudRouter.delete("/:gameId", handle2(games2.remove, { status: 204 }));
package/dist/server.js CHANGED
@@ -1309,7 +1309,7 @@ var package_default;
1309
1309
  var init_package = __esm(() => {
1310
1310
  package_default = {
1311
1311
  name: "@playcademy/sandbox",
1312
- version: "0.3.16-beta.4",
1312
+ version: "0.3.16-beta.6",
1313
1313
  description: "Local development server for Playcademy game development",
1314
1314
  type: "module",
1315
1315
  exports: {
@@ -26649,9 +26649,37 @@ var init_developer_service = __esm(() => {
26649
26649
  // ../api-core/src/services/game.service.ts
26650
26650
  class GameService {
26651
26651
  deps;
26652
+ static MANIFEST_FETCH_TIMEOUT_MS = 5000;
26653
+ static MAX_FETCH_ERROR_MESSAGE_LENGTH = 512;
26652
26654
  constructor(deps) {
26653
26655
  this.deps = deps;
26654
26656
  }
26657
+ static getManifestHost(manifestUrl) {
26658
+ try {
26659
+ return new URL(manifestUrl).host;
26660
+ } catch {
26661
+ return manifestUrl;
26662
+ }
26663
+ }
26664
+ static getFetchErrorMessage(error) {
26665
+ let raw;
26666
+ if (error instanceof Error) {
26667
+ raw = error.message;
26668
+ } else if (typeof error === "string") {
26669
+ raw = error;
26670
+ }
26671
+ if (!raw) {
26672
+ return;
26673
+ }
26674
+ const normalized = raw.replace(/\s+/g, " ").trim();
26675
+ if (!normalized) {
26676
+ return;
26677
+ }
26678
+ return normalized.slice(0, GameService.MAX_FETCH_ERROR_MESSAGE_LENGTH);
26679
+ }
26680
+ static isRetryableStatus(status) {
26681
+ return status === 429 || status >= 500;
26682
+ }
26655
26683
  async list(caller) {
26656
26684
  const db2 = this.deps.db;
26657
26685
  const isAdmin = caller?.role === "admin";
@@ -26713,6 +26741,109 @@ class GameService {
26713
26741
  this.enforceVisibility(game, caller, slug);
26714
26742
  return game;
26715
26743
  }
26744
+ async getManifest(gameId, caller) {
26745
+ const game = await this.getById(gameId, caller);
26746
+ if (game.gameType !== "hosted" || !game.deploymentUrl) {
26747
+ throw new BadRequestError("Game does not have a deployment manifest");
26748
+ }
26749
+ const deploymentUrl = game.deploymentUrl;
26750
+ const manifestUrl = `${deploymentUrl.replace(/\/$/, "")}/playcademy.manifest.json`;
26751
+ const manifestHost = GameService.getManifestHost(manifestUrl);
26752
+ const startedAt = Date.now();
26753
+ const controller = new AbortController;
26754
+ const timeout = setTimeout(() => controller.abort(), GameService.MANIFEST_FETCH_TIMEOUT_MS);
26755
+ function buildDetails(fetchOutcome, manifestErrorKind, extra = {}) {
26756
+ return {
26757
+ manifestUrl,
26758
+ manifestHost,
26759
+ deploymentUrl,
26760
+ fetchOutcome,
26761
+ retryCount: 0,
26762
+ durationMs: Date.now() - startedAt,
26763
+ manifestErrorKind,
26764
+ ...extra
26765
+ };
26766
+ }
26767
+ let response;
26768
+ try {
26769
+ response = await fetch(manifestUrl, {
26770
+ method: "GET",
26771
+ headers: {
26772
+ Accept: "application/json"
26773
+ },
26774
+ signal: controller.signal
26775
+ });
26776
+ } catch (error) {
26777
+ clearTimeout(timeout);
26778
+ const fetchErrorMessage = GameService.getFetchErrorMessage(error);
26779
+ const details = buildDetails("network_error", "temporary", fetchErrorMessage ? { fetchErrorMessage } : {});
26780
+ logger5.error("Failed to fetch game manifest", {
26781
+ gameId,
26782
+ manifestUrl,
26783
+ error,
26784
+ details
26785
+ });
26786
+ if (error instanceof Error && error.name === "AbortError") {
26787
+ throw new TimeoutError("Timed out loading game manifest", details);
26788
+ }
26789
+ throw new ServiceUnavailableError("Failed to load game manifest", details);
26790
+ } finally {
26791
+ clearTimeout(timeout);
26792
+ }
26793
+ if (!response.ok) {
26794
+ const resolvedManifestUrl = response.url || manifestUrl;
26795
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26796
+ const manifestErrorKind = GameService.isRetryableStatus(response.status) ? "temporary" : "permanent";
26797
+ const details = buildDetails("bad_status", manifestErrorKind, {
26798
+ manifestUrl: resolvedManifestUrl,
26799
+ manifestHost: resolvedManifestHost,
26800
+ status: response.status,
26801
+ contentType: response.headers.get("content-type") ?? undefined,
26802
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26803
+ redirected: response.redirected,
26804
+ ...response.redirected ? {
26805
+ originalManifestUrl: manifestUrl,
26806
+ originalManifestHost: manifestHost
26807
+ } : {}
26808
+ });
26809
+ const message = `Failed to fetch manifest: ${response.status} ${response.statusText}`;
26810
+ logger5.error("Game manifest returned non-ok response", {
26811
+ gameId,
26812
+ manifestUrl,
26813
+ status: response.status,
26814
+ details
26815
+ });
26816
+ if (manifestErrorKind === "temporary") {
26817
+ throw new ServiceUnavailableError(message, details);
26818
+ }
26819
+ throw new BadRequestError(message, details);
26820
+ }
26821
+ try {
26822
+ return await response.json();
26823
+ } catch (error) {
26824
+ const resolvedManifestUrl = response.url || manifestUrl;
26825
+ const resolvedManifestHost = GameService.getManifestHost(resolvedManifestUrl);
26826
+ const details = buildDetails("invalid_body", "permanent", {
26827
+ manifestUrl: resolvedManifestUrl,
26828
+ manifestHost: resolvedManifestHost,
26829
+ status: response.status,
26830
+ contentType: response.headers.get("content-type") ?? undefined,
26831
+ cfRay: response.headers.get("cf-ray") ?? undefined,
26832
+ redirected: response.redirected,
26833
+ ...response.redirected ? {
26834
+ originalManifestUrl: manifestUrl,
26835
+ originalManifestHost: manifestHost
26836
+ } : {}
26837
+ });
26838
+ logger5.error("Failed to parse game manifest", {
26839
+ gameId,
26840
+ manifestUrl,
26841
+ error,
26842
+ details
26843
+ });
26844
+ throw new BadRequestError("Failed to parse game manifest", details);
26845
+ }
26846
+ }
26716
26847
  enforceVisibility(game, caller, lookupIdentifier) {
26717
26848
  if (game.visibility !== "internal") {
26718
26849
  return;
@@ -35040,7 +35171,7 @@ function createOneRosterNamespace(client) {
35040
35171
  limit: options?.limit,
35041
35172
  offset: options?.offset,
35042
35173
  fields: options?.fields,
35043
- filter: `student.sourcedId='${studentSourcedId}'`
35174
+ filter: `student.sourcedId='${escapeFilterValue2(studentSourcedId)}'`
35044
35175
  });
35045
35176
  } catch (error) {
35046
35177
  logTimebackError("list assessment results for student", error, {
@@ -94177,7 +94308,7 @@ var init_domain_controller = __esm(() => {
94177
94308
  });
94178
94309
 
94179
94310
  // ../api-core/src/controllers/game.controller.ts
94180
- var logger46, list3, listManageable, getSubjects, getById2, getBySlug, upsertBySlug, remove3, games2;
94311
+ var logger46, list3, listManageable, getSubjects, getById2, getBySlug, getManifest, upsertBySlug, remove3, games2;
94181
94312
  var init_game_controller = __esm(() => {
94182
94313
  init_esm();
94183
94314
  init_schemas_index();
@@ -94217,6 +94348,21 @@ var init_game_controller = __esm(() => {
94217
94348
  logger46.debug("Getting game by slug", { userId: ctx.user.id, slug: slug2, launchId: ctx.launchId });
94218
94349
  return ctx.services.game.getBySlug(slug2, ctx.user);
94219
94350
  });
94351
+ getManifest = requireAuth(async (ctx) => {
94352
+ const gameId = ctx.params.gameId;
94353
+ if (!gameId) {
94354
+ throw ApiError.badRequest("Missing game ID");
94355
+ }
94356
+ if (!isValidUUID(gameId)) {
94357
+ throw ApiError.unprocessableEntity("gameId must be a valid UUID format");
94358
+ }
94359
+ logger46.debug("Getting game manifest by ID", {
94360
+ userId: ctx.user.id,
94361
+ gameId,
94362
+ launchId: ctx.launchId
94363
+ });
94364
+ return ctx.services.game.getManifest(gameId, ctx.user);
94365
+ });
94220
94366
  upsertBySlug = requireAuth(async (ctx) => {
94221
94367
  const slug2 = ctx.params.slug;
94222
94368
  if (!slug2) {
@@ -94253,6 +94399,7 @@ var init_game_controller = __esm(() => {
94253
94399
  listManageable,
94254
94400
  getSubjects,
94255
94401
  getById: getById2,
94402
+ getManifest,
94256
94403
  getBySlug,
94257
94404
  upsertBySlug,
94258
94405
  remove: remove3
@@ -96080,7 +96227,8 @@ var init_crud = __esm(() => {
96080
96227
  init_api();
96081
96228
  gameCrudRouter = new Hono2;
96082
96229
  gameCrudRouter.get("/", handle2(games2.list));
96083
- gameCrudRouter.get("/:gameId{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}", handle2(games2.getById));
96230
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}/manifest", handle2(games2.getManifest));
96231
+ gameCrudRouter.get("/:gameId{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}}", handle2(games2.getById));
96084
96232
  gameCrudRouter.get("/:slug", handle2(games2.getBySlug));
96085
96233
  gameCrudRouter.put("/:slug", handle2(games2.upsertBySlug));
96086
96234
  gameCrudRouter.delete("/:gameId", handle2(games2.remove, { status: 204 }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.16-beta.4",
3
+ "version": "0.3.16-beta.6",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {