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.
- package/dist/agent.js +23 -13
- package/dist/atomicFile.d.ts +29 -0
- package/dist/atomicFile.js +120 -0
- package/dist/castle-host/host.js +37 -0
- package/dist/get-deck.js +2 -11
- package/dist/headlessCover.d.ts +4 -0
- package/dist/headlessCover.js +7 -5
- package/dist/ide.js +14 -6
- package/dist/index.js +10 -6
- package/dist/init.d.ts +1 -1
- package/dist/init.js +11 -1
- package/dist/remix.js +2 -14
- package/dist/save-deck.d.ts +3 -0
- package/dist/save-deck.js +40 -73
- package/dist/saveCover.d.ts +18 -0
- package/dist/saveCover.js +61 -0
- package/dist/serve.js +18 -5
- package/dist/serveSecurity.d.ts +11 -1
- package/dist/serveSecurity.js +38 -4
- package/dist/shell/assets/index-BvQmVwlO.css +1 -0
- package/dist/shell/assets/index-CV5sBby1.js +445 -0
- package/dist/shell/index.html +2 -2
- package/dist/versionStore.js +13 -4
- package/kits/base/CLAUDE.md +9 -0
- package/kits/base/castle.json +16 -11
- package/kits/base/sdk/README.md +66 -1
- package/kits/base/sdk/commands.d.ts +23 -1
- package/kits/base/sdk/commands.js +9 -0
- package/kits/base/sdk/lifecycle.d.ts +20 -0
- package/kits/base/sdk/lifecycle.js +7 -1
- package/kits/base/sdk/resumeState.d.ts +2 -0
- package/kits/base/sdk/resumeState.js +84 -0
- package/kits/base/sdk/runtime.js +65 -0
- package/kits/base/sdk/transport.d.ts +3 -0
- package/kits/base/sdk/transport.js +39 -5
- package/kits/base/sdk/unloadHandshake.d.ts +6 -0
- package/kits/base/sdk/unloadHandshake.js +47 -0
- package/kits/multiplayer-2d/castle.json +3 -3
- package/kits/multiplayer-2d/package-lock.json +0 -27
- package/kits/multiplayer-2d/package.json +0 -1
- package/kits/multiplayer-3d/castle.json +4 -4
- package/kits/multiplayer-3d/package-lock.json +0 -27
- package/kits/multiplayer-3d/package.json +0 -1
- package/kits/physics-2d/CLAUDE.md +75 -43
- package/kits/physics-2d/behaviors/AnalogStick.jsx +75 -45
- package/kits/physics-2d/behaviors/Slingshot.jsx +1 -2
- package/kits/physics-2d/castle.json +6 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +83 -180
- package/kits/physics-2d/editors/SceneEditor.jsx +25 -4
- package/kits/physics-2d/editors/StyleEditor.jsx +6 -2
- package/kits/physics-2d/editors/overlayCanvas.js +77 -0
- package/kits/physics-2d/editors/pathOverlay.js +5 -13
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +5 -1
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -4
- package/kits/physics-2d/editors/pxArtTimeline.jsx +8 -36
- package/kits/physics-2d/editors/pxArtTimeline.module.css +4 -28
- package/kits/physics-2d/editors/styleEditor.module.css +23 -4
- package/kits/physics-2d/editors/useArtboardZoomPan.js +322 -0
- package/kits/physics-2d/engine/SceneUI.jsx +6 -12
- package/kits/physics-2d/engine/icons.js +56 -0
- package/kits/physics-2d/engine/panelSorting.jsx +7 -14
- package/kits/physics-2d/engine/physics/PhysicsSystem.js +66 -11
- package/kits/physics-2d/engine/physics/controls.js +76 -12
- package/kits/physics-2d/engine/scene.js +47 -9
- package/kits/physics-2d/engine/ui.jsx +13 -125
- package/kits/physics-2d/engine/ui.module.css +53 -28
- package/kits/physics-2d/package-lock.json +0 -29
- package/kits/physics-2d/package.json +3 -1
- package/kits/physics-2d/pnpm-lock.yaml +1 -17
- package/kits/physics-3d/castle.json +5 -5
- package/kits/physics-3d/package-lock.json +0 -29
- package/kits/physics-3d/package.json +0 -1
- package/kits/physics-3d/pnpm-lock.yaml +0 -17
- package/kits/turn-based/castle.json +3 -3
- package/kits/turn-based/package-lock.json +0 -27
- package/kits/turn-based/package.json +0 -1
- package/package.json +5 -4
- package/dist/shell/assets/index-CVHj503j.css +0 -1
- 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 {
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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 (
|
|
199
|
+
else if (selected.outcome === 'editor')
|
|
242
200
|
console.log('Using the cover captured in the editor.');
|
|
243
|
-
if (!
|
|
201
|
+
if (!selected.path)
|
|
244
202
|
return null;
|
|
245
|
-
const base64 = fs.readFileSync(
|
|
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
|
|
766
|
-
//
|
|
767
|
-
|
|
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(
|
|
774
|
-
const localContentServers = await startLocalContentServers(
|
|
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 },
|
package/dist/serveSecurity.d.ts
CHANGED
|
@@ -50,7 +50,17 @@ export declare class ServeSecurity {
|
|
|
50
50
|
editOrigin: string;
|
|
51
51
|
playOrigin: string;
|
|
52
52
|
};
|
|
53
|
-
|
|
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>;
|
package/dist/serveSecurity.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|