@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
JavaScript
// @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));
}
}
}