@playcademy/sandbox 0.6.1-beta.5 → 0.6.1-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 +70 -18
  2. package/dist/server.js +70 -18
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1084,7 +1084,7 @@ var package_default;
1084
1084
  var init_package = __esm(() => {
1085
1085
  package_default = {
1086
1086
  name: "@playcademy/sandbox",
1087
- version: "0.6.1-beta.5",
1087
+ version: "0.6.1-beta.6",
1088
1088
  description: "Local development server for Playcademy game development",
1089
1089
  type: "module",
1090
1090
  exports: {
@@ -5689,6 +5689,14 @@ var init_schema = __esm(() => {
5689
5689
  platformServiceJwt: platformServiceJwtConfigSchema.optional(),
5690
5690
  uploadBucket: exports_external.string().optional(),
5691
5691
  queueIngressSecret: exports_external.string().optional()
5692
+ }).superRefine((config2, ctx) => {
5693
+ if (config2.isLocal && !config2.baseUrl) {
5694
+ ctx.addIssue({
5695
+ code: exports_external.ZodIssueCode.custom,
5696
+ path: ["baseUrl"],
5697
+ message: "baseUrl is required when isLocal is true"
5698
+ });
5699
+ }
5692
5700
  });
5693
5701
  });
5694
5702
 
@@ -27520,26 +27528,70 @@ var init_playcademy = __esm(() => {
27520
27528
  });
27521
27529
 
27522
27530
  // ../utils/src/tunnel.ts
27523
- async function getTunnelUrl() {
27524
- let response;
27531
+ function servicePort(service) {
27532
+ if (!service) {
27533
+ return null;
27534
+ }
27525
27535
  try {
27526
- response = await fetch(`${METRICS_BASE}/config`);
27536
+ const url = new URL(service);
27537
+ return url.port || (url.protocol === "https:" ? "443" : "80");
27527
27538
  } catch {
27528
- throw new Error("Local tunnel is not running. Start it with `bun dev` or `bun scripts/infra/tunnel.ts`.");
27539
+ return null;
27529
27540
  }
27530
- if (!response.ok) {
27531
- throw new Error(`Tunnel metrics endpoint returned ${response.status}`);
27541
+ }
27542
+ async function readTunnelIngress(port) {
27543
+ try {
27544
+ const response = await fetch(`http://127.0.0.1:${port}/config`, {
27545
+ signal: AbortSignal.timeout(1000)
27546
+ });
27547
+ if (!response.ok) {
27548
+ return null;
27549
+ }
27550
+ const data = await response.json();
27551
+ return data.config?.ingress ?? [];
27552
+ } catch {
27553
+ return null;
27554
+ }
27555
+ }
27556
+ function matchingHostname(rules, expectedPort) {
27557
+ for (const rule of rules ?? []) {
27558
+ if (rule.hostname && servicePort(rule.service) === expectedPort) {
27559
+ return rule.hostname;
27560
+ }
27561
+ }
27562
+ return null;
27563
+ }
27564
+ async function getTunnelUrl(platformBaseUrl) {
27565
+ const expectedPort = servicePort(platformBaseUrl);
27566
+ if (!expectedPort) {
27567
+ throw new Error(`Cannot resolve a tunnel for unparseable platform URL '${platformBaseUrl}'`);
27568
+ }
27569
+ const cachedPort = matchedPorts.get(expectedPort);
27570
+ if (cachedPort !== undefined) {
27571
+ const hostname = matchingHostname(await readTunnelIngress(cachedPort), expectedPort);
27572
+ if (hostname) {
27573
+ return `https://${hostname}`;
27574
+ }
27575
+ matchedPorts.delete(expectedPort);
27532
27576
  }
27533
- const data = await response.json();
27534
- const hostname = data.config.ingress.find((r) => r.hostname)?.hostname;
27535
- if (!hostname) {
27536
- throw new Error("Tunnel is running but no hostname found in ingress config");
27577
+ const ports = Array.from({ length: TUNNEL_METRICS_PORT_RANGE }, (_, offset) => TUNNEL_METRICS_PORT + offset);
27578
+ const results = await Promise.all(ports.map(async (port) => ({ port, rules: await readTunnelIngress(port) })));
27579
+ for (const { port, rules } of results) {
27580
+ const hostname = matchingHostname(rules, expectedPort);
27581
+ if (hostname) {
27582
+ matchedPorts.set(expectedPort, port);
27583
+ return `https://${hostname}`;
27584
+ }
27537
27585
  }
27538
- return `https://${hostname}`;
27586
+ const foreign = results.flatMap(({ rules }) => (rules ?? []).filter((rule) => rule.hostname).map((rule) => `${rule.hostname} -> ${rule.service}`));
27587
+ if (foreign.length > 0) {
27588
+ throw new Error(`A local tunnel is running, but none route to the platform at ${platformBaseUrl} (found: ${foreign.join(", ")}). ${TUNNEL_START_HINT}`);
27589
+ }
27590
+ throw new Error(`Local tunnel is not running. ${TUNNEL_START_HINT}`);
27539
27591
  }
27540
- var TUNNEL_METRICS_PORT = 20241, METRICS_BASE;
27592
+ var TUNNEL_METRICS_PORT = 20241, TUNNEL_METRICS_PORT_RANGE = 10, TUNNEL_START_HINT = "Start this platform's tunnel with `bun dev` or `bun run tunnel`.", matchedPorts;
27541
27593
  var init_tunnel = __esm(() => {
27542
- METRICS_BASE = `http://127.0.0.1:${TUNNEL_METRICS_PORT}`;
27594
+ matchedPorts = new Map;
27543
27595
  });
27544
27596
 
27545
27597
  // ../utils/src/stages.ts
@@ -28183,13 +28235,13 @@ class DeployService {
28183
28235
  return this.deps.config.baseUrl;
28184
28236
  }
28185
28237
  try {
28186
- return await getTunnelUrl();
28187
- } catch {
28238
+ return await getTunnelUrl(this.deps.config.baseUrl);
28239
+ } catch (error) {
28188
28240
  setAttributes({
28189
28241
  "app.deploy.tunnel_available": false,
28190
28242
  "app.deploy.failure_kind": "local_tunnel_unavailable"
28191
28243
  });
28192
- throw new ValidationError("Local tunnel is not running. Ensure cloudflared is installed (`brew install cloudflared`) and the tunnel DevCommand started successfully.");
28244
+ throw new ValidationError(errorMessage(error));
28193
28245
  }
28194
28246
  }
28195
28247
  async deployToCloudflare(args2) {
@@ -140714,7 +140766,7 @@ var init_session_controller = __esm(() => {
140714
140766
  let baseUrl;
140715
140767
  if (ctx.config.isLocal) {
140716
140768
  try {
140717
- baseUrl = await getTunnelUrl();
140769
+ baseUrl = await getTunnelUrl(ctx.config.baseUrl);
140718
140770
  } catch {}
140719
140771
  }
140720
140772
  return { token, exp, baseUrl };
package/dist/server.js CHANGED
@@ -1083,7 +1083,7 @@ var package_default;
1083
1083
  var init_package = __esm(() => {
1084
1084
  package_default = {
1085
1085
  name: "@playcademy/sandbox",
1086
- version: "0.6.1-beta.5",
1086
+ version: "0.6.1-beta.6",
1087
1087
  description: "Local development server for Playcademy game development",
1088
1088
  type: "module",
1089
1089
  exports: {
@@ -5688,6 +5688,14 @@ var init_schema = __esm(() => {
5688
5688
  platformServiceJwt: platformServiceJwtConfigSchema.optional(),
5689
5689
  uploadBucket: exports_external.string().optional(),
5690
5690
  queueIngressSecret: exports_external.string().optional()
5691
+ }).superRefine((config2, ctx) => {
5692
+ if (config2.isLocal && !config2.baseUrl) {
5693
+ ctx.addIssue({
5694
+ code: exports_external.ZodIssueCode.custom,
5695
+ path: ["baseUrl"],
5696
+ message: "baseUrl is required when isLocal is true"
5697
+ });
5698
+ }
5691
5699
  });
5692
5700
  });
5693
5701
 
@@ -27519,26 +27527,70 @@ var init_playcademy = __esm(() => {
27519
27527
  });
27520
27528
 
27521
27529
  // ../utils/src/tunnel.ts
27522
- async function getTunnelUrl() {
27523
- let response;
27530
+ function servicePort(service) {
27531
+ if (!service) {
27532
+ return null;
27533
+ }
27524
27534
  try {
27525
- response = await fetch(`${METRICS_BASE}/config`);
27535
+ const url = new URL(service);
27536
+ return url.port || (url.protocol === "https:" ? "443" : "80");
27526
27537
  } catch {
27527
- throw new Error("Local tunnel is not running. Start it with `bun dev` or `bun scripts/infra/tunnel.ts`.");
27538
+ return null;
27528
27539
  }
27529
- if (!response.ok) {
27530
- throw new Error(`Tunnel metrics endpoint returned ${response.status}`);
27540
+ }
27541
+ async function readTunnelIngress(port) {
27542
+ try {
27543
+ const response = await fetch(`http://127.0.0.1:${port}/config`, {
27544
+ signal: AbortSignal.timeout(1000)
27545
+ });
27546
+ if (!response.ok) {
27547
+ return null;
27548
+ }
27549
+ const data = await response.json();
27550
+ return data.config?.ingress ?? [];
27551
+ } catch {
27552
+ return null;
27553
+ }
27554
+ }
27555
+ function matchingHostname(rules, expectedPort) {
27556
+ for (const rule of rules ?? []) {
27557
+ if (rule.hostname && servicePort(rule.service) === expectedPort) {
27558
+ return rule.hostname;
27559
+ }
27560
+ }
27561
+ return null;
27562
+ }
27563
+ async function getTunnelUrl(platformBaseUrl) {
27564
+ const expectedPort = servicePort(platformBaseUrl);
27565
+ if (!expectedPort) {
27566
+ throw new Error(`Cannot resolve a tunnel for unparseable platform URL '${platformBaseUrl}'`);
27567
+ }
27568
+ const cachedPort = matchedPorts.get(expectedPort);
27569
+ if (cachedPort !== undefined) {
27570
+ const hostname = matchingHostname(await readTunnelIngress(cachedPort), expectedPort);
27571
+ if (hostname) {
27572
+ return `https://${hostname}`;
27573
+ }
27574
+ matchedPorts.delete(expectedPort);
27531
27575
  }
27532
- const data = await response.json();
27533
- const hostname = data.config.ingress.find((r) => r.hostname)?.hostname;
27534
- if (!hostname) {
27535
- throw new Error("Tunnel is running but no hostname found in ingress config");
27576
+ const ports = Array.from({ length: TUNNEL_METRICS_PORT_RANGE }, (_, offset) => TUNNEL_METRICS_PORT + offset);
27577
+ const results = await Promise.all(ports.map(async (port) => ({ port, rules: await readTunnelIngress(port) })));
27578
+ for (const { port, rules } of results) {
27579
+ const hostname = matchingHostname(rules, expectedPort);
27580
+ if (hostname) {
27581
+ matchedPorts.set(expectedPort, port);
27582
+ return `https://${hostname}`;
27583
+ }
27536
27584
  }
27537
- return `https://${hostname}`;
27585
+ const foreign = results.flatMap(({ rules }) => (rules ?? []).filter((rule) => rule.hostname).map((rule) => `${rule.hostname} -> ${rule.service}`));
27586
+ if (foreign.length > 0) {
27587
+ throw new Error(`A local tunnel is running, but none route to the platform at ${platformBaseUrl} (found: ${foreign.join(", ")}). ${TUNNEL_START_HINT}`);
27588
+ }
27589
+ throw new Error(`Local tunnel is not running. ${TUNNEL_START_HINT}`);
27538
27590
  }
27539
- var TUNNEL_METRICS_PORT = 20241, METRICS_BASE;
27591
+ var TUNNEL_METRICS_PORT = 20241, TUNNEL_METRICS_PORT_RANGE = 10, TUNNEL_START_HINT = "Start this platform's tunnel with `bun dev` or `bun run tunnel`.", matchedPorts;
27540
27592
  var init_tunnel = __esm(() => {
27541
- METRICS_BASE = `http://127.0.0.1:${TUNNEL_METRICS_PORT}`;
27593
+ matchedPorts = new Map;
27542
27594
  });
27543
27595
 
27544
27596
  // ../utils/src/stages.ts
@@ -28182,13 +28234,13 @@ class DeployService {
28182
28234
  return this.deps.config.baseUrl;
28183
28235
  }
28184
28236
  try {
28185
- return await getTunnelUrl();
28186
- } catch {
28237
+ return await getTunnelUrl(this.deps.config.baseUrl);
28238
+ } catch (error) {
28187
28239
  setAttributes({
28188
28240
  "app.deploy.tunnel_available": false,
28189
28241
  "app.deploy.failure_kind": "local_tunnel_unavailable"
28190
28242
  });
28191
- throw new ValidationError("Local tunnel is not running. Ensure cloudflared is installed (`brew install cloudflared`) and the tunnel DevCommand started successfully.");
28243
+ throw new ValidationError(errorMessage(error));
28192
28244
  }
28193
28245
  }
28194
28246
  async deployToCloudflare(args2) {
@@ -140713,7 +140765,7 @@ var init_session_controller = __esm(() => {
140713
140765
  let baseUrl;
140714
140766
  if (ctx.config.isLocal) {
140715
140767
  try {
140716
- baseUrl = await getTunnelUrl();
140768
+ baseUrl = await getTunnelUrl(ctx.config.baseUrl);
140717
140769
  } catch {}
140718
140770
  }
140719
140771
  return { token, exp, baseUrl };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.6.1-beta.5",
3
+ "version": "0.6.1-beta.6",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {