@stage5/lumine 0.2.7 → 0.2.9
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/lib/assets.js +59 -5
- package/lib/commands.js +22 -0
- package/lib/constants.js +23 -8
- package/lib/doctor.js +720 -0
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +18 -3
package/lib/assets.js
CHANGED
|
@@ -275,7 +275,7 @@ export async function assetsUpload(options) {
|
|
|
275
275
|
const filePaths = options.positional.slice(1);
|
|
276
276
|
if (!filePaths.length) {
|
|
277
277
|
throw new Error(
|
|
278
|
-
"Usage: lumine assets upload <file...> (images
|
|
278
|
+
"Usage: lumine assets upload <file...> (runtime media: images, audio, GLB/self-contained glTF, KTX2, HDR, EXR, BIN, DRC)",
|
|
279
279
|
);
|
|
280
280
|
}
|
|
281
281
|
const buildId = await resolveSdkBuildId(options);
|
|
@@ -400,9 +400,10 @@ export async function assetsDelete(options) {
|
|
|
400
400
|
// matching workspace. An asset must be unreferenced by ALL THREE to be a
|
|
401
401
|
// candidate. If the server does not provide `referencedByProject` (older API
|
|
402
402
|
// deployment), prune shows the plan but refuses to delete — the published-
|
|
403
|
-
// snapshot check cannot be done client-side. Structural blind
|
|
403
|
+
// snapshot check cannot be done client-side. Structural blind spots, always
|
|
404
404
|
// disclosed: references stored in app data (privateDb/sharedDb/viewer DB by
|
|
405
|
-
// Twinkle.files-using apps)
|
|
405
|
+
// Twinkle.files-using apps), and references stored only inside uploaded asset
|
|
406
|
+
// files such as .gltf manifests, are invisible to every scan.
|
|
406
407
|
export async function assetsPrune(options) {
|
|
407
408
|
const buildId = await resolveSdkBuildId(options);
|
|
408
409
|
const auth = await ensureAuth(options);
|
|
@@ -470,7 +471,7 @@ export async function assetsPrune(options) {
|
|
|
470
471
|
);
|
|
471
472
|
}
|
|
472
473
|
console.log(
|
|
473
|
-
"Caution: asset URLs stored in app data (privateDb/sharedDb/user DBs by apps using Twinkle.files) cannot be detected.
|
|
474
|
+
"Caution: asset URLs stored in app data (privateDb/sharedDb/user DBs by apps using Twinkle.files), or referenced only inside uploaded files such as .gltf manifests, cannot be detected. Keep those URLs in project files too, or do not prune this build's assets.",
|
|
474
475
|
);
|
|
475
476
|
if (!serverVerified) {
|
|
476
477
|
console.log(
|
|
@@ -558,7 +559,7 @@ export async function readAssetUploadCandidate(filePath) {
|
|
|
558
559
|
const mimeType = ASSET_MIME_BY_EXTENSION[extension];
|
|
559
560
|
if (!mimeType) {
|
|
560
561
|
throw new Error(
|
|
561
|
-
`Unsupported asset type for ${fileName}. Build assets support
|
|
562
|
+
`Unsupported asset type for ${fileName}. Build assets support runtime media files (${Object.keys(ASSET_MIME_BY_EXTENSION).join(", ")}).`,
|
|
562
563
|
);
|
|
563
564
|
}
|
|
564
565
|
let buffer;
|
|
@@ -573,9 +574,62 @@ export async function readAssetUploadCandidate(filePath) {
|
|
|
573
574
|
if (!buffer.length) {
|
|
574
575
|
throw new Error(`Asset file is empty: ${filePath}`);
|
|
575
576
|
}
|
|
577
|
+
if (extension === ".gltf") {
|
|
578
|
+
assertGltfUploadCandidateHasNoRelativeUris({ fileName, buffer });
|
|
579
|
+
}
|
|
576
580
|
return { absolutePath, fileName, mimeType, buffer };
|
|
577
581
|
}
|
|
578
582
|
|
|
583
|
+
function isExternalOrEmbeddedGltfUri(uri) {
|
|
584
|
+
const normalizedUri = String(uri || "").trim();
|
|
585
|
+
if (!normalizedUri) return true;
|
|
586
|
+
if (normalizedUri.startsWith("/") || normalizedUri.startsWith("//")) {
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
const schemeMatch = normalizedUri.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
590
|
+
if (!schemeMatch) return false;
|
|
591
|
+
return ["http", "https", "data"].includes(schemeMatch[1].toLowerCase());
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function collectRelativeGltfUris(parsedGltf) {
|
|
595
|
+
const relativeUris = [];
|
|
596
|
+
if (!parsedGltf || typeof parsedGltf !== "object") {
|
|
597
|
+
return relativeUris;
|
|
598
|
+
}
|
|
599
|
+
for (const entry of [
|
|
600
|
+
...(Array.isArray(parsedGltf.buffers) ? parsedGltf.buffers : []),
|
|
601
|
+
...(Array.isArray(parsedGltf.images) ? parsedGltf.images : []),
|
|
602
|
+
]) {
|
|
603
|
+
const uri = typeof entry?.uri === "string" ? entry.uri.trim() : "";
|
|
604
|
+
if (uri && !isExternalOrEmbeddedGltfUri(uri)) {
|
|
605
|
+
relativeUris.push(uri);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return relativeUris;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function assertGltfUploadCandidateHasNoRelativeUris({ fileName, buffer }) {
|
|
612
|
+
let parsedGltf;
|
|
613
|
+
try {
|
|
614
|
+
parsedGltf = JSON.parse(buffer.toString("utf8"));
|
|
615
|
+
} catch {
|
|
616
|
+
throw new Error(`Invalid .gltf JSON in ${fileName}.`);
|
|
617
|
+
}
|
|
618
|
+
const relativeUris = collectRelativeGltfUris(parsedGltf);
|
|
619
|
+
if (relativeUris.length === 0) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const sampleUris = relativeUris
|
|
623
|
+
.slice(0, 3)
|
|
624
|
+
.map((uri) => `"${uri}"`)
|
|
625
|
+
.join(", ");
|
|
626
|
+
throw new Error(
|
|
627
|
+
`${fileName} contains relative glTF URI${
|
|
628
|
+
relativeUris.length === 1 ? "" : "s"
|
|
629
|
+
} (${sampleUris}${relativeUris.length > 3 ? ", ..." : ""}). Raw .gltf companion files are stored under separate asset URLs and will not resolve relative to the .gltf. Use .glb, embed data URIs, or rewrite .gltf URIs to absolute uploaded asset URLs before uploading.`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
579
633
|
export async function uploadRuntimeAsset({
|
|
580
634
|
options,
|
|
581
635
|
auth,
|
package/lib/commands.js
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
} from "./auth.js";
|
|
55
55
|
import { probeUrl, requestJson } from "./http.js";
|
|
56
56
|
import { sdkCommand } from "./sdk.js";
|
|
57
|
+
import { doctorCommand, normalizePreviewUrl } from "./doctor.js";
|
|
57
58
|
import {
|
|
58
59
|
defaultMainCheckoutDir,
|
|
59
60
|
defaultReferenceDir,
|
|
@@ -191,6 +192,10 @@ export async function main() {
|
|
|
191
192
|
await thumbnailCommand(options);
|
|
192
193
|
return;
|
|
193
194
|
}
|
|
195
|
+
if (options.command === "doctor") {
|
|
196
|
+
await doctorCommand(options);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
194
199
|
|
|
195
200
|
printHelp();
|
|
196
201
|
}
|
|
@@ -1501,6 +1506,9 @@ export function parseArgs(args) {
|
|
|
1501
1506
|
"allowWrite",
|
|
1502
1507
|
"main",
|
|
1503
1508
|
"yes",
|
|
1509
|
+
"json",
|
|
1510
|
+
"keepAssets",
|
|
1511
|
+
"noBrowser",
|
|
1504
1512
|
]);
|
|
1505
1513
|
|
|
1506
1514
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -1569,6 +1577,11 @@ export function parseArgs(args) {
|
|
|
1569
1577
|
siteUrl: trimTrailingSlash(
|
|
1570
1578
|
String(raw.siteUrl || process.env.TWINKLE_SITE_URL || DEFAULT_SITE_URL),
|
|
1571
1579
|
),
|
|
1580
|
+
previewUrl: normalizePreviewUrl(
|
|
1581
|
+
raw.previewUrl ||
|
|
1582
|
+
process.env.TWINKLE_PREVIEW_URL ||
|
|
1583
|
+
process.env.TWINKLE_BUILD_PREVIEW_URL,
|
|
1584
|
+
),
|
|
1572
1585
|
npmRegistryUrl: trimTrailingSlash(
|
|
1573
1586
|
String(
|
|
1574
1587
|
raw.npmRegistryUrl ||
|
|
@@ -1607,6 +1620,9 @@ export function parseArgs(args) {
|
|
|
1607
1620
|
openBrowser: parseBoolean(raw.noOpen, false)
|
|
1608
1621
|
? false
|
|
1609
1622
|
: parseBoolean(raw.open, true),
|
|
1623
|
+
json: parseBoolean(raw.json, false),
|
|
1624
|
+
keepAssets: parseBoolean(raw.keepAssets, false),
|
|
1625
|
+
noBrowser: parseBoolean(raw.noBrowser, false),
|
|
1610
1626
|
updateCheck: parseBoolean(raw.noUpdateCheck, false) ? false : true,
|
|
1611
1627
|
timeoutMs: Math.max(
|
|
1612
1628
|
Number(raw.timeoutMs || process.env.TWINKLE_TIMEOUT_MS) ||
|
|
@@ -1778,6 +1794,7 @@ export function printHelp() {
|
|
|
1778
1794
|
lumine thumbnail set <file>
|
|
1779
1795
|
lumine thumbnail capture [--out <file>]
|
|
1780
1796
|
lumine thumbnail generate ["<prompt>"] --model <gpt-image-2|nano-banana>
|
|
1797
|
+
lumine doctor runtime-assets
|
|
1781
1798
|
|
|
1782
1799
|
Examples:
|
|
1783
1800
|
npx @stage5/lumine@latest
|
|
@@ -1808,10 +1825,12 @@ Examples:
|
|
|
1808
1825
|
npx @stage5/lumine@latest thumbnail set art/cover.png
|
|
1809
1826
|
npx @stage5/lumine@latest thumbnail capture --out capture.png
|
|
1810
1827
|
npx @stage5/lumine@latest thumbnail generate --model gpt-image-2 --yes
|
|
1828
|
+
npx @stage5/lumine@latest doctor runtime-assets --build 917 --json
|
|
1811
1829
|
|
|
1812
1830
|
Options:
|
|
1813
1831
|
--api-url <url> Twinkle API origin
|
|
1814
1832
|
--site-url <url> Twinkle website origin
|
|
1833
|
+
--preview-url <url> Twinkle Build preview origin
|
|
1815
1834
|
--auth-file <path> Saved login path
|
|
1816
1835
|
--auth-token <token> Override saved login
|
|
1817
1836
|
--dir <path> Directory for pulled project files
|
|
@@ -1833,6 +1852,9 @@ Options:
|
|
|
1833
1852
|
--allow-write Permit sdk methods that mutate app data
|
|
1834
1853
|
--path <api/...> Call an sdk endpoint not in the curated list
|
|
1835
1854
|
--scopes <a,b> Override requested build API token scopes
|
|
1855
|
+
--json Print machine-readable output for doctor commands
|
|
1856
|
+
--keep-assets Keep doctor probe assets instead of deleting them
|
|
1857
|
+
--no-browser Skip doctor browser probes
|
|
1836
1858
|
--model <model> Image model for generate: gpt-image-2 or nano-banana (required, no default)
|
|
1837
1859
|
--quality <q> gpt-image-2 quality: low, medium, high (default high)
|
|
1838
1860
|
--name <fileName> File name hint for a generated asset
|
package/lib/constants.js
CHANGED
|
@@ -37,8 +37,9 @@ export const ASSETS_METADATA_FILE = "assets.json";
|
|
|
37
37
|
// Must match the web workspace's 5MB part size: the server presigns one S3
|
|
38
38
|
// part URL per 5MB of the declared fileSize.
|
|
39
39
|
export const ASSET_UPLOAD_CHUNK_BYTES = 5 * 1024 * 1024;
|
|
40
|
-
// Mirrors the web workspace's asset accept list
|
|
41
|
-
//
|
|
40
|
+
// Mirrors the web workspace's runtime asset accept list. The server rejects
|
|
41
|
+
// video; client allowlists keep project asset uploads focused on files loaders
|
|
42
|
+
// can consume from the sandbox.
|
|
42
43
|
export const ASSET_MIME_BY_EXTENSION = {
|
|
43
44
|
".png": "image/png",
|
|
44
45
|
".jpg": "image/jpeg",
|
|
@@ -60,6 +61,13 @@ export const ASSET_MIME_BY_EXTENSION = {
|
|
|
60
61
|
".flac": "audio/flac",
|
|
61
62
|
".aif": "audio/aiff",
|
|
62
63
|
".aiff": "audio/aiff",
|
|
64
|
+
".glb": "model/gltf-binary",
|
|
65
|
+
".gltf": "model/gltf+json",
|
|
66
|
+
".ktx2": "image/ktx2",
|
|
67
|
+
".hdr": "image/vnd.radiance",
|
|
68
|
+
".exr": "image/x-exr",
|
|
69
|
+
".bin": "application/octet-stream",
|
|
70
|
+
".drc": "application/octet-stream",
|
|
63
71
|
};
|
|
64
72
|
// Mirrors the platform's project-file limits. File count and effective-line
|
|
65
73
|
// limits are hardcoded server constants; the total-byte limit is
|
|
@@ -155,22 +163,28 @@ lumine save --summary "Describe the change"
|
|
|
155
163
|
need --allow-write and mutate real app data.
|
|
156
164
|
- Owned canonical builds may be published only when the user explicitly asks.
|
|
157
165
|
|
|
158
|
-
## Assets (
|
|
166
|
+
## Assets (Runtime Media)
|
|
159
167
|
|
|
160
|
-
- Binary files are NOT project files. Never place
|
|
168
|
+
- Binary files are NOT project files. Never place bundled media in this
|
|
161
169
|
workspace — lumine save rejects binaries. Assets live in Twinkle's asset
|
|
162
170
|
storage and are referenced from code by absolute URL.
|
|
163
171
|
- .twinkle/${ASSETS_METADATA_FILE} lists this build's uploaded assets (the current
|
|
164
172
|
CLI user's uploads) with their URLs. Reference an asset by its \`url\` value.
|
|
165
|
-
- \`lumine assets upload <file...>\` uploads
|
|
166
|
-
|
|
173
|
+
- \`lumine assets upload <file...>\` uploads supported runtime assets (images,
|
|
174
|
+
audio, GLB/self-contained glTF models, texture maps, HDR/EXR files, and glTF
|
|
175
|
+
companion files) from disk and prints the URL to use in code. Raw .gltf files
|
|
176
|
+
with relative companion URIs must be converted to .glb or rewritten to
|
|
177
|
+
absolute uploaded asset URLs first. Reference every uploaded asset that must
|
|
178
|
+
survive pruning from project files. \`lumine assets list\`
|
|
179
|
+
prints assets and refreshes
|
|
167
180
|
.twinkle/${ASSETS_METADATA_FILE}; \`lumine assets delete <assetId>\` removes one;
|
|
168
181
|
\`lumine assets prune\` deletes your uploads that nothing references (server
|
|
169
182
|
checks draft files, published/version snapshots, and derived builds; local
|
|
170
183
|
unsaved edits are scanned too). The server enforces storage quotas.
|
|
171
184
|
- CAUTION: asset URLs an app stores in privateDb/sharedDb/user DBs at runtime
|
|
172
|
-
(Twinkle.files uploads)
|
|
173
|
-
|
|
185
|
+
(Twinkle.files uploads), or URLs referenced only inside uploaded files such as
|
|
186
|
+
.gltf manifests, are invisible to prune. Keep those asset URLs in project
|
|
187
|
+
files too, or do not run \`assets prune\` for that build.
|
|
174
188
|
- On team projects the manifest also lists \`projectAssets\` (the project
|
|
175
189
|
owner's uploads) and \`unmatchedReferences\` (asset URLs in code matching no
|
|
176
190
|
known asset). Reuse existing asset URLs — do NOT re-upload media the
|
|
@@ -321,5 +335,6 @@ export const COMMANDS = new Set([
|
|
|
321
335
|
"sdk",
|
|
322
336
|
"assets",
|
|
323
337
|
"thumbnail",
|
|
338
|
+
"doctor",
|
|
324
339
|
"help",
|
|
325
340
|
]);
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
|
|
3
|
+
import { buildApiJson, mintBuildApiToken } from "./api.js";
|
|
4
|
+
import { ensureAuth, assertAuthScope } from "./auth.js";
|
|
5
|
+
import { uploadRuntimeAsset } from "./assets.js";
|
|
6
|
+
import { requestJson } from "./http.js";
|
|
7
|
+
import { resolveSdkBuildId } from "./sdk.js";
|
|
8
|
+
import { formatBytes, trimTrailingSlash } from "./util.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PREVIEW_URL = "https://preview.lumine.app";
|
|
11
|
+
const DOCTOR_SOURCE = "lumine-runtime-assets-doctor";
|
|
12
|
+
const PREVIEW_IFRAME_SANDBOX = "allow-scripts allow-downloads allow-pointer-lock";
|
|
13
|
+
|
|
14
|
+
export async function doctorCommand(options) {
|
|
15
|
+
const topic = String(options.positional[0] || "runtime-assets").trim();
|
|
16
|
+
if (topic !== "runtime-assets") {
|
|
17
|
+
throw new Error("Usage: lumine doctor runtime-assets [--build <id>] [--json] [--keep-assets] [--no-browser]");
|
|
18
|
+
}
|
|
19
|
+
await runtimeAssetsDoctor(options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runtimeAssetsDoctor(options) {
|
|
23
|
+
const buildId = await resolveSdkBuildId(options);
|
|
24
|
+
const auth = await ensureAuth(options);
|
|
25
|
+
await assertAuthScope({ options, auth, scope: "build:sdk" });
|
|
26
|
+
await assertAuthScope({ options, auth, scope: "build:check" });
|
|
27
|
+
|
|
28
|
+
const { token: buildApiToken } = await mintBuildApiToken({
|
|
29
|
+
options,
|
|
30
|
+
auth,
|
|
31
|
+
buildId,
|
|
32
|
+
scopes: ["files:read", "files:write", "preview:read"],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const result = {
|
|
36
|
+
ok: false,
|
|
37
|
+
buildId,
|
|
38
|
+
checkedAt: new Date().toISOString(),
|
|
39
|
+
uploadedAssets: [],
|
|
40
|
+
cloudfront: [],
|
|
41
|
+
previewSession: null,
|
|
42
|
+
browser: null,
|
|
43
|
+
cleanup: [],
|
|
44
|
+
};
|
|
45
|
+
const uploadedAssets = [];
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const candidates = makeRuntimeAssetProbeCandidates();
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
const asset = await uploadRuntimeAsset({
|
|
51
|
+
options,
|
|
52
|
+
auth,
|
|
53
|
+
buildId,
|
|
54
|
+
buildApiToken,
|
|
55
|
+
candidate,
|
|
56
|
+
});
|
|
57
|
+
uploadedAssets.push(asset);
|
|
58
|
+
result.uploadedAssets.push(formatAssetForReport(asset));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const asset of uploadedAssets) {
|
|
62
|
+
result.cloudfront.push(await probeCloudFrontAsset(asset, options));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options.noBrowser) {
|
|
66
|
+
result.previewSession = {
|
|
67
|
+
ok: false,
|
|
68
|
+
skipped: true,
|
|
69
|
+
reason: "browser_probe_disabled",
|
|
70
|
+
};
|
|
71
|
+
result.browser = {
|
|
72
|
+
ok: false,
|
|
73
|
+
skipped: true,
|
|
74
|
+
reason: "browser_probe_disabled",
|
|
75
|
+
};
|
|
76
|
+
} else {
|
|
77
|
+
result.previewSession = await createRuntimeAssetsPreviewSession({
|
|
78
|
+
options,
|
|
79
|
+
auth,
|
|
80
|
+
buildId,
|
|
81
|
+
hdrAsset: findAssetByExtension(uploadedAssets, ".hdr"),
|
|
82
|
+
glbAsset: findAssetByExtension(uploadedAssets, ".glb"),
|
|
83
|
+
});
|
|
84
|
+
result.browser = await runBrowserLoaderProbe({
|
|
85
|
+
siteUrl: options.siteUrl,
|
|
86
|
+
previewUrl: options.previewUrl,
|
|
87
|
+
previewEntryUrl: result.previewSession.previewEntryUrl,
|
|
88
|
+
timeoutMs: options.timeoutMs,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
result.headersOk = cloudFrontRequiredProbesPass(result.cloudfront);
|
|
93
|
+
result.complete = Boolean(result.browser?.ok);
|
|
94
|
+
result.ok = result.headersOk && (options.noBrowser || result.complete);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
result.ok = false;
|
|
97
|
+
result.headersOk = cloudFrontRequiredProbesPass(result.cloudfront);
|
|
98
|
+
result.complete = false;
|
|
99
|
+
result.error = {
|
|
100
|
+
message: error?.message || String(error),
|
|
101
|
+
status: error?.status || null,
|
|
102
|
+
data: error?.data || null,
|
|
103
|
+
};
|
|
104
|
+
if (!result.previewSession) {
|
|
105
|
+
result.previewSession = {
|
|
106
|
+
ok: false,
|
|
107
|
+
error: result.error.message,
|
|
108
|
+
status: result.error.status,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
if (!options.keepAssets) {
|
|
113
|
+
for (const asset of uploadedAssets) {
|
|
114
|
+
result.cleanup.push(
|
|
115
|
+
await deleteRuntimeAssetQuietly({
|
|
116
|
+
options,
|
|
117
|
+
auth,
|
|
118
|
+
buildId,
|
|
119
|
+
buildApiToken,
|
|
120
|
+
asset,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
result.cleanup.push({
|
|
126
|
+
skipped: true,
|
|
127
|
+
reason: "keep_assets",
|
|
128
|
+
assetIds: uploadedAssets.map((asset) => Number(asset.id || 0)).filter(Boolean),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
printRuntimeAssetsDoctorResult(result, options);
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function makeRuntimeAssetProbeCandidates() {
|
|
140
|
+
const stamp = new Date().toISOString().replace(/[^0-9A-Za-z]+/g, "-");
|
|
141
|
+
return [
|
|
142
|
+
{
|
|
143
|
+
absolutePath: null,
|
|
144
|
+
fileName: `lumine-doctor-env-${stamp}.hdr`,
|
|
145
|
+
mimeType: "image/vnd.radiance",
|
|
146
|
+
buffer: makeMinimalHdr(),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
absolutePath: null,
|
|
150
|
+
fileName: `lumine-doctor-scene-${stamp}.glb`,
|
|
151
|
+
mimeType: "model/gltf-binary",
|
|
152
|
+
buffer: makeMinimalGlb(),
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeMinimalHdr() {
|
|
158
|
+
const header = "#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 1\n";
|
|
159
|
+
return Buffer.concat([
|
|
160
|
+
Buffer.from(header, "ascii"),
|
|
161
|
+
Buffer.from([255, 255, 255, 128]),
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function makeMinimalGlb() {
|
|
166
|
+
const json = JSON.stringify({
|
|
167
|
+
asset: { version: "2.0", generator: "lumine-runtime-assets-doctor" },
|
|
168
|
+
scene: 0,
|
|
169
|
+
scenes: [{ nodes: [] }],
|
|
170
|
+
nodes: [],
|
|
171
|
+
});
|
|
172
|
+
const jsonPadding = (4 - (Buffer.byteLength(json) % 4)) % 4;
|
|
173
|
+
const jsonChunk = Buffer.concat([
|
|
174
|
+
Buffer.from(json, "utf8"),
|
|
175
|
+
Buffer.alloc(jsonPadding, 0x20),
|
|
176
|
+
]);
|
|
177
|
+
const totalLength = 12 + 8 + jsonChunk.length;
|
|
178
|
+
const buffer = Buffer.alloc(totalLength);
|
|
179
|
+
buffer.writeUInt32LE(0x46546c67, 0);
|
|
180
|
+
buffer.writeUInt32LE(2, 4);
|
|
181
|
+
buffer.writeUInt32LE(totalLength, 8);
|
|
182
|
+
buffer.writeUInt32LE(jsonChunk.length, 12);
|
|
183
|
+
buffer.writeUInt32LE(0x4e4f534a, 16);
|
|
184
|
+
jsonChunk.copy(buffer, 20);
|
|
185
|
+
return buffer;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatAssetForReport(asset) {
|
|
189
|
+
return {
|
|
190
|
+
id: Number(asset?.id || 0),
|
|
191
|
+
fileName: asset?.fileName || "",
|
|
192
|
+
originalFileName: asset?.originalFileName || asset?.fileName || "",
|
|
193
|
+
mimeType: asset?.mimeType || null,
|
|
194
|
+
sizeBytes: Number(asset?.sizeBytes || 0),
|
|
195
|
+
url: asset?.url || "",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function findAssetByExtension(assets, extension) {
|
|
200
|
+
return assets.find((asset) =>
|
|
201
|
+
String(asset?.fileName || asset?.originalFileName || "")
|
|
202
|
+
.toLowerCase()
|
|
203
|
+
.endsWith(extension),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function probeCloudFrontAsset(asset, options) {
|
|
208
|
+
const probes = buildCloudFrontProbeSpecs(options);
|
|
209
|
+
const requests = [];
|
|
210
|
+
for (const probe of probes) {
|
|
211
|
+
requests.push(await probeCloudFrontGet(asset.url, probe, options.timeoutMs));
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
asset: formatAssetForReport(asset),
|
|
215
|
+
requests,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function buildCloudFrontProbeSpecs(options) {
|
|
220
|
+
const probes = [
|
|
221
|
+
{
|
|
222
|
+
label: "opaque sandbox",
|
|
223
|
+
origin: "null",
|
|
224
|
+
required: true,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
label: "preview origin",
|
|
228
|
+
origin: normalizeOrigin(options.previewUrl),
|
|
229
|
+
required: false,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
label: "site origin",
|
|
233
|
+
origin: normalizeOrigin(options.siteUrl),
|
|
234
|
+
required: false,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
label: "no Origin",
|
|
238
|
+
origin: null,
|
|
239
|
+
required: false,
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
const merged = new Map();
|
|
243
|
+
for (const probe of probes) {
|
|
244
|
+
if (probe.origin === "") continue;
|
|
245
|
+
const key = probe.origin === null ? "no-origin" : probe.origin;
|
|
246
|
+
const existing = merged.get(key);
|
|
247
|
+
if (!existing) {
|
|
248
|
+
merged.set(key, probe);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
existing.required = existing.required || probe.required;
|
|
252
|
+
existing.label = existing.required ? existing.label : probe.label;
|
|
253
|
+
}
|
|
254
|
+
return Array.from(merged.values());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeOrigin(value) {
|
|
258
|
+
try {
|
|
259
|
+
return new URL(String(value || "")).origin;
|
|
260
|
+
} catch {
|
|
261
|
+
return "";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cloudFrontRequiredProbesPass(cloudfront) {
|
|
266
|
+
if (!cloudfront.length) return false;
|
|
267
|
+
return cloudfront.every((entry) =>
|
|
268
|
+
entry.requests.filter((request) => request.required).length > 0 &&
|
|
269
|
+
entry.requests
|
|
270
|
+
.filter((request) => request.required)
|
|
271
|
+
.every((request) => request.ok && request.corsAllowed),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function probeCloudFrontGet(url, probe, timeoutMs) {
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
278
|
+
const startedAt = Date.now();
|
|
279
|
+
try {
|
|
280
|
+
const response = await fetch(url, {
|
|
281
|
+
method: "GET",
|
|
282
|
+
headers: probe.origin ? { origin: probe.origin } : {},
|
|
283
|
+
signal: controller.signal,
|
|
284
|
+
});
|
|
285
|
+
const body = await response.arrayBuffer();
|
|
286
|
+
const headers = pickHeaders(response.headers);
|
|
287
|
+
const corsAllowed = accessControlAllowOriginMatches({
|
|
288
|
+
headers,
|
|
289
|
+
origin: probe.origin,
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
label: probe.label,
|
|
293
|
+
origin: probe.origin,
|
|
294
|
+
required: probe.required,
|
|
295
|
+
ok: response.ok,
|
|
296
|
+
status: response.status,
|
|
297
|
+
ms: Date.now() - startedAt,
|
|
298
|
+
bytes: body.byteLength,
|
|
299
|
+
hasAccessControlAllowOrigin: Boolean(headers["access-control-allow-origin"]),
|
|
300
|
+
corsAllowed,
|
|
301
|
+
headers,
|
|
302
|
+
};
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return {
|
|
305
|
+
label: probe.label,
|
|
306
|
+
origin: probe.origin,
|
|
307
|
+
required: probe.required,
|
|
308
|
+
ok: false,
|
|
309
|
+
status: 0,
|
|
310
|
+
ms: Date.now() - startedAt,
|
|
311
|
+
bytes: 0,
|
|
312
|
+
hasAccessControlAllowOrigin: false,
|
|
313
|
+
corsAllowed: false,
|
|
314
|
+
error: error?.message || String(error),
|
|
315
|
+
headers: {},
|
|
316
|
+
};
|
|
317
|
+
} finally {
|
|
318
|
+
clearTimeout(timeout);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function accessControlAllowOriginMatches({ headers, origin }) {
|
|
323
|
+
const acao = String(headers["access-control-allow-origin"] || "").trim();
|
|
324
|
+
if (!acao) return false;
|
|
325
|
+
// No-Origin requests are telemetry only. They are not browser CORS checks, so
|
|
326
|
+
// report ACAO presence but never let this path affect required pass/fail.
|
|
327
|
+
if (origin === null) return true;
|
|
328
|
+
return acao === "*" || acao === origin;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function pickHeaders(headers) {
|
|
332
|
+
const keys = [
|
|
333
|
+
"access-control-allow-origin",
|
|
334
|
+
"access-control-allow-methods",
|
|
335
|
+
"access-control-expose-headers",
|
|
336
|
+
"content-type",
|
|
337
|
+
"content-length",
|
|
338
|
+
"cache-control",
|
|
339
|
+
"etag",
|
|
340
|
+
"x-cache",
|
|
341
|
+
"via",
|
|
342
|
+
"x-amz-cf-pop",
|
|
343
|
+
];
|
|
344
|
+
const picked = {};
|
|
345
|
+
for (const key of keys) {
|
|
346
|
+
const value = headers.get(key);
|
|
347
|
+
if (value !== null) picked[key] = value;
|
|
348
|
+
}
|
|
349
|
+
return picked;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function createRuntimeAssetsPreviewSession({
|
|
353
|
+
options,
|
|
354
|
+
auth,
|
|
355
|
+
buildId,
|
|
356
|
+
hdrAsset,
|
|
357
|
+
glbAsset,
|
|
358
|
+
}) {
|
|
359
|
+
if (!hdrAsset?.url || !glbAsset?.url) {
|
|
360
|
+
throw new Error("Runtime asset probe did not produce both .hdr and .glb URLs.");
|
|
361
|
+
}
|
|
362
|
+
const session = await requestJson({
|
|
363
|
+
method: "POST",
|
|
364
|
+
url: `${options.apiUrl}/build/${buildId}/preview-session`,
|
|
365
|
+
authToken: auth.token,
|
|
366
|
+
body: {
|
|
367
|
+
entryPath: "/index.html",
|
|
368
|
+
files: [
|
|
369
|
+
{
|
|
370
|
+
path: "/index.html",
|
|
371
|
+
content: buildRuntimeAssetsProbeHtml({
|
|
372
|
+
hdrUrl: hdrAsset.url,
|
|
373
|
+
glbUrl: glbAsset.url,
|
|
374
|
+
}),
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
},
|
|
378
|
+
timeoutMs: options.timeoutMs,
|
|
379
|
+
});
|
|
380
|
+
const entryUrl = String(session?.entryUrl || "");
|
|
381
|
+
if (!entryUrl) {
|
|
382
|
+
throw new Error("Twinkle did not return a preview-session entry URL.");
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
sessionId: session.sessionId || null,
|
|
387
|
+
entryUrl,
|
|
388
|
+
previewEntryUrl: `${options.previewUrl}${entryUrl}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildRuntimeAssetsProbeHtml({ hdrUrl, glbUrl }) {
|
|
393
|
+
return `<!doctype html>
|
|
394
|
+
<html>
|
|
395
|
+
<head>
|
|
396
|
+
<meta charset="utf-8">
|
|
397
|
+
<title>Lumine Runtime Assets Doctor</title>
|
|
398
|
+
</head>
|
|
399
|
+
<body>
|
|
400
|
+
<script type="module">
|
|
401
|
+
const source = ${JSON.stringify(DOCTOR_SOURCE)};
|
|
402
|
+
const hdrUrl = ${JSON.stringify(hdrUrl)};
|
|
403
|
+
const glbUrl = ${JSON.stringify(glbUrl)};
|
|
404
|
+
|
|
405
|
+
function headersToObject(headers) {
|
|
406
|
+
const result = {};
|
|
407
|
+
for (const [key, value] of headers.entries()) {
|
|
408
|
+
result[key.toLowerCase()] = value;
|
|
409
|
+
}
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function fetchAsset(url) {
|
|
414
|
+
const response = await fetch(url, {
|
|
415
|
+
method: 'GET',
|
|
416
|
+
mode: 'cors',
|
|
417
|
+
cache: 'no-store',
|
|
418
|
+
credentials: 'omit'
|
|
419
|
+
});
|
|
420
|
+
const body = await response.arrayBuffer();
|
|
421
|
+
return {
|
|
422
|
+
ok: response.ok,
|
|
423
|
+
status: response.status,
|
|
424
|
+
type: response.type,
|
|
425
|
+
url: response.url,
|
|
426
|
+
bytes: body.byteLength,
|
|
427
|
+
headers: headersToObject(response.headers)
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function loadHdr(url) {
|
|
432
|
+
const { RGBELoader } = await import('/build/vendor/three/0.184.0/addons/loaders/RGBELoader.js');
|
|
433
|
+
const texture = await new Promise((resolve, reject) => {
|
|
434
|
+
new RGBELoader().load(url, resolve, undefined, reject);
|
|
435
|
+
});
|
|
436
|
+
return {
|
|
437
|
+
ok: true,
|
|
438
|
+
width: texture && texture.image ? texture.image.width || null : null,
|
|
439
|
+
height: texture && texture.image ? texture.image.height || null : null,
|
|
440
|
+
type: texture ? texture.type || null : null
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function loadGlb(url) {
|
|
445
|
+
const { GLTFLoader } = await import('/build/vendor/three/0.184.0/addons/loaders/GLTFLoader.js');
|
|
446
|
+
const gltf = await new Promise((resolve, reject) => {
|
|
447
|
+
new GLTFLoader().load(url, resolve, undefined, reject);
|
|
448
|
+
});
|
|
449
|
+
return {
|
|
450
|
+
ok: true,
|
|
451
|
+
sceneType: gltf && gltf.scene ? gltf.scene.type || null : null,
|
|
452
|
+
sceneChildren: gltf && gltf.scene && gltf.scene.children ? gltf.scene.children.length : null,
|
|
453
|
+
scenes: gltf && gltf.scenes ? gltf.scenes.length : null
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function run() {
|
|
458
|
+
const result = {
|
|
459
|
+
source,
|
|
460
|
+
ok: false,
|
|
461
|
+
href: location.href,
|
|
462
|
+
origin: location.origin,
|
|
463
|
+
crossOriginIsolated: window.crossOriginIsolated === true,
|
|
464
|
+
hdrFetch: null,
|
|
465
|
+
glbFetch: null,
|
|
466
|
+
hdrLoader: null,
|
|
467
|
+
glbLoader: null
|
|
468
|
+
};
|
|
469
|
+
try {
|
|
470
|
+
result.hdrFetch = await fetchAsset(hdrUrl);
|
|
471
|
+
result.glbFetch = await fetchAsset(glbUrl);
|
|
472
|
+
result.hdrLoader = await loadHdr(hdrUrl).catch((error) => ({
|
|
473
|
+
ok: false,
|
|
474
|
+
name: error && error.name ? error.name : null,
|
|
475
|
+
error: error && error.message ? error.message : String(error)
|
|
476
|
+
}));
|
|
477
|
+
result.glbLoader = await loadGlb(glbUrl).catch((error) => ({
|
|
478
|
+
ok: false,
|
|
479
|
+
name: error && error.name ? error.name : null,
|
|
480
|
+
error: error && error.message ? error.message : String(error)
|
|
481
|
+
}));
|
|
482
|
+
result.ok = Boolean(
|
|
483
|
+
result.hdrFetch && result.hdrFetch.ok &&
|
|
484
|
+
result.glbFetch && result.glbFetch.ok &&
|
|
485
|
+
result.hdrLoader && result.hdrLoader.ok &&
|
|
486
|
+
result.glbLoader && result.glbLoader.ok
|
|
487
|
+
);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
result.error = error && error.message ? error.message : String(error);
|
|
490
|
+
}
|
|
491
|
+
window.parent.postMessage(result, '*');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
run();
|
|
495
|
+
</script>
|
|
496
|
+
</body>
|
|
497
|
+
</html>`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function runBrowserLoaderProbe({
|
|
501
|
+
siteUrl,
|
|
502
|
+
previewUrl,
|
|
503
|
+
previewEntryUrl,
|
|
504
|
+
timeoutMs,
|
|
505
|
+
}) {
|
|
506
|
+
const playwright = await loadPlaywright();
|
|
507
|
+
if (!playwright?.chromium) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
skipped: true,
|
|
511
|
+
reason: "playwright_not_available",
|
|
512
|
+
message:
|
|
513
|
+
"Install Playwright or set LUMINE_PLAYWRIGHT_MODULE to enable the sandboxed loader probe.",
|
|
514
|
+
previewEntryUrl,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
519
|
+
const page = await browser.newPage({
|
|
520
|
+
viewport: { width: 1280, height: 900 },
|
|
521
|
+
});
|
|
522
|
+
const consoleMessages = [];
|
|
523
|
+
page.on("console", (message) => {
|
|
524
|
+
consoleMessages.push({
|
|
525
|
+
type: message.type(),
|
|
526
|
+
text: message.text().slice(0, 500),
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const harnessUrl = buildBrowserHarnessUrl(siteUrl);
|
|
532
|
+
await page.route(harnessUrl, async (route) => {
|
|
533
|
+
await route.fulfill({
|
|
534
|
+
status: 200,
|
|
535
|
+
contentType: "text/html; charset=utf-8",
|
|
536
|
+
headers: { "cache-control": "no-store" },
|
|
537
|
+
body: buildOuterSandboxHarness(previewEntryUrl),
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
await page.goto(harnessUrl, {
|
|
541
|
+
waitUntil: "domcontentloaded",
|
|
542
|
+
timeout: timeoutMs,
|
|
543
|
+
});
|
|
544
|
+
const loaderResult = await waitForBrowserProbeResult(page, timeoutMs);
|
|
545
|
+
return {
|
|
546
|
+
ok: Boolean(loaderResult?.ok),
|
|
547
|
+
harnessUrl,
|
|
548
|
+
previewOrigin: previewUrl,
|
|
549
|
+
previewEntryUrl,
|
|
550
|
+
sandbox: PREVIEW_IFRAME_SANDBOX,
|
|
551
|
+
result: loaderResult,
|
|
552
|
+
consoleMessages,
|
|
553
|
+
};
|
|
554
|
+
} finally {
|
|
555
|
+
await browser.close();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function buildBrowserHarnessUrl(siteUrl) {
|
|
560
|
+
const url = new URL("/__lumine-doctor/runtime-assets-harness", siteUrl);
|
|
561
|
+
url.searchParams.set("t", String(Date.now()));
|
|
562
|
+
return url.toString();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function waitForBrowserProbeResult(page, timeoutMs) {
|
|
566
|
+
try {
|
|
567
|
+
await page.waitForFunction(
|
|
568
|
+
() => Boolean(window.__lumineRuntimeAssetsDoctorResult),
|
|
569
|
+
null,
|
|
570
|
+
{ timeout: timeoutMs },
|
|
571
|
+
);
|
|
572
|
+
return await page.evaluate(() => window.__lumineRuntimeAssetsDoctorResult);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
return {
|
|
575
|
+
ok: false,
|
|
576
|
+
error: error?.message || "Timed out waiting for runtime asset probe message.",
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function loadPlaywright() {
|
|
582
|
+
const require = createRequire(import.meta.url);
|
|
583
|
+
const candidates = [
|
|
584
|
+
process.env.LUMINE_PLAYWRIGHT_MODULE,
|
|
585
|
+
"playwright",
|
|
586
|
+
].filter(Boolean);
|
|
587
|
+
for (const candidate of candidates) {
|
|
588
|
+
try {
|
|
589
|
+
return require(candidate);
|
|
590
|
+
} catch {
|
|
591
|
+
// Try the next candidate.
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function buildOuterSandboxHarness(previewEntryUrl) {
|
|
598
|
+
return `<!doctype html>
|
|
599
|
+
<html>
|
|
600
|
+
<head>
|
|
601
|
+
<meta charset="utf-8">
|
|
602
|
+
<title>Lumine Runtime Assets Doctor Harness</title>
|
|
603
|
+
</head>
|
|
604
|
+
<body>
|
|
605
|
+
<script>
|
|
606
|
+
window.__lumineRuntimeAssetsDoctorResult = null;
|
|
607
|
+
window.addEventListener('message', (event) => {
|
|
608
|
+
if (!event || !event.data || event.data.source !== ${JSON.stringify(DOCTOR_SOURCE)}) return;
|
|
609
|
+
window.__lumineRuntimeAssetsDoctorResult = event.data;
|
|
610
|
+
});
|
|
611
|
+
</script>
|
|
612
|
+
<iframe
|
|
613
|
+
title="Lumine runtime assets doctor"
|
|
614
|
+
sandbox="${PREVIEW_IFRAME_SANDBOX}"
|
|
615
|
+
src="${escapeHtmlAttribute(previewEntryUrl)}"
|
|
616
|
+
style="width: 800px; height: 600px; border: 0;"
|
|
617
|
+
></iframe>
|
|
618
|
+
</body>
|
|
619
|
+
</html>`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function escapeHtmlAttribute(value) {
|
|
623
|
+
return String(value || "")
|
|
624
|
+
.replace(/&/g, "&")
|
|
625
|
+
.replace(/"/g, """)
|
|
626
|
+
.replace(/</g, "<")
|
|
627
|
+
.replace(/>/g, ">");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function deleteRuntimeAssetQuietly({
|
|
631
|
+
options,
|
|
632
|
+
auth,
|
|
633
|
+
buildId,
|
|
634
|
+
buildApiToken,
|
|
635
|
+
asset,
|
|
636
|
+
}) {
|
|
637
|
+
const assetId = Number(asset?.id || 0);
|
|
638
|
+
if (!assetId) {
|
|
639
|
+
return { ok: false, assetId, error: "missing_asset_id" };
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
await buildApiJson({
|
|
643
|
+
options,
|
|
644
|
+
auth,
|
|
645
|
+
buildId,
|
|
646
|
+
buildApiToken,
|
|
647
|
+
endpointPath: "api/files/delete",
|
|
648
|
+
body: { assetId },
|
|
649
|
+
});
|
|
650
|
+
return { ok: true, assetId };
|
|
651
|
+
} catch (error) {
|
|
652
|
+
return {
|
|
653
|
+
ok: false,
|
|
654
|
+
assetId,
|
|
655
|
+
status: error?.status || null,
|
|
656
|
+
error: error?.message || String(error),
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function printRuntimeAssetsDoctorResult(result, options) {
|
|
662
|
+
if (options.json) {
|
|
663
|
+
console.log(JSON.stringify(result, null, 2));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
console.log(`Runtime asset doctor for Build #${result.buildId}`);
|
|
668
|
+
for (const asset of result.uploadedAssets) {
|
|
669
|
+
console.log(
|
|
670
|
+
`- uploaded #${asset.id} ${asset.originalFileName} ${formatBytes(asset.sizeBytes)}`,
|
|
671
|
+
);
|
|
672
|
+
console.log(` ${asset.url}`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
for (const entry of result.cloudfront) {
|
|
676
|
+
console.log(`- CloudFront ${entry.asset.originalFileName}`);
|
|
677
|
+
for (const request of entry.requests) {
|
|
678
|
+
const origin = request.origin === null ? "(no Origin)" : request.origin;
|
|
679
|
+
const acao = request.headers["access-control-allow-origin"] || "(missing)";
|
|
680
|
+
const contentType = request.headers["content-type"] || "(missing)";
|
|
681
|
+
const required = request.required ? "required" : "info";
|
|
682
|
+
const cors = request.corsAllowed ? "allowed" : "blocked";
|
|
683
|
+
console.log(
|
|
684
|
+
` GET ${request.label} [${required}] Origin ${origin}: ` +
|
|
685
|
+
`${request.ok ? "ok" : "fail"} ${request.status} ` +
|
|
686
|
+
`ACAO=${acao} CORS=${cors} Content-Type=${contentType} bytes=${request.bytes}`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (result.previewSession?.previewEntryUrl) {
|
|
692
|
+
console.log(`- preview session: ${result.previewSession.previewEntryUrl}`);
|
|
693
|
+
}
|
|
694
|
+
if (result.error?.message) {
|
|
695
|
+
const status = result.error.status ? ` (${result.error.status})` : "";
|
|
696
|
+
console.log(`- error: ${result.error.message}${status}`);
|
|
697
|
+
}
|
|
698
|
+
if (result.browser?.skipped) {
|
|
699
|
+
console.log(`- browser loader probe: skipped (${result.browser.reason})`);
|
|
700
|
+
if (result.browser.message) console.log(` ${result.browser.message}`);
|
|
701
|
+
} else if (result.browser) {
|
|
702
|
+
console.log(`- browser loader probe: ${result.browser.ok ? "ok" : "fail"}`);
|
|
703
|
+
const loader = result.browser.result || {};
|
|
704
|
+
console.log(` HDRLoader: ${loader.hdrLoader?.ok ? "ok" : "fail"}`);
|
|
705
|
+
console.log(` GLTFLoader: ${loader.glbLoader?.ok ? "ok" : "fail"}`);
|
|
706
|
+
}
|
|
707
|
+
for (const cleanup of result.cleanup) {
|
|
708
|
+
if (cleanup.skipped) {
|
|
709
|
+
console.log(`- cleanup: skipped (${cleanup.reason})`);
|
|
710
|
+
} else {
|
|
711
|
+
console.log(`- cleanup asset #${cleanup.assetId}: ${cleanup.ok ? "ok" : "fail"}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const suffix = result.ok && !result.complete ? " (browser skipped)" : "";
|
|
715
|
+
console.log(`Result: ${result.ok ? "ok" : "fail"}${suffix}`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function normalizePreviewUrl(value) {
|
|
719
|
+
return trimTrailingSlash(String(value || DEFAULT_PREVIEW_URL));
|
|
720
|
+
}
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.26.
|
|
4
|
-
Updated: 2026-07-
|
|
5
|
-
Generated: 2026-07-
|
|
3
|
+
Version: 1.26.3
|
|
4
|
+
Updated: 2026-07-08
|
|
5
|
+
Generated: 2026-07-08T09:25:17.959Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -22,12 +22,27 @@ Generated: 2026-07-05T06:47:32.349Z
|
|
|
22
22
|
- Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
|
|
23
23
|
- Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
|
|
24
24
|
- Interface text must not be selectable on touch devices: apply user-select: none plus -webkit-user-select: none and -webkit-touch-callout: none to interface text (HUD, buttons, labels, menus, scores, game controls) so mobile long-press does not highlight UI. Keep text inputs and genuinely user-copyable content selectable.
|
|
25
|
+
- Build app tab mute is enforced by the host runtime automatically for standard media elements and Web Audio connections to AudioContext.destination. Apps with custom audio engines can also observe Twinkle.onAudioMuteChange and check Twinkle.isAudioMuted.
|
|
25
26
|
|
|
26
27
|
## Token Scopes
|
|
27
28
|
files:read, user:read, users:read, dailyReflections:read, content:read, sharedDb:read, sharedDb:write, privateDb:read, privateDb:write, files:write, chat:read, chat:write, notifications:read, notifications:write, notifications:emit, reminders:read, reminders:write
|
|
28
29
|
|
|
29
30
|
## Namespaces
|
|
30
31
|
|
|
32
|
+
### Twinkle
|
|
33
|
+
- isAudioMuted() | scopes: none
|
|
34
|
+
- Returns: boolean
|
|
35
|
+
- Returns whether the host runtime currently has this Build app tab muted.
|
|
36
|
+
- The host automatically mutes standard <audio>/<video> elements and Web Audio nodes connected to AudioContext.destination.
|
|
37
|
+
- Use this only when your app manages audio outside those standard paths.
|
|
38
|
+
- Example: if (Twinkle.isAudioMuted()) pauseCustomMixerOutput();
|
|
39
|
+
- onAudioMuteChange(listener, options?) | scopes: none
|
|
40
|
+
- Returns: unsubscribe function
|
|
41
|
+
- Subscribe to host tab mute changes for custom audio engines.
|
|
42
|
+
- The listener receives the current muted boolean immediately by default.
|
|
43
|
+
- Pass { immediate: false } to skip the initial callback.
|
|
44
|
+
- Example: const unsubscribe = Twinkle.onAudioMuteChange((muted) => customMixer.setMuted(muted));
|
|
45
|
+
|
|
31
46
|
### Twinkle.capabilities
|
|
32
47
|
- async get() | scopes: none
|
|
33
48
|
- Returns: Capability snapshot
|