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.

364 lines (323 loc) 11.2 kB
// @ts-check import https from 'https'; import http from 'http'; import { createHash } from 'crypto'; import { needleLog, setTransientLogLineCleaner } from './logging.js'; /** @typedef {import('./local-files-types.js').LocalizationContext} LocalizationContext */ const debug = false; const DOWNLOAD_TIMEOUT_MS = 30_000; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let spinnerIndex = 0; let spinnerActive = false; setTransientLogLineCleaner(() => clearSpinnerLine()); function clearSpinnerLine() { if (!process.stdout.isTTY || !spinnerActive) return; process.stdout.write("\r\x1b[2K"); spinnerActive = false; } /** @param {string} url */ function updateMakeLocalProgress(url) { if (!process.stdout.isTTY) { needleLog("needle:local-files", "Make local: " + url, "log", { dimBody: true, showHeader: false }); return; } const frame = SPINNER_FRAMES[spinnerIndex++ % SPINNER_FRAMES.length]; const maxLength = Math.max(24, (process.stdout.columns || 120) - 4); const message = `Make local: ${url}`; const text = message.length > maxLength ? `${message.slice(0, Math.max(0, maxLength - 1))}…` : message; process.stdout.write(`\r\x1b[2K${frame} ${text}\x1b[0K`); spinnerActive = true; } export function finishMakeLocalProgress() { clearSpinnerLine(); } /** * @param {string} src * @param {string} search * @param {string} replacement * @returns {string} */ export function replaceAll(src, search, replacement) { return src.split(search).join(replacement); } export class Cache { /** @type {Map<string, Buffer|string>} */ __cache = new Map(); /** * @param {string} key * @param {Buffer|string} value */ addToCache(key, value) { if (debug && this.__cache.has(key)) { console.warn("Key " + key + " already exists in cache, overwriting."); } this.__cache.set(key, value); } /** * @param {string} key * @returns {Buffer|string|null} */ getFromCache(key) { return this.__cache.get(key) ?? null; } get map() { return this.__cache; } } /** * @param {string} path * @param {string} basePath * @returns {string} */ export function getRelativeToBasePath(path, basePath) { const normalizedPath = normalizeWebPath(path); const normalizedBasePath = normalizeWebPath(basePath); if (!normalizedBasePath) return normalizedPath; if (normalizedPath.startsWith(normalizedBasePath)) { return "./" + normalizedPath.substring(normalizedBasePath.length); } const baseSegments = normalizedBasePath.replace(/\/+$/g, "").split("/").filter(Boolean); const backtrack = baseSegments.map(() => "..").join("/"); return (backtrack ? backtrack + "/" : "") + normalizedPath; } /** * @param {string} path * @returns {string} */ export function normalizeWebPath(path) { if (!path) return ""; return path.replace(/\\/g, "/"); } /** * @param {string} path * @returns {string} */ export function ensureTrailingSlash(path) { if (!path) return ""; return path.endsWith("/") ? path : path + "/"; } /** * @param {string} src * @returns {string} */ export function fixRelativeNewURL(src) { src = src.replace( /(?<==\s*)(["'])((?:(?:\.{1,2}\/)|\/)?ext\/[^"']*\/)\1/g, (/** @type {string} */ _match, /** @type {string} */ quote, /** @type {string} */ path) => { const runtimePath = path.startsWith("/ext/") ? path.substring(1) : path; return `new URL(${quote}${runtimePath}${quote}, self.location?.href || ${quote}${quote}).href`; } ); src = src.replace( /new\s+URL\s*\(\s*(["'`])((?:(?:\.{1,2}\/)|\/)?ext\/[^"'`]+)\1\s*\)/g, (/** @type {string} */ _match, /** @type {string} */ quote, /** @type {string} */ path) => { const runtimePath = path.startsWith("/ext/") ? path.substring(1) : path; return `new URL(${quote}${runtimePath}${quote}, self.location?.href)`; } ); return src; } /** * @param {string} src * @returns {string} */ export function fixDracoRangeQueryProbe(src) { return src; } /** * @param {string} path * @param {string|Buffer} content * @returns {string} */ export function getValidFilename(path, content) { if (path.startsWith("http:") || path.startsWith("https:")) { const url = new URL(path); const pathParts = url.pathname.split('/'); const filename = pathParts.pop() || 'file'; const nameParts = filename.split('.'); const nameWithoutExt = nameParts.slice(0, -1).join('.'); path = nameWithoutExt + "-" + createContentMd5(url.host + pathParts.join('/')) + "." + (nameParts.pop() || 'unknown'); } let name = path.replace(/[^a-z0-9_\-\.\+]/gi, '-'); const maxLength = 200; if (path.length > maxLength) { const hash = createContentMd5(content); let ext = ""; const extIndex = name.lastIndexOf('.'); if (extIndex !== -1) { ext = name.substring(extIndex + 1); name = name.substring(0, extIndex); } name = name.substring(0, maxLength) + "-" + hash + (ext ? "." + ext : ''); } return name; } /** * @param {string | Buffer} str * @returns {string} */ function createContentMd5(str) { return createHash('md5') .update(str) .digest('hex'); } /** * @param {string} url * @returns {Promise<string>} */ export function downloadText(url) { return new Promise((res, rej) => { const timer = setTimeout(() => { rej(new Error("Download timed out after " + DOWNLOAD_TIMEOUT_MS + "ms: " + url)); }, DOWNLOAD_TIMEOUT_MS); const req = (url.startsWith("http://") ? http.get(url, (response) => { if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { clearTimeout(timer); downloadText(response.headers.location).then(res).catch(rej); return; } if (response.statusCode !== 200) { clearTimeout(timer); clearSpinnerLine(); rej(new Error("Failed to download (" + response.statusCode + "): " + url)); return; } updateMakeLocalProgress(url); let data = ''; response.on('data', (/** @type {Buffer|string} */ chunk) => { data += chunk; }); response.on('end', () => { clearTimeout(timer); res(data); }); response.on('error', (err) => { clearTimeout(timer); rej(err); }); }) : https.get(url, (response) => { if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { clearTimeout(timer); downloadText(response.headers.location).then(res).catch(rej); return; } if (response.statusCode !== 200) { clearTimeout(timer); clearSpinnerLine(); rej(new Error("Failed to download (" + response.statusCode + "): " + url)); return; } updateMakeLocalProgress(url); let data = ''; response.on('data', (/** @type {Buffer|string} */ chunk) => { data += chunk; }); response.on('end', () => { clearTimeout(timer); res(data); }); response.on('error', (err) => { clearTimeout(timer); rej(err); }); })); req.on('error', (err) => { clearTimeout(timer); clearSpinnerLine(); rej(err); }); }); } /** * @param {string} url * @returns {Promise<Buffer>} */ export function downloadBinary(url) { return new Promise((res, rej) => { const timer = setTimeout(() => { rej(new Error("Download timed out after " + DOWNLOAD_TIMEOUT_MS + "ms: " + url)); }, DOWNLOAD_TIMEOUT_MS); const req = (url.startsWith("http://") ? http.get(url, (response) => { if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { clearTimeout(timer); downloadBinary(response.headers.location).then(res).catch(rej); return; } if (response.statusCode !== 200) { clearTimeout(timer); clearSpinnerLine(); rej(new Error("Failed to download (" + response.statusCode + "): " + url)); return; } updateMakeLocalProgress(url); const chunks = /** @type {Buffer[]} */ ([]); response.on('data', (/** @type {Buffer} */ chunk) => { chunks.push(chunk); }); response.on('end', () => { clearTimeout(timer); res(Buffer.concat(chunks)); }); response.on('error', (err) => { clearTimeout(timer); rej(err); }); }) : https.get(url, (response) => { if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { clearTimeout(timer); downloadBinary(response.headers.location).then(res).catch(rej); return; } if (response.statusCode !== 200) { clearTimeout(timer); clearSpinnerLine(); rej(new Error("Failed to download (" + response.statusCode + "): " + url)); return; } updateMakeLocalProgress(url); const chunks = /** @type {Buffer[]} */ ([]); response.on('data', (/** @type {Buffer} */ chunk) => { chunks.push(chunk); }); response.on('end', () => { clearTimeout(timer); res(Buffer.concat(chunks)); }); response.on('error', (err) => { clearTimeout(timer); rej(err); }); })); req.on('error', (err) => { clearTimeout(timer); clearSpinnerLine(); rej(err); }); }); } /** * @param {LocalizationContext} context * @param {string} url * @param {unknown} err */ export function recordFailedDownload(context, url, err) { if (!url || !context?.failedDownloads) return; const message = err instanceof Error ? err.message : ""; if (!context.failedDownloads.has(url)) { context.failedDownloads.set(url, message); } } /** * @param {string} url * @returns {string} */ export function getShortUrlName(url) { try { const parsed = new URL(url); const parts = parsed.pathname.split("/").filter(Boolean); return parts[parts.length - 1] || parsed.host; } catch { return url; } }