UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

993 lines (908 loc) 36.8 kB
// @ts-check import { SKYBOX_MAGIC_KEYWORDS, buildAutoPolicy, getLocalFilesAutoPolicy, getWebXRProfilesForMode, hasAutoFeatureSelection, makeFilesLocalIsEnabled, resolveOptions, resolveSkyboxSelectionUrls, resolveSkyboxValueToUrl, shouldHandleUrlInAutoMode, } from './local-files-analysis.js'; /** @typedef {import('./local-files-types.js').LocalizationContext} LocalizationContext */ /** @typedef {import('./local-files-types.js').LocalizationOptions} LocalizationOptions */ /** @typedef {import('./local-files-types.js').LocalizationStats} LocalizationStats */ /** @typedef {import('./local-files-types.js').AutoPolicy} AutoPolicy */ /** @typedef {import('./local-files-types.js').UrlHandler} UrlHandler */ import { Cache, downloadBinary, downloadText, ensureTrailingSlash, fixRelativeNewURL, getRelativeToBasePath, getShortUrlName, getValidFilename, normalizeWebPath, recordFailedDownload, replaceAll, finishMakeLocalProgress, } from './local-files-utils.js'; import { needleBlue, needleDim, needleLog, needleSupportsColor } from './logging.js'; const debug = false; /** @param {unknown} err @returns {string} */ function getErrMessage(err) { return err instanceof Error ? getErrMessage(err) : String(err); } /** @type {UrlHandler[]} */ const urlHandlers = [ { name: "Google Fonts CSS", pattern: /["'`](https:\/\/fonts\.googleapis\.com\/css2?\?[^"'`]+)["'`]/g, type: "css", feature: "fonts", }, { name: "Google Fonts gstatic", pattern: /["'`(](https:\/\/fonts\.gstatic\.com\/[^"'`)]+)["'`)]/g, type: "binary", feature: "fonts", }, { name: "QRCode.js", pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/gh\/davidshimjs\/qrcodejs@[^"'`]+\/qrcode\.min\.js)["'`]/g, type: "binary", feature: "cdn-scripts", }, { name: "vConsole", pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/npm\/vconsole@[^"'`]+\/dist\/vconsole\.min\.js)["'`]/g, type: "binary", feature: "cdn-scripts", }, { name: "HLS.js", pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/npm\/hls\.js@[^"'`]+)["'`]/g, type: "binary", feature: "cdn-scripts", }, { name: "WebXR Input Profiles", pattern: /["'`](https:\/\/cdn\.jsdelivr\.net\/npm\/@webxr-input-profiles\/assets@[^"'`]*\/dist\/profiles)\/?["'`]/g, type: "webxr-profiles", feature: "xr", }, { name: "Polyhaven", pattern: /["'`](https:\/\/dl\.polyhaven\.org\/file\/[^"'`]+)["'`]/g, type: "binary", feature: "polyhaven", }, { name: "Needle CDN skybox", pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/skybox\/[^"'`]+)["'`]/g, type: "binary", feature: "skybox", }, { name: "Needle CDN fonts", pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/fonts\/[^"'`]+)["'`]/g, type: "binary", feature: "needle-fonts", }, { name: "Needle CDN models", pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/models\/[^"'`]+)["'`]/g, type: "binary", feature: "needle-models", }, { name: "Needle CDN avatars", pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/avatars\/[^"'`]+)["'`]/g, type: "binary", feature: "needle-avatars", }, { name: "Needle CDN branding", pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/branding\/[^"'`]+)["'`]/g, type: "binary", feature: "needle-branding", }, { name: "Needle CDN basis decoder", pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/three\/[^"'`]*\/basis2\/?)['"`]/g, type: "decoder-dir", decoderFiles: ["basis_transcoder.js", "basis_transcoder.wasm"], localDirName: "basis", feature: "ktx2", }, { name: "Draco decoder (gstatic)", pattern: /["'`](https:\/\/www\.gstatic\.com\/draco\/versioned\/decoders\/[^"'`]*\/?)['"`]/g, type: "decoder-dir", decoderFiles: ["draco_decoder.js", "draco_decoder.wasm", "draco_wasm_wrapper.js"], localDirName: "draco", feature: "draco", }, { name: "Needle CDN MaterialX", pattern: /["'`](https:\/\/cdn\.needle\.tools\/static\/materialx\/[^"'`]*\/?)['"`]/g, type: "decoder-dir", decoderFiles: ["JsMaterialXCore.wasm", "JsMaterialXGenShader.wasm", "JsMaterialXGenShader.data.txt"], localDirName: "materialx", feature: "materialx", }, { name: "Needle uploads", pattern: /["'`](https:\/\/uploads\.needle\.tools\/include\/[^"'`]+)["'`]/g, type: "binary", feature: "needle-uploads", }, { name: "GitHub raw content", pattern: /["'`](https:\/\/raw\.githubusercontent\.com\/[^"'`$]+\.[a-z]{2,5})["'`]/g, type: "binary", feature: "github-content", }, { name: "threejs.org models", pattern: /["'`](https:\/\/threejs\.org\/examples\/models\/[^"'`]+)["'`]/g, type: "binary", feature: "threejs-models", }, ]; export { makeFilesLocalIsEnabled }; /** * @returns {LocalizationStats} */ function createLocalizationStats() { return { fileCount: 0, totalBytes: 0, mimeCounts: new Map(), }; } /** * @param {string} url * @param {string} [fallback] * @returns {string} */ function inferMimeType(url, fallback = "application/octet-stream") { const value = String(url || "").split("?")[0].toLowerCase(); if (value.endsWith(".css")) return "text/css"; if (value.endsWith(".json")) return "application/json"; if (value.endsWith(".js") || value.endsWith(".mjs")) return "application/javascript"; if (value.endsWith(".wasm")) return "application/wasm"; if (value.endsWith(".ttf")) return "font/ttf"; if (value.endsWith(".woff2")) return "font/woff2"; if (value.endsWith(".woff")) return "font/woff"; if (value.endsWith(".otf")) return "font/otf"; if (value.endsWith(".png")) return "image/png"; if (value.endsWith(".jpg") || value.endsWith(".jpeg")) return "image/jpeg"; if (value.endsWith(".webp")) return "image/webp"; if (value.endsWith(".exr")) return "image/exr"; if (value.endsWith(".glb")) return "model/gltf-binary"; if (value.endsWith(".gltf")) return "model/gltf+json"; return fallback; } /** * @param {LocalizationStats} stats * @param {string} url * @param {number} sizeBytes * @param {string | null | undefined} mimeType */ function recordLocalizedAsset(stats, url, sizeBytes, mimeType) { if (!stats || !Number.isFinite(sizeBytes) || sizeBytes < 0) return; stats.fileCount += 1; stats.totalBytes += sizeBytes; const mime = mimeType || inferMimeType(url); stats.mimeCounts.set(mime, (stats.mimeCounts.get(mime) || 0) + 1); } /** * @param {"build" | "serve"} command * @param {unknown} _config * @param {import('../types').userSettings} userSettings * @returns {import('vite').Plugin | null} */ export function needleMakeFilesLocal(command, _config, userSettings) { if (!makeFilesLocalIsEnabled(userSettings)) { return null; } const options = resolveOptions(userSettings); const startupLines = ["Local files plugin is enabled"]; if (options.platform) startupLines.push("Platform: " + options.platform); if (options.exclude?.length) startupLines.push("Custom excludes: " + options.exclude.join(", ")); if (options.features === "auto") startupLines.push("Feature detection: auto"); else if (Array.isArray(options.features) && options.features.length) startupLines.push("Features: " + options.features.join(", ")); if (options.excludeFeatures?.length) startupLines.push("Excluded features: " + options.excludeFeatures.join(", ")); if (options.packages?.length) startupLines.push("Package filter: " + options.packages.join(", ")); const projectDir = process.cwd(); const autoPolicy = getLocalFilesAutoPolicy(projectDir) ?? buildAutoPolicy(options, projectDir); const activeHandlers = getActiveHandlers(options, autoPolicy); { const featureSet = Array.from(new Set(activeHandlers.map(h => h.feature))); const allFeatureSet = Array.from(new Set(urlHandlers.map(h => h.feature))); if (options.features === "auto") { startupLines.push("Auto-detected features (" + featureSet.length + "/" + allFeatureSet.length + "): " + featureSet.join(", ")); } else if (Array.isArray(options.features) && options.features.length || options.excludeFeatures?.length) { startupLines.push("Active features (" + featureSet.length + "): " + featureSet.join(", ")); } } needleLog("needle:local-files", startupLines.join("\n"), "log", { dimBody: false }); const cache = new Cache(); /** @type {Map<string, string>} */ const failedDownloads = new Map(); const localizationStats = createLocalizationStats(); /** @type {import('vite').ResolvedConfig | null} */ let viteConfig = null; const plugin = /** @type {import('vite').Plugin} */ ({ name: "needle:local-files", apply: "build", configResolved(config) { viteConfig = config; }, async buildStart() { await prefetchConfiguredAssets({ pluginContext: /** @type {import('rollup').PluginContext} */ (this), cache, command, viteConfig, options, autoPolicy, failedDownloads, localizationStats, }, activeHandlers); }, async transform(src, _id) { if (options.packages?.length && _id) { const matchesPackage = options.packages.some(pkg => _id.includes('/node_modules/' + pkg + '/') || _id.includes('\\node_modules\\' + pkg + '\\')); if (!matchesPackage) { const isProjectFile = !_id.includes('/node_modules/') && !_id.includes('\\node_modules\\'); if (!isProjectFile) return { code: src, map: null }; } } try { const assetsDir = normalizeWebPath(ensureTrailingSlash(viteConfig?.build?.assetsDir || "assets")); const isCssTransform = /\.css($|\?)/i.test(_id || ""); const currentDir = isCssTransform ? assetsDir : ""; src = await makeLocal(src, "ext/", currentDir, { pluginContext: /** @type {import('rollup').PluginContext} */ (this), cache, command, viteConfig, options, autoPolicy, failedDownloads, localizationStats, }, activeHandlers); src = fixRelativeNewURL(src); } catch (err) { needleLog("needle:local-files", "Error in transform: " + getErrMessage(err), "error"); } return { code: src, map: null, }; }, renderChunk(code, chunk) { if (!chunk.fileName?.endsWith(".js")) return null; const fixed = fixRelativeNewURL(code); if (fixed === code) return null; return { code: fixed, map: null, }; }, generateBundle(_options, bundle) { for (const output of Object.values(bundle)) { if (output.type !== "chunk") continue; if (!output.fileName?.endsWith(".js")) continue; const fixed = fixRelativeNewURL(output.code); if (fixed !== output.code) output.code = fixed; } }, transformIndexHtml: { order: 'pre', async handler(html, _ctx) { try { html = await makeLocalHtml(html, "ext/", { pluginContext: null, cache, command, viteConfig, options, autoPolicy, failedDownloads, localizationStats, }, activeHandlers); } catch (err) { needleLog("needle:local-files", "Error in transformIndexHtml: " + getErrMessage(err), "error"); } return html; } }, buildEnd() { finishMakeLocalProgress(); const map = cache.map; const shorten = (value, max = 140) => value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value; const supportsColor = needleSupportsColor(); const key = (text) => supportsColor ? needleBlue(text) : text; const localized = ["Local files summary"]; localized.push(key("Files made local") + " : " + localizationStats.fileCount); localized.push(key("Total size") + " : " + (localizationStats.totalBytes / 1024).toFixed(2) + " kB"); localized.push(key("Unique mime types") + " : " + localizationStats.mimeCounts.size); if (localizationStats.mimeCounts.size > 0) { const mimeBreakdown = Array.from(localizationStats.mimeCounts.entries()) .sort((a, b) => b[1] - a[1]) .map(([mime, count]) => supportsColor ? `${mime}${needleDim(` ${count}×`)}` : `${mime} ${count}×`) .join(", "); localized.push(key("Mime types") + " : " + mimeBreakdown); } const remoteUrls = Array.from(map.keys()).filter(url => /^https?:\/\//i.test(url)); const previewLimit = 5; needleLog("needle:local-files", localized.join("\n"), "log", { dimBody: false }); if (remoteUrls.length > 0) { needleLog("needle:local-files", remoteUrls.slice(0, previewLimit).map(url => "- " + shorten(url)).join("\n") + (remoteUrls.length > previewLimit ? `\n... and ${remoteUrls.length - previewLimit} more` : ""), "log", { showHeader: false, dimBody: true }); } if (failedDownloads.size > 0) { const failed = ["Failed to make local:"]; for (const [url, message] of Array.from(failedDownloads.entries())) { const shortName = getShortUrlName(url); failed.push(" " + shortName + " (" + url + ")" + (message ? " - " + message : "")); } needleLog("needle:local-files", failed.join("\n"), "warn"); } } }); return plugin; } /** * @param {LocalizationOptions} options * @param {AutoPolicy | null} autoPolicy * @returns {UrlHandler[]} */ export function getActiveHandlers(options, autoPolicy) { let handlers = urlHandlers; const hasAuto = hasAutoFeatureSelection(options.features); if (hasAuto && Array.isArray(options.features) && options.features.length) { const detected = autoPolicy?.features ?? new Set(); const includeSet = new Set(options.features.filter(f => f !== "auto")); handlers = handlers.filter(h => detected.has(h.feature) || includeSet.has(h.feature)); } else if (hasAuto) { const detected = autoPolicy?.features ?? new Set(); handlers = handlers.filter(h => detected.has(h.feature)); } else if (Array.isArray(options.features) && options.features.length) { const includeSet = new Set(options.features.filter(f => f !== "auto")); handlers = handlers.filter(h => includeSet.has(h.feature)); } if (options.excludeFeatures?.length) { const excludeSet = new Set(options.excludeFeatures); handlers = handlers.filter(h => !excludeSet.has(h.feature)); } return handlers; } /** * @param {string} src * @returns {string} */ function stripComments(src) { let result = ''; let i = 0; const len = src.length; while (i < len) { const ch = src[i]; const next = i + 1 < len ? src[i + 1] : ''; if (ch === '"' || ch === "'" || ch === '`') { const quote = ch; result += ch; i++; while (i < len) { const c = src[i]; if (c === '\\') { result += src[i] + (i + 1 < len ? src[i + 1] : ''); i += 2; continue; } if (c === quote) { result += c; i++; break; } result += c; i++; } continue; } if (ch === '/' && next === '/') { const prev = i > 0 ? src[i - 1] : ''; if (prev !== ':') { while (i < len && src[i] !== '\n') { result += ' '; i++; } continue; } } if (ch === '/' && next === '*') { result += ' '; i += 2; while (i < len) { if (src[i] === '*' && i + 1 < len && src[i + 1] === '/') { result += ' '; i += 2; break; } result += src[i] === '\n' ? '\n' : ' '; i++; } continue; } result += ch; i++; } return result; } /** * @param {string} url * @param {LocalizationOptions} options * @returns {boolean} */ function shouldExclude(url, options) { if (url.includes("${")) return true; if (options.platform === "facebook-instant") { if (url.includes("connect.facebook.net")) return true; } if (options.exclude) { for (const pattern of options.exclude) { if (typeof pattern === "string") { if (url.includes(pattern)) return true; } else if (pattern instanceof RegExp) { if (pattern.test(url)) return true; } } } return false; } /** * @param {string} src * @param {string} stripped * @param {string} basePath * @param {string} currentDir * @param {LocalizationContext} context * @returns {Promise<string>} */ async function expandTemplateUrls(src, stripped, basePath, currentDir, context) { const expansions = context.options.templateExpansions; if (!expansions?.length) return src; for (const expansion of expansions) { const { cdnPrefix, variables } = expansion; if (!cdnPrefix || !variables || !Object.keys(variables).length) continue; const localPrefix = expansion.localPrefix || deriveLocalPrefix(cdnPrefix); const localBasePath = basePath + localPrefix + "/"; const escapedPrefix = cdnPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const templateRegex = new RegExp('`' + escapedPrefix + '([^`]*\\$\\{[^`]*)`', 'g'); const templateSuffixes = new Set(); let match; while ((match = templateRegex.exec(stripped)) !== null) { templateSuffixes.add(match[1]); } if (templateSuffixes.size === 0) continue; for (const suffix of Array.from(templateSuffixes)) { const expandedSuffixes = expandVariables(suffix, variables); for (const expanded of expandedSuffixes) { const concreteUrl = cdnPrefix + expanded; const localFilePath = localBasePath + expanded; if (context.cache.getFromCache(concreteUrl)) continue; try { const data = await downloadBinary(concreteUrl); if (context.command === 'build' && context.pluginContext) { context.pluginContext.emitFile({ type: 'asset', fileName: localFilePath, source: data, }); } context.cache.addToCache(concreteUrl, localFilePath); if (debug) console.log("[needle:local-files] Template expansion: " + concreteUrl + " → " + localFilePath); } catch (err) { needleLog("needle:local-files", "Failed to download template expansion: " + concreteUrl + " - " + getErrMessage(err), "warn", { dimBody: false }); } } } const replaceRegex = new RegExp('`' + escapedPrefix + '([^`]*\\$\\{[^`]*)`', 'g'); src = src.replace(replaceRegex, (/** @type {string} */ _fullMatch, /** @type {string} */ rest) => { const newPrefix = getRelativeToBasePath(localBasePath, currentDir); return '`' + newPrefix + rest + '`'; }); } return src; } /** * @param {string} cdnPrefix * @returns {string} */ function deriveLocalPrefix(cdnPrefix) { try { const url = new URL(cdnPrefix); const segments = url.pathname.split('/').filter(Boolean); return segments[segments.length - 1] || 'cdn'; } catch { return 'cdn'; } } /** * @param {string} template * @param {Record<string, string[]>} variables * @returns {string[]} */ function expandVariables(template, variables) { const varRefs = /** @type {string[]} */ ([]); const varRegex = /\$\{(\w+)\}/g; /** @type {RegExpExecArray | null} */ let m = null; while ((m = varRegex.exec(template)) !== null) { if (!varRefs.includes(m[1])) { varRefs.push(m[1]); } } for (const v of varRefs) { if (!variables[v]?.length) return []; } if (varRefs.length === 0) return [template]; const results = /** @type {string[]} */ ([]); /** * @param {number} idx * @param {Record<string, string>} current */ function expand(idx, current) { if (idx === varRefs.length) { let expanded = template; for (const [key, value] of Object.entries(current)) { expanded = expanded.split('${' + key + '}').join(value); } results.push(expanded); return; } const varName = varRefs[idx]; for (const value of variables[varName]) { current[varName] = value; expand(idx + 1, current); } } expand(0, {}); return results; } /** * @param {string} src * @param {string} basePath * @param {string} currentDir * @param {LocalizationContext} context * @param {UrlHandler[]} [handlers] * @returns {Promise<string>} */ export async function makeLocal(src, basePath, currentDir, context, handlers) { if (!handlers) handlers = urlHandlers; const stripped = stripComments(src); for (const handler of handlers) { handler.pattern.lastIndex = 0; const matches = /** @type {string[]} */ ([]); /** @type {RegExpExecArray | null} */ let match = null; while ((match = handler.pattern.exec(stripped)) !== null) { const url = match[1]; if (!url || url.length < 10) continue; if (shouldExclude(url, context.options)) continue; if (!shouldHandleUrlInAutoMode(url, handler, context)) continue; if (url.endsWith("/") && handler.type !== "decoder-dir" && handler.type !== "webxr-profiles") continue; matches.push(url); } if (matches.length === 0) continue; const uniqueUrls = Array.from(new Set(matches)); for (const url of uniqueUrls) { try { const handlerBasePath = getFeatureBasePath(basePath, handler.feature); if (handler.type === "decoder-dir") { const localPath = await handleDecoderDir(url, handlerBasePath, currentDir, handler, context); if (localPath) src = replaceAll(src, url, localPath); } else if (handler.type === "webxr-profiles") { const localPath = await handleWebXRProfiles(url, handlerBasePath, currentDir, context); if (localPath) src = replaceAll(src, url, localPath); } else if (handler.type === "css") { const localPath = await handleCssUrl(url, handlerBasePath, currentDir, context, handlers); if (localPath) src = replaceAll(src, url, localPath); } else { const localPath = await handleBinaryUrl(url, handlerBasePath, currentDir, context); if (localPath) src = replaceAll(src, url, localPath); } } catch (err) { recordFailedDownload(context, url, err); } } } src = await expandTemplateUrls(src, stripped, basePath, currentDir, context); return src; } /** * @param {string} html * @param {string} basePath * @param {LocalizationContext} context * @param {UrlHandler[]} [handlers] * @returns {Promise<string>} */ export async function makeLocalHtml(html, basePath, context, handlers) { if (!handlers) handlers = urlHandlers; for (const handler of handlers) { handler.pattern.lastIndex = 0; /** @type {RegExpExecArray | null} */ let match = null; const matches = /** @type {string[]} */ ([]); while ((match = handler.pattern.exec(html)) !== null) { const url = match[1]; if (!url || url.length < 10) continue; if (shouldExclude(url, context.options)) continue; if (!shouldHandleUrlInAutoMode(url, handler, context)) continue; if (url.endsWith("/") && handler.type !== "decoder-dir" && handler.type !== "webxr-profiles") continue; matches.push(url); } const uniqueUrls = Array.from(new Set(matches)); for (const url of uniqueUrls) { try { const handlerBasePath = getFeatureBasePath(basePath, handler.feature); if (handler.type === "css") { const localPath = await handleCssUrl(url, handlerBasePath, "", context, handlers); if (localPath) html = replaceAll(html, url, localPath); } else { const localPath = await handleBinaryUrl(url, handlerBasePath, "", context); if (localPath) html = replaceAll(html, url, localPath); } } catch (err) { recordFailedDownload(context, url, err); needleLog("needle:local-files", "Failed to make HTML URL local: " + url + " - " + getErrMessage(err), "warn", { dimBody: false }); } } } return html; } /** * @param {string} basePath * @param {string} feature * @returns {string} */ export function getFeatureBasePath(basePath, feature) { const normalized = basePath.endsWith("/") ? basePath : basePath + "/"; const mappedFeatureDir = mapFeatureToOutputDir(feature); if (normalized.endsWith("/" + mappedFeatureDir + "/")) return normalized; return normalized + mappedFeatureDir + "/"; } /** * @param {string} feature * @returns {string} */ function mapFeatureToOutputDir(feature) { if (feature === "cdn-scripts") return "scripts"; if (feature === "needle-fonts") return "fonts"; if (feature === "needle-avatars") return "xr/avatars"; if (feature === "polyhaven") return "skybox"; return feature; } /** * @param {string} url * @param {string} basePath * @param {string} currentDir * @param {LocalizationContext} context * @param {UrlHandler[]} handlers * @returns {Promise<string|undefined>} */ async function handleCssUrl(url, basePath, currentDir, context, handlers) { const cached = context.cache.getFromCache(url); if (typeof cached === 'string') return cached; let cssContent = await downloadText(url); cssContent = await makeLocal(cssContent, basePath, basePath, context, handlers); recordLocalizedAsset(context.localizationStats, url, Buffer.byteLength(cssContent, "utf8"), "text/css"); const familyNameMatch = /family=([^&]+)/.exec(url); const familyName = familyNameMatch ? getValidFilename(familyNameMatch[1], cssContent) : getValidFilename(url, cssContent); const fileName = "font-" + familyName + ".css"; const outputPath = basePath + fileName; /** @type {string | undefined} */ let newPath; if (context.command === 'build' && context.pluginContext) { const referenceId = context.pluginContext.emitFile({ type: 'asset', fileName: outputPath, source: cssContent, }); const localPath = "" + context.pluginContext.getFileName(referenceId); newPath = getRelativeToBasePath(localPath, currentDir); } else { const base64 = Buffer.from(cssContent).toString('base64'); newPath = "data:text/css;base64," + base64; } if (newPath) context.cache.addToCache(url, newPath); return newPath; } /** * @param {string} url * @param {string} basePath * @param {string} currentDir * @param {LocalizationContext} context * @returns {Promise<string|undefined>} */ async function handleBinaryUrl(url, basePath, currentDir, context) { const cached = context.cache.getFromCache(url); if (typeof cached === 'string') return cached; const data = await downloadBinary(url); recordLocalizedAsset(context.localizationStats, url, data.length, inferMimeType(url)); const filename = getValidFilename(url, data); /** @type {string | undefined} */ let newPath; if (context.command === 'build' && context.pluginContext) { const referenceId = context.pluginContext.emitFile({ type: 'asset', fileName: basePath + filename, source: data, }); const localPath = "" + context.pluginContext.getFileName(referenceId); newPath = getRelativeToBasePath(localPath, currentDir); } else { const base64 = Buffer.from(data).toString('base64'); newPath = "data:application/octet-stream;base64," + base64; } if (newPath) context.cache.addToCache(url, newPath); return newPath; } /** * @param {string} baseUrl * @param {string} basePath * @param {string} currentDir * @param {UrlHandler} handler * @param {LocalizationContext} context * @returns {Promise<string>} */ async function handleDecoderDir(baseUrl, basePath, currentDir, handler, context) { const cached = context.cache.getFromCache(baseUrl); if (typeof cached === 'string') return cached; const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; const localDir = ensureTrailingSlash(basePath); const files = handler.decoderFiles || []; for (const file of files) { const fileUrl = normalizedBaseUrl + file; const fileCached = context.cache.getFromCache(fileUrl); if (fileCached) continue; try { const data = await downloadBinary(fileUrl); recordLocalizedAsset(context.localizationStats, fileUrl, data.length, inferMimeType(fileUrl)); if (context.command === 'build' && context.pluginContext) { const referenceId = context.pluginContext.emitFile({ type: 'asset', fileName: localDir + file, source: data, }); const localPath = "" + context.pluginContext.getFileName(referenceId); context.cache.addToCache(fileUrl, localPath); } } catch (err) { recordFailedDownload(context, fileUrl, err); needleLog("needle:local-files", "Failed to download decoder file: " + fileUrl + " - " + getErrMessage(err), "warn", { dimBody: false }); } } const newBasePath = getRelativeToBasePath(localDir, currentDir); context.cache.addToCache(baseUrl, newBasePath); return newBasePath; } /** * @param {string} baseUrl * @param {string} basePath * @param {string} currentDir * @param {LocalizationContext} context * @returns {Promise<string>} */ export async function handleWebXRProfiles(baseUrl, basePath, currentDir, context) { const cached = context.cache.getFromCache(baseUrl); if (typeof cached === 'string') return cached; const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; const localDir = basePath + "webxr-profiles/"; const profiles = context.autoPolicy?.selectedWebXRProfiles?.length ? context.autoPolicy.selectedWebXRProfiles : getWebXRProfilesForMode(context.options.webxr); try { const profilesListUrl = normalizedBaseUrl + "profilesList.json"; const profilesList = await downloadBinary(profilesListUrl); recordLocalizedAsset(context.localizationStats, profilesListUrl, profilesList.length, "application/json"); if (context.command === 'build' && context.pluginContext) { context.pluginContext.emitFile({ type: 'asset', fileName: localDir + "profilesList.json", source: profilesList, }); context.cache.addToCache(profilesListUrl, localDir + "profilesList.json"); } } catch (err) { recordFailedDownload(context, normalizedBaseUrl + "profilesList.json", err); needleLog("needle:local-files", "Failed to download profilesList.json: " + getErrMessage(err), "warn", { dimBody: false }); } for (const profile of profiles) { const profileDir = localDir + profile + "/"; const profileBaseUrl = normalizedBaseUrl + profile + "/"; const filesToDownload = [ "profile.json", "left.glb", "right.glb", ]; for (const file of filesToDownload) { const fileUrl = profileBaseUrl + file; const fileCached = context.cache.getFromCache(fileUrl); if (fileCached) continue; try { const data = await downloadBinary(fileUrl); recordLocalizedAsset(context.localizationStats, fileUrl, data.length, inferMimeType(fileUrl)); if (context.command === 'build' && context.pluginContext) { context.pluginContext.emitFile({ type: 'asset', fileName: profileDir + file, source: data, }); context.cache.addToCache(fileUrl, profileDir + file); } } catch (err) { recordFailedDownload(context, fileUrl, err); if (debug) needleLog("needle:local-files", "Failed to download WebXR profile file: " + fileUrl + " - " + getErrMessage(err), "warn", { dimBody: false }); } } } const newBasePath = getRelativeToBasePath(localDir, currentDir); context.cache.addToCache(baseUrl, newBasePath); return newBasePath; } /** * @param {LocalizationContext} context * @param {UrlHandler[]} handlers * @returns {Promise<void>} */ async function prefetchConfiguredAssets(context, handlers) { const skyboxHandler = handlers.find(h => h.feature === "skybox"); if (!skyboxHandler) return; const configuredSkyboxUrls = resolveSkyboxSelectionUrls(context.options.skybox, new Set()); if (!configuredSkyboxUrls || configuredSkyboxUrls.size === 0) { if (context.options.skybox === "all") { for (const keyword of SKYBOX_MAGIC_KEYWORDS) { const url = resolveSkyboxValueToUrl(keyword); if (!url) continue; try { await handleBinaryUrl(url, getFeatureBasePath("ext/", "skybox"), "", context); } catch (err) { recordFailedDownload(context, url, err); if (debug) needleLog("needle:local-files", "Failed to prefetch skybox: " + url + " - " + getErrMessage(err), "warn", { dimBody: false }); } } } return; } for (const url of configuredSkyboxUrls) { try { await handleBinaryUrl(url, getFeatureBasePath("ext/", "skybox"), "", context); } catch (err) { recordFailedDownload(context, url, err); if (debug) console.warn("[needle:local-files] Failed to prefetch configured skybox: " + url + " - " + getErrMessage(err)); } } }