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.

353 lines (316 loc) 13.6 kB
// @ts-check let isStringifying = false; /** * Patches console methods to capture log messages and send them to the server. * This is useful for debugging and logging in the client. * @param {"log" | "warn" | "info" | "debug" | "error" | "internal"} level * @param {...unknown} message - The log message to capture. */ function sendLogToServer(level, ...message) { if (isStringifying) return; if ("hot" in import.meta) { try { isStringifying = true; // console.time("sendLogToServer"); let msg = /** @type {string} */ (stringifyLog(message)); // console.timeEnd("sendLogToServer"); // keep messages below payload limit if (msg.length > 100_000) { msg = msg.slice(0, 100_000) + "... <truncated>"; } // @ts-ignore import.meta.hot.send("needle:client-log", { level, message: msg }); } catch (/** @type {any} */ e) { // silently fail but send a message try { import.meta.hot?.send("needle:client-log", { level: "error", message: `Error during logging: ${e?.message ?? String(e)}` }); } catch (/** @type {unknown} */ e2) { // fallback failed as well } } finally { isStringifying = false; } } } /** @param {(...args: unknown[]) => void} fn @param {unknown[]} args */ function logHelper(fn, args) { const error = new Error(); const stack = error.stack; const caller = stack?.split('\n')[3]; // Get the actual caller const path = caller?.trim(); if (!path) { return fn(...args); } const pathWithoutBrackets = path.replaceAll("(", "").replaceAll(")", ""); fn(...args, `\n» ${pathWithoutBrackets}`); } if (import.meta && "hot" in import.meta) { function patchLogs() { const originalLog = /** @type {(...args: unknown[]) => void} */ (console.log.bind(console)); const originalWarn = /** @type {(...args: unknown[]) => void} */ (console.warn.bind(console)); const originalInfo = /** @type {(...args: unknown[]) => void} */ (console.info.bind(console)); const originalDebug = /** @type {(...args: unknown[]) => void} */ (console.debug.bind(console)); const originalError = /** @type {(...args: unknown[]) => void} */ (console.error.bind(console)); console.log = /** @type {(...args: unknown[]) => void} */ (function (...args) { logHelper(originalLog, args); sendLogToServer("log", ...args); }) console.warn = /** @type {(...args: unknown[]) => void} */ ((...args) => { logHelper(originalWarn, args); sendLogToServer("warn", ...args); }) console.info = /** @type {(...args: unknown[]) => void} */ ((...args) => { logHelper(originalInfo, args); sendLogToServer("info", ...args); }) console.debug = /** @type {(...args: unknown[]) => void} */ ((...args) => { logHelper(originalDebug, args); sendLogToServer("debug", ...args); }) console.error = /** @type {(...args: unknown[]) => void} */ ((...args) => { logHelper(originalError, args); sendLogToServer("error", ...args); }) return () => { console.log = originalLog; console.warn = originalWarn; console.info = originalInfo; console.debug = originalDebug; console.error = originalError; } } const query = new URLSearchParams(window.location.search); if (query.has("needle-debug")) { patchLogs(); } else { // const unpatch = patchLogs(); // setTimeout(() => { // sendLogToServer("internal", "Stop listening to console.log."); // unpatch(); // }, 10_000); const threshold = 100; const devToolsArePotentiallyOpen = window.outerHeight - window.innerHeight > threshold || window.outerWidth - window.innerWidth > threshold; if (devToolsArePotentiallyOpen) { sendLogToServer("internal", "Console logging is disabled (devtools are open)"); } else { sendLogToServer("internal", "Console logging is enabled"); patchLogs(); } } try { sendLogToServer("internal", `Page loaded URL: ${window.location.href} UserAgent: ${navigator.userAgent} Screen: ${window.innerWidth} x ${window.innerHeight}px Device Pixel Ratio: ${window.devicePixelRatio} Device Memory: ${"deviceMemory" in navigator ? navigator.deviceMemory : "Not available"} GB Online: ${navigator.onLine} Language: ${navigator.language} Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone} Connection: ${"connection" in navigator ? JSON.stringify(navigator.connection) : "Not available"} User Activation: ${"userActivation" in navigator ? JSON.stringify(navigator.userActivation) : "Not available"} `); /** @typedef {{ vendor?: string, architecture?: string, device?: string, description?: string, features?: unknown, limits?: unknown }} GPUAdapterInfoLike */ if ("gpu" in navigator) { // @ts-ignore navigator.gpu.requestAdapter() .then(/** @type {(adapter: {requestDevice(): Promise<{adapterInfo: GPUAdapterInfoLike}>}|null) => Promise<{adapterInfo: GPUAdapterInfoLike}>|null} */ (adapter) => adapter ? adapter.requestDevice() : null) .then(/** @type {(device: {adapterInfo: GPUAdapterInfoLike}|null) => void} */ (device) => { if (device) { const adapterInfo = device.adapterInfo; if (adapterInfo) { sendLogToServer("internal", [`WebGPU adapter info`, { vendor: adapterInfo.vendor, architecture: adapterInfo.architecture, device: adapterInfo.device, description: adapterInfo.description, features: adapterInfo.features, limits: adapterInfo.limits }]); } } }); } } catch (/** @type {any} */ e) { // silently fail sendLogToServer("error", `Error during initial log: ${e?.message ?? String(e)}`); } window.addEventListener('error', (event) => { const typedErrorEvent = /** @type {{ error: unknown, message: string }} */ (/** @type {unknown} */ (event)); const error = /** @type {{ stack?: string, message?: string } | null | undefined} */ (typedErrorEvent.error); const errorMessage = error ? error.stack || error.message : typedErrorEvent.message; sendLogToServer("error", errorMessage); }); window.addEventListener('unhandledrejection', (event) => { const typedRejEvent = /** @type {{ reason: unknown }} */ (/** @type {unknown} */ (event)); const rejectionReason = /** @type {{ stack?: string, message?: string } | null | undefined} */ (typedRejEvent.reason); const reason = rejectionReason ? rejectionReason.stack || rejectionReason.message : "Unhandled rejection without reason"; sendLogToServer("error", `Unhandled promise rejection: ${reason}`); }); window.addEventListener('beforeunload', () => { sendLogToServer("internal", "Page is unloading\n\n"); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { sendLogToServer("internal", "Page is hidden"); } else if (document.visibilityState === 'visible') { sendLogToServer("internal", "Page is visible again"); } else { sendLogToServer("internal", `Page visibility changed to ${document.visibilityState}`); } }); window.addEventListener("focus", () => { sendLogToServer("internal", "Page gained focus"); }); window.addEventListener("blur", () => { sendLogToServer("internal", "Page lost focus"); }); window.addEventListener('load', () => { sendLogToServer("internal", "Page fully loaded"); }); window.addEventListener('DOMContentLoaded', () => { sendLogToServer("internal", "DOM fully loaded and parsed"); }); window.addEventListener('online', () => { sendLogToServer("internal", "Browser is online"); }); window.addEventListener('offline', () => { sendLogToServer("warn", "Browser is offline"); }); window.addEventListener('resize', () => { sendLogToServer("internal", `Window resized to ${window.innerWidth}x${window.innerHeight}px`); }); window.addEventListener('orientationchange', () => { sendLogToServer("internal", `Orientation changed to ${screen.orientation.type}`); }); window.addEventListener('fullscreenchange', () => { if (document.fullscreenElement) { sendLogToServer("internal", "Entered fullscreen mode"); } else { sendLogToServer("internal", "Exited fullscreen mode"); } }); // url change event window.addEventListener('hashchange', () => { sendLogToServer("internal", `URL hash changed to ${location.hash}`); }); window.addEventListener('popstate', () => { sendLogToServer("internal", `History state changed: ${JSON.stringify(/** @type {{ state: unknown }} */ (history).state)}`); }); } // #region copied from common/logger.js /** * Stringifies a log message, handling circular references and formatting. * @param {unknown} log * @param {Set<unknown>} [seen] */ function stringifyLog(log, seen = /** @type {Set<unknown>} */ (new Set()), depth = 0) { const isServer = typeof window === "undefined"; const stringify_limits = { string: isServer ? 100_000 : 1_000, object_keys: isServer ? 300 : 200, object_depth: isServer ? 10 : 3, array_items: isServer ? 2_000 : 100, } if (typeof log === "string") { if (log.length > stringify_limits.string) log = `${log.slice(0, stringify_limits.string)}... <truncated ${log.length - stringify_limits.string} characters>`; return log; } if (typeof log === "number" || typeof log === "boolean") { return String(log); } if (log === null) { return "null"; } if (log === undefined) { return "undefined"; } if (typeof log === "function") { return "<function>"; } if (seen.has(log)) return "<circular>"; if (Array.isArray(log) || log instanceof ArrayBuffer || log instanceof Uint8Array || log instanceof Float32Array || log instanceof Int32Array || log instanceof Uint32Array || log instanceof Uint16Array || log instanceof Uint8ClampedArray || log instanceof Int16Array || log instanceof Int8Array || log instanceof BigInt64Array || log instanceof BigUint64Array || log instanceof Float64Array ) { const logArr = /** @type {ArrayLike<unknown>} */ (/** @type {unknown} */ (log)); seen.add(logArr); return stringifyArray(logArr); } if (typeof log === "object") { if (depth > stringify_limits.object_depth) { return "<object too deep>"; } seen.add(log); if (log instanceof Error) { return `<Error: ${log.message}\nStack: ${log.stack}>`; } const logObj = /** @type {Record<string, unknown>} */ (log); const keys = Object.keys(logObj); let res = "{"; for (let i = 0; i < keys.length; i++) { const key = keys[i]; let value = logObj[key]; if (i >= stringify_limits.object_keys) { res += `, ... <truncated ${keys.length - i} keys>`; break; } if (typeof value === "number") { // clamp precision for numbers if it has decimal places if (value % 1 !== 0) { value = Number(value.toFixed(4)); } } let str = stringifyLog(value, seen, depth + 1); if (typeof value === "object") { if (Array.isArray(value)) { str = `[${str}]`; } } else if (typeof value === "string") { str = `"${str}"`; } if (i > 0) res += ", "; res += `"${key}":${str}`; } res += "}"; return res; // let entries = Object.entries(log).map(([key, value], index) => { // if (index > stringify_limits.object_keys) return `"${key}": <truncated>`; // return `"${key}": ${stringifyLog(value, seen, depth + 1)}`; // }); // return `{ ${entries.join(", ")} }`; } return String(log); /** @param {ArrayLike<unknown>} arr @returns {string} */ function stringifyArray(arr) { let res = ""; for (let i = 0; i < arr.length; i++) { let entry = arr[i]; if (res && i > 0) res += ", "; if (i > stringify_limits.array_items) { res += "<truncated " + (arr.length - i) + ">"; break; } res += stringifyLog(entry, seen, depth + 1); } return res; } }