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