playcademy 0.14.12-alpha.1 → 0.14.13

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.
@@ -177,8 +177,9 @@ async function serveFromR2(c: Context<HonoEnv>, bucket: R2Bucket): Promise<Respo
177
177
  return new Response(object.body, {
178
178
  headers: {
179
179
  'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
180
- 'Cache-Control':
181
- key === 'index.html' ? 'no-cache' : 'public, max-age=31536000, immutable',
180
+ // Use 1-hour caching - balances performance with update propagation
181
+ // (Godot and other engines use non-hashed filenames)
182
+ 'Cache-Control': key === 'index.html' ? 'no-cache' : 'public, max-age=3600',
182
183
  ETag: object.etag,
183
184
  },
184
185
  })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Stub Worker for Static-Only Games
3
+ *
4
+ * Minimal worker that serves static assets with CORS headers.
5
+ * Used for games that don't have custom backend routes.
6
+ *
7
+ * Features:
8
+ * - Serves files from Workers Assets or R2 bucket binding
9
+ * - Supports hybrid deployment strategy (small files via Assets, large files via R2)
10
+ * - Adds CORS headers for cross-origin manifest fetching
11
+ * - Handles OPTIONS preflight requests
12
+ * - Returns 404 for /api/* routes (no backend deployed)
13
+ * - Supports SPA routing (falls back to index.html)
14
+ */
15
+
16
+ type AssetsFetcher = { fetch(request: Request): Promise<Response> }
17
+ type R2Bucket = { get(key: string): Promise<R2Object | null> }
18
+ type R2Object = { body: ReadableStream; httpMetadata?: { contentType?: string }; etag: string }
19
+
20
+ interface Env {
21
+ ASSETS: AssetsFetcher | R2Bucket
22
+ }
23
+
24
+ /**
25
+ * Detect if ASSETS binding is an R2 bucket or Workers Assets Fetcher
26
+ */
27
+ function isR2Bucket(assets: AssetsFetcher | R2Bucket): assets is R2Bucket {
28
+ return 'get' in assets && typeof assets.get === 'function' && !('fetch' in assets)
29
+ }
30
+
31
+ /**
32
+ * Serve from Workers Assets (default for files <25MB)
33
+ */
34
+ async function serveFromAssets(request: Request, assets: AssetsFetcher): Promise<Response> {
35
+ const response = await assets.fetch(request)
36
+
37
+ if (response.status !== 404) {
38
+ // Add CORS headers
39
+ const modifiedResponse = new Response(response.body, response)
40
+ modifiedResponse.headers.set('Access-Control-Allow-Origin', '*')
41
+ modifiedResponse.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
42
+ return modifiedResponse
43
+ }
44
+
45
+ // SPA routing: if not a file request, try index.html
46
+ const path = new URL(request.url).pathname
47
+ if (path.includes('.')) {
48
+ return response // File request not found
49
+ }
50
+
51
+ const indexUrl = new URL(request.url)
52
+ indexUrl.pathname = '/index.html'
53
+ const indexResponse = await assets.fetch(new Request(indexUrl.toString()))
54
+
55
+ // Add CORS headers to index.html response
56
+ const modifiedResponse = new Response(indexResponse.body, indexResponse)
57
+ modifiedResponse.headers.set('Access-Control-Allow-Origin', '*')
58
+ modifiedResponse.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
59
+ return modifiedResponse
60
+ }
61
+
62
+ /**
63
+ * Serve from R2 bucket (for large files like Godot WASM)
64
+ */
65
+ async function serveFromR2(request: Request, bucket: R2Bucket): Promise<Response> {
66
+ const path = new URL(request.url).pathname
67
+ const key = path === '/' ? 'index.html' : path.slice(1)
68
+
69
+ const object = await bucket.get(key)
70
+
71
+ if (object) {
72
+ return new Response(object.body, {
73
+ headers: {
74
+ 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
75
+ // Use 1-hour caching - balances performance with update propagation
76
+ // (Godot and other engines use non-hashed filenames)
77
+ 'Cache-Control': key === 'index.html' ? 'no-cache' : 'public, max-age=3600',
78
+ ETag: object.etag,
79
+ 'Access-Control-Allow-Origin': '*',
80
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
81
+ },
82
+ })
83
+ }
84
+
85
+ // SPA routing: if not a file request, try index.html
86
+ if (path.includes('.')) {
87
+ return new Response('Not Found', { status: 404 })
88
+ }
89
+
90
+ const indexObject = await bucket.get('index.html')
91
+ if (indexObject) {
92
+ return new Response(indexObject.body, {
93
+ headers: {
94
+ 'Content-Type': 'text/html',
95
+ 'Cache-Control': 'no-cache',
96
+ 'Access-Control-Allow-Origin': '*',
97
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
98
+ },
99
+ })
100
+ }
101
+
102
+ return new Response('Not Found', { status: 404 })
103
+ }
104
+
105
+ export default {
106
+ async fetch(request: Request, env: Env): Promise<Response> {
107
+ try {
108
+ const url = new URL(request.url)
109
+
110
+ // Handle CORS preflight
111
+ if (request.method === 'OPTIONS') {
112
+ const headers = new Headers()
113
+ headers.set('Access-Control-Allow-Origin', '*')
114
+ headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
115
+ const reqHeaders = request.headers.get('Access-Control-Request-Headers')
116
+ if (reqHeaders) {
117
+ headers.set('Access-Control-Allow-Headers', reqHeaders)
118
+ }
119
+ headers.set('Access-Control-Max-Age', '86400')
120
+ return new Response(null, { status: 204, headers })
121
+ }
122
+
123
+ // No backend routes available
124
+ if (url.pathname.startsWith('/api/')) {
125
+ return new Response('No backend deployed', { status: 404 })
126
+ }
127
+
128
+ // Serve static assets (auto-detect Workers Assets vs R2)
129
+ if (!env.ASSETS) {
130
+ return new Response('ASSETS binding not configured', { status: 500 })
131
+ }
132
+
133
+ return isR2Bucket(env.ASSETS)
134
+ ? await serveFromR2(request, env.ASSETS)
135
+ : await serveFromAssets(request, env.ASSETS)
136
+ } catch (error) {
137
+ return new Response('Error: ' + (error as Error).message, { status: 500 })
138
+ }
139
+ },
140
+ }
package/dist/index.js CHANGED
@@ -5680,7 +5680,7 @@ function getDevDbPath() {
5680
5680
 
5681
5681
  // src/lib/db/bundle-seed.ts
5682
5682
  async function bundleSeedWorker(seedFilePath, projectPath) {
5683
- const esbuild2 = await import("esbuild");
5683
+ const esbuild3 = await import("esbuild");
5684
5684
  const entryCode = `
5685
5685
  import { seed } from '${seedFilePath}'
5686
5686
 
@@ -5718,7 +5718,7 @@ async function bundleSeedWorker(seedFilePath, projectPath) {
5718
5718
  sourcemap: false,
5719
5719
  logLevel: "error"
5720
5720
  };
5721
- const result = await esbuild2.build(buildConfig);
5721
+ const result = await esbuild3.build(buildConfig);
5722
5722
  if (!result.outputFiles?.[0]) {
5723
5723
  throw new Error("Seed worker bundling failed: no output");
5724
5724
  }
@@ -6045,8 +6045,8 @@ init_constants2();
6045
6045
  function textLoaderPlugin() {
6046
6046
  return {
6047
6047
  name: "text-loader",
6048
- setup(build2) {
6049
- build2.onLoad({ filter: /edge-play\/src\/entry\.ts$/ }, async (args) => {
6048
+ setup(build3) {
6049
+ build3.onLoad({ filter: /edge-play\/src\/entry\.ts$/ }, async (args) => {
6050
6050
  const fs2 = await import("fs/promises");
6051
6051
  const text5 = await fs2.readFile(args.path, "utf8");
6052
6052
  return {
@@ -6054,7 +6054,7 @@ function textLoaderPlugin() {
6054
6054
  loader: "js"
6055
6055
  };
6056
6056
  });
6057
- build2.onLoad({ filter: /edge-play\/src\/routes\/root\.html$/ }, async (args) => {
6057
+ build3.onLoad({ filter: /edge-play\/src\/stub-entry\.ts$/ }, async (args) => {
6058
6058
  const fs2 = await import("fs/promises");
6059
6059
  const text5 = await fs2.readFile(args.path, "utf8");
6060
6060
  return {
@@ -6062,7 +6062,15 @@ function textLoaderPlugin() {
6062
6062
  loader: "js"
6063
6063
  };
6064
6064
  });
6065
- build2.onLoad({ filter: /templates\/sample-route\.ts$/ }, async (args) => {
6065
+ build3.onLoad({ filter: /edge-play\/src\/routes\/root\.html$/ }, async (args) => {
6066
+ const fs2 = await import("fs/promises");
6067
+ const text5 = await fs2.readFile(args.path, "utf8");
6068
+ return {
6069
+ contents: `export default ${JSON.stringify(text5)}`,
6070
+ loader: "js"
6071
+ };
6072
+ });
6073
+ build3.onLoad({ filter: /templates\/sample-route\.ts$/ }, async (args) => {
6066
6074
  const fs2 = await import("fs/promises");
6067
6075
  const text5 = await fs2.readFile(args.path, "utf8");
6068
6076
  return {
@@ -6176,8 +6184,8 @@ async function transpileRoute(filePath) {
6176
6184
  if (isBun() || !filePath.endsWith(".ts")) {
6177
6185
  return filePath;
6178
6186
  }
6179
- const esbuild2 = await import("esbuild");
6180
- const result = await esbuild2.build({
6187
+ const esbuild3 = await import("esbuild");
6188
+ const result = await esbuild3.build({
6181
6189
  entryPoints: [filePath],
6182
6190
  write: false,
6183
6191
  format: "esm",
@@ -6333,7 +6341,7 @@ function createEsbuildConfig(entryCode, paths, bundleConfig, customRoutesDir, op
6333
6341
  };
6334
6342
  }
6335
6343
  async function bundleBackend(config, options = {}) {
6336
- const esbuild2 = await import("esbuild");
6344
+ const esbuild3 = await import("esbuild");
6337
6345
  const { customRouteData, customRoutesDir } = await discoverCustomRoutes(config);
6338
6346
  const bundleConfig = {
6339
6347
  ...config,
@@ -6349,7 +6357,7 @@ async function bundleBackend(config, options = {}) {
6349
6357
  customRoutesDir,
6350
6358
  options
6351
6359
  );
6352
- const result = await esbuild2.build(buildConfig);
6360
+ const result = await esbuild3.build(buildConfig);
6353
6361
  if (!result.outputFiles?.[0]) {
6354
6362
  throw new Error("Backend bundling failed: no output");
6355
6363
  }
@@ -6632,14 +6640,14 @@ function formatDelta(bytes) {
6632
6640
  return `${arrow} ${value.toFixed(2)} ${unit}`;
6633
6641
  }
6634
6642
  function displayDeploymentDiff(options) {
6635
- const { diff, noChanges, build: build2, backend, integrations } = options;
6643
+ const { diff, noChanges, build: build3, backend, integrations } = options;
6636
6644
  if (noChanges) {
6637
6645
  logger.remark("No changes detected");
6638
6646
  logger.newLine();
6639
6647
  return;
6640
6648
  }
6641
6649
  const hasConfigChanges = Object.keys(diff).length > 0;
6642
- const buildChanged = build2?.changed === true;
6650
+ const buildChanged = build3?.changed === true;
6643
6651
  const backendChanged = backend?.changed === true;
6644
6652
  const forceBackend = backend?.forced;
6645
6653
  const schemaStatementCount = backend?.schemaStatementCount;
@@ -6675,8 +6683,8 @@ function displayDeploymentDiff(options) {
6675
6683
  }
6676
6684
  if (buildChanged) {
6677
6685
  logger.bold("Frontend", 1);
6678
- const previousSize = build2?.previousSize;
6679
- const currentSize = build2?.currentSize;
6686
+ const previousSize = build3?.previousSize;
6687
+ const currentSize = build3?.currentSize;
6680
6688
  if (previousSize !== void 0 && currentSize !== void 0) {
6681
6689
  logger.sizeChange("Build", previousSize, currentSize, formatSize, formatDelta, 2);
6682
6690
  } else if (currentSize !== void 0) {
@@ -9413,6 +9421,35 @@ import { existsSync as existsSync20 } from "fs";
9413
9421
  import { readFile as readFile4 } from "fs/promises";
9414
9422
  import { basename as basename2, join as join24, resolve as resolve8 } from "path";
9415
9423
 
9424
+ // src/lib/deploy/stub.ts
9425
+ import * as esbuild2 from "esbuild";
9426
+
9427
+ // ../edge-play/src/stub-entry.ts
9428
+ var stub_entry_default = "/**\n * Stub Worker for Static-Only Games\n *\n * Minimal worker that serves static assets with CORS headers.\n * Used for games that don't have custom backend routes.\n *\n * Features:\n * - Serves files from Workers Assets or R2 bucket binding\n * - Supports hybrid deployment strategy (small files via Assets, large files via R2)\n * - Adds CORS headers for cross-origin manifest fetching\n * - Handles OPTIONS preflight requests\n * - Returns 404 for /api/* routes (no backend deployed)\n * - Supports SPA routing (falls back to index.html)\n */\n\ntype AssetsFetcher = { fetch(request: Request): Promise<Response> }\ntype R2Bucket = { get(key: string): Promise<R2Object | null> }\ntype R2Object = { body: ReadableStream; httpMetadata?: { contentType?: string }; etag: string }\n\ninterface Env {\n ASSETS: AssetsFetcher | R2Bucket\n}\n\n/**\n * Detect if ASSETS binding is an R2 bucket or Workers Assets Fetcher\n */\nfunction isR2Bucket(assets: AssetsFetcher | R2Bucket): assets is R2Bucket {\n return 'get' in assets && typeof assets.get === 'function' && !('fetch' in assets)\n}\n\n/**\n * Serve from Workers Assets (default for files <25MB)\n */\nasync function serveFromAssets(request: Request, assets: AssetsFetcher): Promise<Response> {\n const response = await assets.fetch(request)\n\n if (response.status !== 404) {\n // Add CORS headers\n const modifiedResponse = new Response(response.body, response)\n modifiedResponse.headers.set('Access-Control-Allow-Origin', '*')\n modifiedResponse.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')\n return modifiedResponse\n }\n\n // SPA routing: if not a file request, try index.html\n const path = new URL(request.url).pathname\n if (path.includes('.')) {\n return response // File request not found\n }\n\n const indexUrl = new URL(request.url)\n indexUrl.pathname = '/index.html'\n const indexResponse = await assets.fetch(new Request(indexUrl.toString()))\n\n // Add CORS headers to index.html response\n const modifiedResponse = new Response(indexResponse.body, indexResponse)\n modifiedResponse.headers.set('Access-Control-Allow-Origin', '*')\n modifiedResponse.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')\n return modifiedResponse\n}\n\n/**\n * Serve from R2 bucket (for large files like Godot WASM)\n */\nasync function serveFromR2(request: Request, bucket: R2Bucket): Promise<Response> {\n const path = new URL(request.url).pathname\n const key = path === '/' ? 'index.html' : path.slice(1)\n\n const object = await bucket.get(key)\n\n if (object) {\n return new Response(object.body, {\n headers: {\n 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',\n // Use 1-hour caching - balances performance with update propagation\n // (Godot and other engines use non-hashed filenames)\n 'Cache-Control': key === 'index.html' ? 'no-cache' : 'public, max-age=3600',\n ETag: object.etag,\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',\n },\n })\n }\n\n // SPA routing: if not a file request, try index.html\n if (path.includes('.')) {\n return new Response('Not Found', { status: 404 })\n }\n\n const indexObject = await bucket.get('index.html')\n if (indexObject) {\n return new Response(indexObject.body, {\n headers: {\n 'Content-Type': 'text/html',\n 'Cache-Control': 'no-cache',\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',\n },\n })\n }\n\n return new Response('Not Found', { status: 404 })\n}\n\nexport default {\n async fetch(request: Request, env: Env): Promise<Response> {\n try {\n const url = new URL(request.url)\n\n // Handle CORS preflight\n if (request.method === 'OPTIONS') {\n const headers = new Headers()\n headers.set('Access-Control-Allow-Origin', '*')\n headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')\n const reqHeaders = request.headers.get('Access-Control-Request-Headers')\n if (reqHeaders) {\n headers.set('Access-Control-Allow-Headers', reqHeaders)\n }\n headers.set('Access-Control-Max-Age', '86400')\n return new Response(null, { status: 204, headers })\n }\n\n // No backend routes available\n if (url.pathname.startsWith('/api/')) {\n return new Response('No backend deployed', { status: 404 })\n }\n\n // Serve static assets (auto-detect Workers Assets vs R2)\n if (!env.ASSETS) {\n return new Response('ASSETS binding not configured', { status: 500 })\n }\n\n return isR2Bucket(env.ASSETS)\n ? await serveFromR2(request, env.ASSETS)\n : await serveFromAssets(request, env.ASSETS)\n } catch (error) {\n return new Response('Error: ' + (error as Error).message, { status: 500 })\n }\n },\n}\n";
9429
+
9430
+ // src/lib/deploy/stub.ts
9431
+ var stubEntryTemplate = stub_entry_default.toString();
9432
+ async function bundleStubWorker() {
9433
+ const result = await esbuild2.build({
9434
+ stdin: {
9435
+ contents: stubEntryTemplate,
9436
+ loader: "ts"
9437
+ },
9438
+ bundle: true,
9439
+ format: "esm",
9440
+ platform: "browser",
9441
+ target: "es2022",
9442
+ write: false,
9443
+ minify: false,
9444
+ sourcemap: false,
9445
+ logLevel: "error"
9446
+ });
9447
+ if (!result.outputFiles?.[0]) {
9448
+ throw new Error("Failed to bundle stub worker");
9449
+ }
9450
+ return result.outputFiles[0].text;
9451
+ }
9452
+
9416
9453
  // src/lib/deploy/utils.ts
9417
9454
  init_src2();
9418
9455
  function getDeploymentId(gameSlug) {
@@ -9616,6 +9653,17 @@ async function deployGame(context2, shouldUploadBuild, shouldDeployBackend) {
9616
9653
  deployedSecrets: deploymentPrep.secretKeys,
9617
9654
  deployedAt: (/* @__PURE__ */ new Date()).toISOString()
9618
9655
  };
9656
+ } else if (buildFile && !shouldDeployBackend) {
9657
+ const stubCode = await runStep(
9658
+ "Bundling stub worker",
9659
+ async () => bundleStubWorker(),
9660
+ "Stub worker bundled",
9661
+ { silent: true }
9662
+ );
9663
+ backendBundle = {
9664
+ code: stubCode,
9665
+ config: fullConfig || { name: "stub" }
9666
+ };
9619
9667
  }
9620
9668
  let game;
9621
9669
  if (buildFile) {
@@ -11736,6 +11784,7 @@ async function runDbResetRemote(options) {
11736
11784
  async function runDbResetLocal(options) {
11737
11785
  const workspace = getWorkspace();
11738
11786
  const dbDir = join30(workspace, CLI_DIRECTORIES.DATABASE);
11787
+ logger.newLine();
11739
11788
  if (!existsSync23(dbDir)) {
11740
11789
  logger.warn("No database found to reset");
11741
11790
  logger.newLine();
@@ -13611,7 +13660,6 @@ async function runBucketListLocal(options) {
13611
13660
  logger.newLine();
13612
13661
  return;
13613
13662
  }
13614
- logger.success(`Found ${files.length} file${files.length === 1 ? "" : "s"}`);
13615
13663
  if (options.prefix) {
13616
13664
  logger.data("Prefix", options.prefix, 1);
13617
13665
  }
package/dist/utils.js CHANGED
@@ -3521,6 +3521,14 @@ function textLoaderPlugin() {
3521
3521
  loader: "js"
3522
3522
  };
3523
3523
  });
3524
+ build2.onLoad({ filter: /edge-play\/src\/stub-entry\.ts$/ }, async (args) => {
3525
+ const fs2 = await import("fs/promises");
3526
+ const text = await fs2.readFile(args.path, "utf8");
3527
+ return {
3528
+ contents: `export default ${JSON.stringify(text)}`,
3529
+ loader: "js"
3530
+ };
3531
+ });
3524
3532
  build2.onLoad({ filter: /edge-play\/src\/routes\/root\.html$/ }, async (args) => {
3525
3533
  const fs2 = await import("fs/promises");
3526
3534
  const text = await fs2.readFile(args.path, "utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playcademy",
3
- "version": "0.14.12-alpha.1",
3
+ "version": "0.14.13",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {