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.

597 lines (522 loc) 21.2 kB
// @ts-check import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync, write } from "fs"; const filename_timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const debug = false; // #region public api /** * @typedef {"server" | "client" | "client-http"} ProcessType */ let originalConsoleLog = console.log; let originalConsoleError = console.error; let originalConsoleWarn = console.warn; let originalConsoleInfo = console.info; let originalConsoleDebug = console.debug; let didPatch = false; /** @type {(() => void) | null} */ let unpatchFunction = null; const buildOutputState = { mode: "serve", progressActive: false, assetFiles: 0, assetTotalKb: 0, assetGzipKb: 0, compressionFiles: 0, compressionTotalKb: 0, compressionGzipKb: 0, compressionCaptureActive: false, }; /** * Parses a numeric kB value from a potentially formatted string (e.g. "1.23 kB"), * stripping all non-numeric characters except the decimal point. * Returns 0 for any value that cannot be parsed as a finite number. * @param {unknown} value @returns {number} */ function parseKb(value) { const numeric = Number.parseFloat(String(value ?? "0").replace(/[^0-9.]/g, "")); return Number.isFinite(numeric) ? numeric : 0; } /** * Clears the current in-place progress line from the terminal, if one is active. * Has no effect in non-TTY environments (CI, piped output). */ function clearBuildProgressLine() { if (!process.stdout.isTTY || !buildOutputState.progressActive) return; process.stdout.write("\r\x1b[2K"); buildOutputState.progressActive = false; } /** * Overwrites the current terminal line with a transient build progress indicator. * Subsequent calls overwrite the same line in place, keeping the output clean. * Has no effect in non-TTY environments (CI, piped output). * @param {string} text */ function writeBuildProgressLine(text) { if (!process.stdout.isTTY) return; const maxLength = Math.max(24, (process.stdout.columns || 120) - 1); const line = text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text; process.stdout.write(`\r\x1b[2K${line}\x1b[0K`); buildOutputState.progressActive = true; } /** * Resets all per-build asset and compression counters back to zero. * Called at the start of each build and after summaries are flushed. */ function resetBuildOutputState() { buildOutputState.progressActive = false; buildOutputState.assetFiles = 0; buildOutputState.assetTotalKb = 0; buildOutputState.assetGzipKb = 0; buildOutputState.compressionFiles = 0; buildOutputState.compressionTotalKb = 0; buildOutputState.compressionGzipKb = 0; buildOutputState.compressionCaptureActive = false; } /** * Emits aggregated bundle/compression summary lines to stdout, then resets counters. * * During a build, Vite normally prints one line per output file (e.g. * "dist/foo.js 1.23 kB │ gzip: 0.45 kB"). Those lines are intercepted by * tryHandleBuildConsoleOutput, which silently accumulates file counts and sizes * instead of printing them. This function is called once bundling finishes (when * Vite emits "✓ built in …") and replaces all the per-file lines with a single * formatted summary like "[needle-buildinfo]\n✓ Bundled 27 files (1234.56 kB)". */ function flushBuildOutputSummaries() { if (buildOutputState.mode !== "build") return; clearBuildProgressLine(); if (buildOutputState.assetFiles > 0) { const msg = formatBuildInfoSummaryMessage(`✓ Bundled ${buildOutputState.assetFiles} files (${buildOutputState.assetTotalKb.toFixed(2)} kB${buildOutputState.assetGzipKb > 0 ? `, gzip ${buildOutputState.assetGzipKb.toFixed(2)} kB` : ""})`); originalConsoleLog(msg); captureLogMessage("server", "log", msg, null); } if (buildOutputState.compressionFiles > 0) { const msg = formatBuildInfoSummaryMessage(`✓ Gzip compressed ${buildOutputState.compressionFiles} files (${buildOutputState.compressionTotalKb.toFixed(2)} kB → ${buildOutputState.compressionGzipKb.toFixed(2)} kB)`); originalConsoleLog(msg); captureLogMessage("server", "log", msg, null); } resetBuildOutputState(); } /** * Returns true if stdout is a TTY and the NO_COLOR environment variable is not set. * Used to guard ANSI escape sequences so output stays clean in CI and piped contexts. */ function supportsColorOutput() { return !!process.stdout?.isTTY && process.env.NO_COLOR !== "1"; } /** * Formats a plugin name as a styled [header] string. * Names starting with "needle-" or "needle:" get ANSI green coloring with a bold suffix. * Falls back to plain "[name]" when color output is not supported. * @param {string} name @returns {string} */ function formatNeedleHeader(name) { if (!supportsColorOutput()) return `[${name}]`; if (name.startsWith("needle-")) { const suffix = name.substring("needle-".length); return `\x1b[32m[\x1b[0m\x1b[32mneedle-\x1b[0m\x1b[1;32m${suffix}\x1b[0m\x1b[32m]\x1b[0m`; } if (name.startsWith("needle:")) { const suffix = name.substring("needle:".length); return `\x1b[32m[\x1b[0m\x1b[32mneedle:\x1b[0m\x1b[1;32m${suffix}\x1b[0m\x1b[32m]\x1b[0m`; } return `\x1b[32m[\x1b[0m\x1b[1;32m${name}\x1b[0m\x1b[32m]\x1b[0m`; } /** * Wraps a build summary string under the [needle-buildinfo] header. * Used by flushBuildOutputSummaries to format the final "✓ Bundled N files" * and "✓ Gzip compressed N files" lines. * @param {string} body @returns {string} */ function formatBuildInfoSummaryMessage(body) { return `${formatNeedleHeader("needle-buildinfo")}\n${body}`; } /** * Joins an array of console.log arguments into a single string, * serializing non-string values with stringifyLog. * @param {unknown[]} args @returns {string} */ function normalizeConsoleArgs(args) { return args.map(arg => typeof arg === "string" ? arg : stringifyLog(arg)).join(" "); } /** * Intercepts console output during a build run and decides whether to suppress or forward it. * * - Per-file asset lines ("dist/foo.js 1.23 kB") are silently accumulated into * buildOutputState counters and replaced by a summary via flushBuildOutputSummaries. * - vite-plugin-compression per-file lines are similarly accumulated. * - "transforming (N)" progress messages are rendered as an animated in-place indicator. * - Any other message (including "✓ built in …") triggers flushBuildOutputSummaries * before passing through. * * Returns true if the message was fully consumed (caller must not forward to original * console), or false if it should pass through normally. * @param {unknown[]} args @returns {boolean} */ function tryHandleBuildConsoleOutput(args) { if (buildOutputState.mode !== "build") return false; const raw = normalizeConsoleArgs(args); const message = raw.replace(/\u001b\[[0-9;]*m/g, "").trim(); if (!message.length) return true; if (/^transforming\s*\(/i.test(message)) { writeBuildProgressLine(`⏳ ${message}`); return true; } if (message.includes("[vite-plugin-compression]:algorithm=")) { buildOutputState.compressionCaptureActive = true; return true; } const assetMatch = message.match(/^dist\/.*?\s+([0-9.,]+)\s*kB(?:\s*[│|]\s*gzip:\s*([0-9.,]+)\s*kB)?$/i); if (assetMatch) { buildOutputState.assetFiles++; buildOutputState.assetTotalKb += parseKb(assetMatch[1]); if (assetMatch[2]) buildOutputState.assetGzipKb += parseKb(assetMatch[2]); writeBuildProgressLine(`📦 Bundling assets: ${buildOutputState.assetFiles} files`); return true; } if (buildOutputState.compressionCaptureActive) { const compressionMatch = message.match(/^dist\/.*?\s+([0-9.]+)\s*kb\s*\/\s*gzip:\s*([0-9.]+)\s*kb$/i); if (compressionMatch) { buildOutputState.compressionFiles++; buildOutputState.compressionTotalKb += parseKb(compressionMatch[1]); buildOutputState.compressionGzipKb += parseKb(compressionMatch[2]); writeBuildProgressLine(`🗜️ Compressing: ${buildOutputState.compressionFiles} files`); return true; } } if (message.startsWith("✓ built in")) { flushBuildOutputSummaries(); return false; } flushBuildOutputSummaries(); return false; } /** * Patches the global console methods to intercept and process Vite build output. * * In build mode, per-file asset lines emitted by Vite (and vite-plugin-compression) * are absorbed silently, counts and sizes are accumulated, and a concise summary is * printed by flushBuildOutputSummaries once bundling completes. In serve mode all * messages pass through unchanged. * * All console output (log/error/warn/info/debug) is also written to a rotating * timestamped log file under node_modules/.needle/logs/ via captureLogMessage. * * Calling patchConsoleLogs a second time is a no-op and returns the same unpatch * function. Call the returned function to restore the original console methods and * flush any pending summaries. * * @param {{command?: string} | undefined} [options] * @returns {(() => void) | null} */ export function patchConsoleLogs(options = undefined) { if (didPatch) return unpatchFunction; didPatch = true; buildOutputState.mode = options?.command === "build" ? "build" : "serve"; resetBuildOutputState(); console.log = (...args) => { if (tryHandleBuildConsoleOutput(args)) return; originalConsoleLog(...args); captureLogMessage("server", 'log', args, null); }; console.error = (...args) => { flushBuildOutputSummaries(); originalConsoleError(...args); captureLogMessage("server", 'error', args, null); }; console.warn = (...args) => { flushBuildOutputSummaries(); originalConsoleWarn(...args); captureLogMessage("server", 'warn', args, null); }; console.info = (...args) => { if (tryHandleBuildConsoleOutput(args)) return; originalConsoleInfo(...args); captureLogMessage("server", 'info', args, null); }; console.debug = (...args) => { if (tryHandleBuildConsoleOutput(args)) return; originalConsoleDebug(...args); captureLogMessage("server", 'debug', args, null); }; // Restore original console methods unpatchFunction = () => { flushBuildOutputSummaries(); didPatch = false; console.log = originalConsoleLog; console.error = originalConsoleError; console.warn = originalConsoleWarn; console.info = originalConsoleInfo; console.debug = originalConsoleDebug; buildOutputState.mode = "serve"; } return unpatchFunction; } let isCapturing = false; /** @type {Set<unknown>} */ const isCapturingLogMessage = new Set(); /** @type {Array<{ process: ProcessType, key: string, log:unknown, timestamp:number, connectionId: string | null }>} */ const queue = new Array(); /** * Serializes a log message and appends it to the rotating per-process log file * (node_modules/.needle/logs/<timestamp>.server.needle.log). * * Re-entrant calls while a capture is already in progress are queued and * processed afterwards to prevent recursive logging loops. Circular references * in the log value are also guarded against via the isCapturingLogMessage set. * * @param {ProcessType} process * @param {string} key * @param {unknown} log * @param {string | null} connectionId - Optional connection ID for client logs. * @param {number} [time] - Optional timestamp, defaults to current time. */ export function captureLogMessage(process, key, log, connectionId, time = Date.now()) { if (isCapturingLogMessage.has(log)) { return; // prevent circular logs } if (isCapturing) { queue.push({ process, key, log, timestamp: Date.now(), connectionId }); return; } isCapturing = true; isCapturingLogMessage.add(log); try { let str = /** @type {string} */(stringifyLog(log)); if (str.trim().length > 0) { // if(process === "server") str = stripAnsiColors(str); const prefix = `${getTimestamp(time, true)}, ${process}${connectionId ? (`[${connectionId}]`) : ""}.${key}: `; const separator = ""; const finalLog = indent(`${prefix}${separator}${removeEmptyLinesAtStart(str)}`, prefix.length, separator) writeToFile(process, finalLog, connectionId); } } finally { isCapturing = false; isCapturingLogMessage.delete(log); } let queued = queue.pop(); if (queued) { captureLogMessage(queued.process, queued.key, queued.log, queued.connectionId, queued.timestamp); } } // #region stringify log /** * Recursively serializes an arbitrary value to a human-readable string. * Handles primitives, plain objects, arrays, typed arrays, Error instances, * circular references, and truncates large values based on per-environment limits * (server limits are more generous than browser limits). * @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; } } // #region utility functions /** * Returns the current time as a HH:MM:SS string (timeOnly=true) or a full ISO 8601 * timestamp string. Used as a prefix in log file entries. * @param {number} [date] - Optional timestamp in ms, defaults to now. * @param {boolean} [timeOnly] */ function getTimestamp(date, timeOnly = false) { const now = date ? new Date(date) : new Date(); if (timeOnly) { return now.toTimeString().split(' ')[0]; // HH:MM:SS } return now.toISOString(); } /** * Indents a string by a specified length. * @param {string} str - The string to indent. * @param {number} length - The number of spaces to indent each line. * @returns {string} The indented string. */ function indent(str, length, separator = "") { const lines = str.split("\n"); const prefixStr = " ".repeat(length) + separator; for (let i = 1; i < lines.length; i++) { let entry = lines[i].trim(); if (entry.length === 0) continue; // skip empty lines // indent the line lines[i] = prefixStr + entry; } return lines.join("\n"); } /** * Removes empty lines at the start of a string. * @param {string} str - The string to process. */ function removeEmptyLinesAtStart(str) { const lines = str.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.length > 0) { lines[i] = line; // keep the first non-empty line return lines.slice(i).join("\n"); } } return ""; } /** * Strips ANSI color codes from a string. * @param {string} str - The string to process. */ function stripAnsiColors(str) { // This pattern catches most ANSI escape sequences return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); } // #region log to file /** @type {Map<string, import("fs").WriteStream>} */ const filestreams = new Map(); const fileLogDirectory = "node_modules/.needle/logs"; // cleanup old log files if (existsSync(fileLogDirectory)) { const files = readdirSync(fileLogDirectory); // sort by age and keep the last 10 files files.sort((a, b) => { const aStat = statSync(`${fileLogDirectory}/${a}`); const bStat = statSync(`${fileLogDirectory}/${b}`); return aStat.mtimeMs - bStat.mtimeMs; }); // remove all but the last 30 files const filesToKeep = 30; for (let i = 0; i < files.length - filesToKeep; i++) { rmSync(`${fileLogDirectory}/${files[i]}`, { force: true }); } } /** * Appends a single log entry to the per-process rotating file log. * The file is created lazily under node_modules/.needle/logs/ on first write, * using a timestamped filename so each process start gets a fresh file. * @param {ProcessType} process * @param {string} log * @param {string | null} _connectionId - Reserved for future per-connection log files. */ function writeToFile(process, log, _connectionId) { const filename = `${process}.needle.log`; //connectionId && process === "client" ? `${process}-${connectionId}.needle.log` : `${process}.needle.log`; if (!filestreams.has(filename)) { if (!existsSync(fileLogDirectory)) { mkdirSync(fileLogDirectory, { recursive: true }); } filestreams.set(filename, createWriteStream(`${fileLogDirectory}/${filename_timestamp}.${filename}`, { flags: 'a' })); } const writeStream = filestreams.get(filename); if (!writeStream) { if (debug) console.error(`No write stream for process: ${filename}`); return; } writeStream.write(log + '\n'); } // #region process exit /** Flushes and closes all open log write streams. Registered for all exit-related process events. */ function onExit() { filestreams.forEach((stream) => stream.end()); filestreams.clear(); } /** Explicitly close all log file streams. Call this before a clean process exit (e.g. end of a build run). */ export function closeLogStreams() { onExit(); } const events = ['SIGTERM', 'SIGINT', 'beforeExit', 'rejectionHandled', 'uncaughtException', 'exit']; for (const event of events) { process.on(event, onExit); }