castle-web-cli 0.4.169 → 0.4.170

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 (79) hide show
  1. package/dist/agent.js +23 -13
  2. package/dist/atomicFile.d.ts +29 -0
  3. package/dist/atomicFile.js +120 -0
  4. package/dist/castle-host/host.js +37 -0
  5. package/dist/get-deck.js +2 -11
  6. package/dist/headlessCover.d.ts +4 -0
  7. package/dist/headlessCover.js +7 -5
  8. package/dist/ide.js +14 -6
  9. package/dist/index.js +10 -6
  10. package/dist/init.d.ts +1 -1
  11. package/dist/init.js +11 -1
  12. package/dist/remix.js +2 -14
  13. package/dist/save-deck.d.ts +3 -0
  14. package/dist/save-deck.js +40 -73
  15. package/dist/saveCover.d.ts +18 -0
  16. package/dist/saveCover.js +61 -0
  17. package/dist/serve.js +18 -5
  18. package/dist/serveSecurity.d.ts +11 -1
  19. package/dist/serveSecurity.js +38 -4
  20. package/dist/shell/assets/index-BvQmVwlO.css +1 -0
  21. package/dist/shell/assets/index-CV5sBby1.js +445 -0
  22. package/dist/shell/index.html +2 -2
  23. package/dist/versionStore.js +13 -4
  24. package/kits/base/CLAUDE.md +9 -0
  25. package/kits/base/castle.json +16 -11
  26. package/kits/base/sdk/README.md +66 -1
  27. package/kits/base/sdk/commands.d.ts +23 -1
  28. package/kits/base/sdk/commands.js +9 -0
  29. package/kits/base/sdk/lifecycle.d.ts +20 -0
  30. package/kits/base/sdk/lifecycle.js +7 -1
  31. package/kits/base/sdk/resumeState.d.ts +2 -0
  32. package/kits/base/sdk/resumeState.js +84 -0
  33. package/kits/base/sdk/runtime.js +65 -0
  34. package/kits/base/sdk/transport.d.ts +3 -0
  35. package/kits/base/sdk/transport.js +39 -5
  36. package/kits/base/sdk/unloadHandshake.d.ts +6 -0
  37. package/kits/base/sdk/unloadHandshake.js +47 -0
  38. package/kits/multiplayer-2d/castle.json +3 -3
  39. package/kits/multiplayer-2d/package-lock.json +0 -27
  40. package/kits/multiplayer-2d/package.json +0 -1
  41. package/kits/multiplayer-3d/castle.json +4 -4
  42. package/kits/multiplayer-3d/package-lock.json +0 -27
  43. package/kits/multiplayer-3d/package.json +0 -1
  44. package/kits/physics-2d/CLAUDE.md +75 -43
  45. package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
  46. package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
  47. package/kits/physics-2d/castle.json +6 -6
  48. package/kits/physics-2d/editors/PxArtEditor.jsx +83 -180
  49. package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
  50. package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
  51. package/kits/physics-2d/editors/overlayCanvas.js +77 -0
  52. package/kits/physics-2d/editors/pathOverlay.js +5 -13
  53. package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
  54. package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
  55. package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
  56. package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
  57. package/kits/physics-2d/editors/styleEditor.module.css +23 -4
  58. package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
  59. package/kits/physics-2d/engine/SceneUI.jsx +6 -12
  60. package/kits/physics-2d/engine/icons.js +56 -0
  61. package/kits/physics-2d/engine/panelSorting.jsx +7 -14
  62. package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
  63. package/kits/physics-2d/engine/physics/controls.js +76 -12
  64. package/kits/physics-2d/engine/scene.js +47 -9
  65. package/kits/physics-2d/engine/ui.jsx +13 -125
  66. package/kits/physics-2d/engine/ui.module.css +53 -28
  67. package/kits/physics-2d/package-lock.json +0 -29
  68. package/kits/physics-2d/package.json +3 -1
  69. package/kits/physics-2d/pnpm-lock.yaml +1 -17
  70. package/kits/physics-3d/castle.json +5 -5
  71. package/kits/physics-3d/package-lock.json +0 -29
  72. package/kits/physics-3d/package.json +0 -1
  73. package/kits/physics-3d/pnpm-lock.yaml +0 -17
  74. package/kits/turn-based/castle.json +3 -3
  75. package/kits/turn-based/package-lock.json +0 -27
  76. package/kits/turn-based/package.json +0 -1
  77. package/package.json +5 -4
  78. package/dist/shell/assets/index-CVHj503j.css +0 -1
  79. package/dist/shell/assets/index-x_QkP3Xq.js +0 -444
package/dist/save-deck.js CHANGED
@@ -7,7 +7,8 @@ import * as api from './api.js';
7
7
  import * as config from './config.js';
8
8
  import { bundleProject } from './bundle.js';
9
9
  import { resolveServerConfig } from './castleJson.js';
10
- import { COVER_FILE } from './ide.js';
10
+ import { selectSaveCover } from './saveCover.js';
11
+ import { COVER_FILE } from './localPaths.js';
11
12
  import { buildServerBundle } from './serverBundle.js';
12
13
  // `imports/` is derived state like node_modules: a dependency's files belong to
13
14
  // the deck that published them, and castle.json's import pins are enough to fetch
@@ -20,6 +21,7 @@ export const SOURCE_ARCHIVE_EXCLUDES = ['node_modules', 'dist', '.castle', '.git
20
21
  // adding machine output to interactive local saves. Old CLIs ignore the env, so releases remain
21
22
  // independently deployable.
22
23
  export const SAVE_STAGE_TELEMETRY_PREFIX = 'CASTLE_SAVE_TELEMETRY ';
24
+ export const SAVE_COVER_SELECTION_TELEMETRY_PREFIX = 'CASTLE_SAVE_COVER_SELECTION ';
23
25
  function emitSaveStage(stage, outcome, startedAtMs, fields = {}) {
24
26
  if (process.env.CASTLE_SAVE_STAGE_TELEMETRY !== '1')
25
27
  return;
@@ -90,6 +92,21 @@ export async function archiveSource(projectDir) {
90
92
  }
91
93
  }
92
94
  }
95
+ // Written to tmp and read or untarred out of there, never moved out: in a sandbox
96
+ // the deck is on a different filesystem from tmp, and a rename across that is EXDEV.
97
+ // The caller owns the file it gets back.
98
+ export async function downloadSourceArchive(source, namePrefix) {
99
+ console.log(`Fetching ${source.archiveUrl}`);
100
+ const res = await fetch(source.archiveUrl, { signal: AbortSignal.timeout(60000) });
101
+ if (!res.ok) {
102
+ throw new Error(`Archive fetch failed: HTTP ${res.status}`);
103
+ }
104
+ const buf = Buffer.from(await res.arrayBuffer());
105
+ console.log(`Archive: ${(buf.length / 1024).toFixed(1)}KB (updated ${source.updatedAt})`);
106
+ const file = path.join(os.tmpdir(), `${namePrefix}-${nanoid(8)}.tar.gz`);
107
+ fs.writeFileSync(file, buf);
108
+ return file;
109
+ }
93
110
  async function uploadPresignedFile(uploadConfig, file, contentType, timeoutMs, label) {
94
111
  const formData = new FormData();
95
112
  formData.append('Content-Type', contentType);
@@ -166,83 +183,24 @@ async function publishServerBundle(projectDir, deckId) {
166
183
  writePublishedServerBundleState(projectDir, { deckId, sha256: bundle.sha256 });
167
184
  console.log(`Server bundle published${saved ? ` as ${saved.version}` : ''} (sha256 ${bundle.sha256}).`);
168
185
  }
169
- // Publish the deck's local cover, if it has one, and return the file id for the
170
- // card. The editor writes `preview.png` as you play (see the serve's cover
171
- // endpoint); this is the moment it becomes public.
172
- //
173
- // Never fatal: a deck that saves but whose cover upload failed is a deck that
174
- // saved. Losing the push over a thumbnail would be the wrong trade.
175
- // The deck's own serve, if one is running -- the URL a headless cover capture
176
- // loads. No serve means no deck to photograph, which is a normal state (a save
177
- // from a script), not an error.
178
- async function runningServeUrl(projectDir) {
179
- try {
180
- const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
181
- const parsed = JSON.parse(raw);
182
- const port = parsed.port;
183
- if (process.env.CASTLE_SANDBOX_ID && typeof port === 'number') {
184
- try {
185
- const response = await fetch(`http://localhost:${port}/__castle/browser-content-grant`, { signal: AbortSignal.timeout(2_000) });
186
- const body = (await response.json());
187
- if (response.ok && typeof body.url === 'string')
188
- return body.url;
189
- }
190
- catch {
191
- // Fall through to the existing best-effort local capture path.
192
- }
193
- }
194
- if (typeof parsed.contentPlayUrl === 'string')
195
- return parsed.contentPlayUrl;
196
- return typeof port === 'number' ? `http://localhost:${port}` : null;
197
- }
198
- catch {
199
- return null;
200
- }
201
- }
202
- // Photograph the running deck with a real browser, into a scratch file.
203
- //
204
- // This OUTRANKS the editor's `preview.png`, and deliberately: both are the
205
- // AUTOMATIC cover (a creator's hand-picked one lives in the card's custom slot
206
- // and outranks both), so the question is only which automatic capture is better
207
- // -- and the in-page one silently drops a WebGL canvas that lacks
208
- // `preserveDrawingBuffer`, plus anything using `filter`/`clip-path`/`mask`. A
209
- // browser compositing its own frame has no such list.
210
- //
211
- // `preview.png` does have one real advantage -- it is whatever the creator last
212
- // had on screen, where this is a cold load -- so it stays the fallback. But a
213
- // cover that is missing the game beats a cover of the opening seconds only if
214
- // you never look at it, and nobody looks at an unattended capture. Writing to a
215
- // scratch file rather than over `preview.png` keeps that fallback intact.
216
- async function captureAutomaticCover(projectDir) {
217
- const serveUrl = await runningServeUrl(projectDir);
218
- if (!serveUrl)
219
- return null;
220
- const outPath = path.join(projectDir, '.castle', 'headless-cover.png');
221
- const { captureCoverHeadless } = await import('./headlessCover.js');
222
- const result = await captureCoverHeadless({
223
- serveUrl,
224
- outPath,
225
- onProgress: (message) => console.log(message),
226
- });
227
- if (!result.ok) {
228
- // Never fatal, same as the upload below: a deck that saved without a cover
229
- // is a deck that saved.
230
- console.log(`Cover capture skipped: ${result.error}`);
231
- return null;
232
- }
233
- return outPath;
234
- }
235
186
  async function uploadLocalCover(projectDir) {
236
187
  try {
237
- const captured = await captureAutomaticCover(projectDir);
238
- const coverPath = captured ?? path.join(projectDir, COVER_FILE);
239
- if (captured)
188
+ const selected = await selectSaveCover(projectDir, { onProgress: (message) => console.log(message) });
189
+ if (process.env.CASTLE_SAVE_STAGE_TELEMETRY === '1') {
190
+ console.log(`${SAVE_COVER_SELECTION_TELEMETRY_PREFIX}${JSON.stringify({
191
+ event: 'castle_web_save_cover_selected',
192
+ outcome: selected.outcome,
193
+ capture_outcome: selected.captureOutcome,
194
+ duration_ms: selected.durationMs,
195
+ })}`);
196
+ }
197
+ if (selected.outcome === 'remote' || selected.outcome === 'local_browser')
240
198
  console.log('Captured a cover from the running deck.');
241
- else if (fs.existsSync(coverPath))
199
+ else if (selected.outcome === 'editor')
242
200
  console.log('Using the cover captured in the editor.');
243
- if (!fs.existsSync(coverPath))
201
+ if (!selected.path)
244
202
  return null;
245
- const base64 = fs.readFileSync(coverPath).toString('base64');
203
+ const base64 = fs.readFileSync(selected.path).toString('base64');
246
204
  const file = await api.uploadBase64(base64, COVER_FILE);
247
205
  return file?.fileId ?? null;
248
206
  }
@@ -411,6 +369,15 @@ export async function saveDeck(dir, opts = {}) {
411
369
  else if (code === 'DECK_INVALID_PERMISSIONS') {
412
370
  console.error('You do not have permission to save this deck.');
413
371
  }
372
+ else if (code === 'DECK_DELETED') {
373
+ // The deck this workspace points at was deleted somewhere else -- the app,
374
+ // the cloud editor, another machine. The server refuses the write rather
375
+ // than publishing into a deck nobody can reach, so say what happened and
376
+ // what recovers the code, which is still here and untouched.
377
+ console.error(`This deck was deleted (${castleJson?.deckId}), so it can no longer be saved to.\n` +
378
+ 'Your files are unchanged. To publish them as a new deck, remove the ' +
379
+ '"deckId" and "cardId" fields from castle.json and save again.');
380
+ }
414
381
  else {
415
382
  throw e;
416
383
  }
@@ -0,0 +1,18 @@
1
+ export type SaveCoverOutcome = 'remote' | 'local_browser' | 'editor' | 'none';
2
+ export interface SaveCoverSelection {
3
+ outcome: SaveCoverOutcome;
4
+ captureOutcome: 'succeeded' | 'timed_out' | 'capture_failed' | 'not_attempted';
5
+ path?: string;
6
+ durationMs: number;
7
+ }
8
+ export interface SaveCoverOptions {
9
+ totalBudgetMs?: number;
10
+ fetch?: typeof globalThis.fetch;
11
+ now?: () => number;
12
+ onProgress?: (message: string) => void;
13
+ }
14
+ export declare function runningServeUrl(projectDir: string, options?: Pick<SaveCoverOptions, 'fetch'> & {
15
+ signal?: AbortSignal;
16
+ }): Promise<string | null>;
17
+ /** Select exactly the automatic cover that save-deck would upload, without uploading it. */
18
+ export declare function selectSaveCover(projectDir: string, options?: SaveCoverOptions): Promise<SaveCoverSelection>;
@@ -0,0 +1,61 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { COVER_FILE } from './localPaths.js';
4
+ import { contentGrantFromBrowserUrl } from './native/playtest-remote.js';
5
+ export async function runningServeUrl(projectDir, options = {}) {
6
+ try {
7
+ const raw = fs.readFileSync(path.join(projectDir, '.castle', 'serve.json'), 'utf8');
8
+ const parsed = JSON.parse(raw);
9
+ const port = parsed.port;
10
+ if (process.env.CASTLE_SANDBOX_ID && typeof port === 'number') {
11
+ try {
12
+ const response = await (options.fetch ?? globalThis.fetch)(`http://localhost:${port}/__castle/browser-content-grant`, { signal: options.signal ? AbortSignal.any([AbortSignal.timeout(2_000), options.signal]) : AbortSignal.timeout(2_000) });
13
+ const body = (await response.json());
14
+ if (response.ok && typeof body.url === 'string')
15
+ return body.url;
16
+ }
17
+ catch {
18
+ // The serve metadata below remains a valid local fallback.
19
+ }
20
+ }
21
+ if (typeof parsed.contentPlayUrl === 'string')
22
+ return parsed.contentPlayUrl;
23
+ return typeof port === 'number' ? `http://localhost:${port}` : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** Select exactly the automatic cover that save-deck would upload, without uploading it. */
30
+ export async function selectSaveCover(projectDir, options = {}) {
31
+ const now = options.now ?? Date.now;
32
+ const startedAt = now();
33
+ const totalBudgetMs = options.totalBudgetMs ?? 15_000;
34
+ const deadlineMs = startedAt + totalBudgetMs;
35
+ const signal = AbortSignal.timeout(Math.max(1, totalBudgetMs));
36
+ const serveUrl = await runningServeUrl(projectDir, { ...options, signal });
37
+ const outPath = path.join(projectDir, '.castle', 'headless-cover.png');
38
+ const isRemote = Boolean(serveUrl && contentGrantFromBrowserUrl(serveUrl));
39
+ let captureOutcome = 'not_attempted';
40
+ // If cloud grant discovery consumed the caller budget, do not reinterpret its localhost
41
+ // metadata fallback as permission to start a new 60-second local-browser attempt.
42
+ if (serveUrl && !signal.aborted) {
43
+ const { captureCoverHeadless } = await import('./headlessCover.js');
44
+ const result = await captureCoverHeadless({
45
+ serveUrl,
46
+ outPath,
47
+ onProgress: options.onProgress,
48
+ ...(isRemote ? { remoteBudgetMs: Math.max(1, deadlineMs - now()), signal } : {}),
49
+ });
50
+ captureOutcome = result.outcome ?? (result.ok ? 'succeeded' : 'capture_failed');
51
+ if (result.ok && fs.existsSync(outPath)) {
52
+ return { outcome: isRemote ? 'remote' : 'local_browser', captureOutcome: 'succeeded', path: outPath, durationMs: Math.max(0, now() - startedAt) };
53
+ }
54
+ }
55
+ if (process.env.CASTLE_SANDBOX_ID && signal.aborted)
56
+ captureOutcome = 'timed_out';
57
+ const localPath = path.join(projectDir, COVER_FILE);
58
+ return fs.existsSync(localPath)
59
+ ? { outcome: 'editor', captureOutcome, path: localPath, durationMs: Math.max(0, now() - startedAt) }
60
+ : { outcome: 'none', captureOutcome, durationMs: Math.max(0, now() - startedAt) };
61
+ }
package/dist/serve.js CHANGED
@@ -762,16 +762,25 @@ export async function serve(dir, options = {}) {
762
762
  : options.host === '::'
763
763
  ? '::1'
764
764
  : options.host;
765
- // Content origins follow --host: a serve bound to a tailnet/LAN address is
766
- // being viewed from another device, where loopback is that device's own.
767
- const contentBindHost = !options.host ||
765
+ // Content proxies must listen where a remote browser can reach them. Binding
766
+ // the shell to 0.0.0.0 / :: means a phone on LAN/tailnet opens the shell; its
767
+ // content iframes need those same interfaces — the phone's 127.0.0.1 is itself.
768
+ const contentListenHost = options.host === '0.0.0.0' || options.host === '::'
769
+ ? options.host
770
+ : !options.host || options.host === 'localhost'
771
+ ? '127.0.0.1'
772
+ : options.host;
773
+ // Playtest / serve.json still advertise loopback: Chromium on this machine
774
+ // cannot navigate to 0.0.0.0, and same-machine Safari keeps the prior origins.
775
+ // Remote browsers get the Host they used via ServeSecurity.contentOrigins.
776
+ const contentAdvertiseHost = !options.host ||
768
777
  options.host === 'localhost' ||
769
778
  options.host === '0.0.0.0' ||
770
779
  options.host === '::'
771
780
  ? '127.0.0.1'
772
781
  : options.host;
773
- security.setLocalContentHost(contentBindHost);
774
- const localContentServers = await startLocalContentServers(contentBindHost, upstreamHost, port, security, new Set([port]));
782
+ security.setLocalContentHost(contentAdvertiseHost);
783
+ const localContentServers = await startLocalContentServers(contentListenHost, upstreamHost, port, security, new Set([port]));
775
784
  process.on('exit', () => {
776
785
  for (const server of localContentServers)
777
786
  server.close();
@@ -1318,6 +1327,10 @@ function trustedBridgeContentOrigin(security, request, role, value) {
1318
1327
  return null;
1319
1328
  if (origin === security.localContentOrigin(role) || origin === request.headers.origin)
1320
1329
  return origin;
1330
+ // Phone / LAN / tailnet: contentOrigins remints against the shell Host header.
1331
+ const expected = security.contentOrigins(request)[role === 'edit' ? 'editOrigin' : 'playOrigin'];
1332
+ if (origin === expected)
1333
+ return origin;
1321
1334
  const url = new URL(origin);
1322
1335
  const synthetic = {
1323
1336
  headers: { ...request.headers, host: url.host },
@@ -50,7 +50,17 @@ export declare class ServeSecurity {
50
50
  editOrigin: string;
51
51
  playOrigin: string;
52
52
  };
53
- localContentOrigin(role: ContentRole): string;
53
+ /**
54
+ * Origin a browser should use for a local content role.
55
+ *
56
+ * When the shell was opened via a LAN / tailnet hostname (or any non-loopback
57
+ * Host the serve already accepted), content iframes must use that same host —
58
+ * `127.0.0.1` is the *phone's* loopback, not the machine running the serve.
59
+ * Loopback / wildcard request hosts keep the advertised localContentHost
60
+ * (playtest and same-machine Safari).
61
+ */
62
+ localContentOrigin(role: ContentRole, requestHost?: string): string;
63
+ private contentHostForBrowser;
54
64
  /** A short-lived play URL for one shared-browser admission attempt. */
55
65
  browserContentOrigin(role?: ContentRole): string | null;
56
66
  localContentProxyHeaders(role: ContentRole): Record<string, string>;
@@ -41,6 +41,20 @@ function hostWithoutPort(host) {
41
41
  return host.slice(0, host.indexOf(']') + 1).toLowerCase();
42
42
  return host.split(':')[0].toLowerCase();
43
43
  }
44
+ /** Strip brackets so callers can compare / classify the bare hostname. */
45
+ function bareHostname(host) {
46
+ const h = hostWithoutPort(host).toLowerCase();
47
+ return h.startsWith('[') && h.endsWith(']') ? h.slice(1, -1) : h;
48
+ }
49
+ /** IPv6 literals need brackets in an origin URL. */
50
+ function formatHostForOrigin(host) {
51
+ const bare = bareHostname(host);
52
+ return bare.includes(':') ? `[${bare}]` : bare;
53
+ }
54
+ function isLoopbackHostname(host) {
55
+ const bare = bareHostname(host);
56
+ return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1';
57
+ }
44
58
  function requestOrigin(req) {
45
59
  const forwarded = req.headers['x-forwarded-proto'];
46
60
  const protocol = typeof forwarded === 'string' ? forwarded.split(',')[0] : 'http';
@@ -80,7 +94,8 @@ export class ServeSecurity {
80
94
  throw new Error('edit and play content ports must be distinct');
81
95
  this.localContentPorts = ports;
82
96
  }
83
- // The host a browser should use to reach deck content; see contentBindHost.
97
+ // Loopback host advertised to same-machine consumers (playtest, serve.json).
98
+ // Remote browsers get the Host they used; see localContentOrigin(role, host).
84
99
  setLocalContentHost(host) {
85
100
  this.localContentHost = host;
86
101
  }
@@ -276,17 +291,36 @@ window.fetch=function(input,init){
276
291
  // Safari on macOS 15 asks DNS to resolve `*.localhost`, where it fails.
277
292
  // Dedicated loopback ports are still distinct browser origins and need no DNS.
278
293
  if (!cloud || !contentDomain)
279
- return this.localContentOrigin(role);
294
+ return this.localContentOrigin(role, host);
280
295
  const label = this.mintContentLabel(role, sandboxId, expiresAtSeconds);
281
296
  return `https://${label}.${contentDomain}`;
282
297
  };
283
298
  return { editOrigin: originFor('edit'), playOrigin: originFor('play') };
284
299
  }
285
- localContentOrigin(role) {
300
+ /**
301
+ * Origin a browser should use for a local content role.
302
+ *
303
+ * When the shell was opened via a LAN / tailnet hostname (or any non-loopback
304
+ * Host the serve already accepted), content iframes must use that same host —
305
+ * `127.0.0.1` is the *phone's* loopback, not the machine running the serve.
306
+ * Loopback / wildcard request hosts keep the advertised localContentHost
307
+ * (playtest and same-machine Safari).
308
+ */
309
+ localContentOrigin(role, requestHost) {
286
310
  const port = this.localContentPorts?.[role];
287
311
  if (!port)
288
312
  throw new Error('local content ports are not configured');
289
- return `http://${this.localContentHost}:${port}`;
313
+ const host = this.contentHostForBrowser(requestHost);
314
+ return `http://${formatHostForOrigin(host)}:${port}`;
315
+ }
316
+ contentHostForBrowser(requestHost) {
317
+ if (!requestHost)
318
+ return this.localContentHost;
319
+ const bare = bareHostname(requestHost);
320
+ if (!bare || bare === '0.0.0.0' || bare === '::' || isLoopbackHostname(bare)) {
321
+ return this.localContentHost;
322
+ }
323
+ return bare;
290
324
  }
291
325
  /** A short-lived play URL for one shared-browser admission attempt. */
292
326
  browserContentOrigin(role = 'play') {