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.

168 lines (146 loc) 7.03 kB
// @ts-check /** * GLB / glTF JSON chunk readers — local files and remote URLs. * * All functions return the parsed glTF JSON object or null on failure. * Remote fetches use HTTP Range requests and cache by Last-Modified header. */ import { openSync, readSync, closeSync, readFileSync } from 'fs'; import { needleLog } from '../vite/logging.js'; const PLUGIN = "needle:dts-generator"; /** * Read the JSON chunk from a binary GLB file without loading the binary blob. * @param {string} filePath * @returns {Record<string, unknown> | null} */ export function readGlbJsonChunk(filePath) { const fd = openSync(filePath, "r"); try { const header = Buffer.allocUnsafe(20); const bytesRead = readSync(fd, header, 0, header.length, 0); if (bytesRead < 20) return null; const magic = header.readUInt32LE(0); const version = header.readUInt32LE(4); const chunkLength = header.readUInt32LE(12); const chunkType = header.readUInt32LE(16); if (magic !== 0x46546c67 || version !== 2) return null; if (chunkType !== 0x4E4F534A) return null; // not JSON chunk const jsonBuffer = Buffer.allocUnsafe(chunkLength); const jsonBytesRead = readSync(fd, jsonBuffer, 0, chunkLength, 20); if (jsonBytesRead < chunkLength) return null; return JSON.parse(jsonBuffer.toString("utf8").replace(/\u0000+$/g, "")); } catch (_e) { return null; } finally { closeSync(fd); } } /** * @param {string} filePath * @returns {Record<string, unknown> | null} */ export function readGltfJsonFile(filePath) { try { return JSON.parse(readFileSync(filePath, "utf8")); } catch (_e) { return null; } } /** * Parse the filename from a `Content-Disposition` header value. * Handles `filename="foo.glb"` and `filename*=UTF-8''foo.glb` forms. * Returns null if not present or not parseable. * @param {string | null} header * @returns {string | null} */ export function parseContentDispositionFilename(header) { if (!header) return null; // RFC 5987 extended form: filename*=UTF-8''foo.glb const extMatch = header.match(/filename\*\s*=\s*(?:[^']*'')?([^;]+)/i); if (extMatch) { try { return decodeURIComponent(extMatch[1].trim()); } catch (_e) { /* fall through */ } } // Plain form: filename="foo.glb" or filename=foo.glb const plainMatch = header.match(/filename\s*=\s*"?([^";]+)"?/i); if (plainMatch) return plainMatch[1].trim(); return null; } /** @type {Map<string, { lastModified: string, json: Record<string, unknown>, filename: string | null }>} */ const _remoteGlbCache = new Map(); /** * Fetch the JSON chunk of a remote GLB. * Tries an initial Range request for the header bytes; if the server already returned * enough data (200 + full body, or 206 with sufficient bytes) the JSON chunk is * extracted directly. Otherwise a second Range request fetches the JSON chunk. * Uses `Last-Modified` header for caching — avoids re-parsing if unchanged. * Also captures `Content-Disposition` filename for friendly key generation. * Returns `null` on any network or parse error. * * @param {string} url * @returns {Promise<{ json: Record<string, unknown>, filename: string | null } | null>} */ export async function readRemoteGlbJsonChunk(url) { try { const cached = _remoteGlbCache.get(url); const headerRes = await fetch(url, { headers: { Range: "bytes=0-19" } }); if (!headerRes.ok) { needleLog(PLUGIN, `Remote GLB fetch failed (${headerRes.status}): ${url}`, "warn"); return null; } const lastModified = headerRes.headers.get("Last-Modified") ?? ""; const filename = parseContentDispositionFilename(headerRes.headers.get("Content-Disposition")); if (cached && lastModified && cached.lastModified === lastModified) { // Prefer the filename we resolved during the full fetch (may have come from the JSON chunk // response). Only fall back to the fresh header value if the cache has nothing. return { json: cached.json, filename: cached.filename ?? filename }; } const firstBytes = Buffer.from(await headerRes.arrayBuffer()); if (firstBytes.length < 20) return null; const magic = firstBytes.readUInt32LE(0); const version = firstBytes.readUInt32LE(4); const chunkLength = firstBytes.readUInt32LE(12); const chunkType = firstBytes.readUInt32LE(16); if (magic !== 0x46546c67 || version !== 2) return null; // not a GLB if (chunkType !== 0x4E4F534A) return null; // chunk0 not JSON let jsonBytes; let resolvedFilename = filename; if (firstBytes.length >= 20 + chunkLength) { // The first response already contained the full JSON chunk (server ignored Range or returned 200). jsonBytes = firstBytes.slice(20, 20 + chunkLength); } else { // Try a second Range request for just the JSON chunk bytes. const jsonEnd = 20 + chunkLength - 1; const jsonRes = await fetch(url, { headers: { Range: `bytes=20-${jsonEnd}` } }); if (!jsonRes.ok) { needleLog(PLUGIN, `Remote GLB JSON chunk fetch failed (${jsonRes.status}): ${url}`, "warn"); return null; } const jsonResBytes = Buffer.from(await jsonRes.arrayBuffer()); // Server may return 200 + full body even for a Range request — slice out our window. if (jsonResBytes.length >= 20 + chunkLength) { jsonBytes = jsonResBytes.slice(20, 20 + chunkLength); } else if (jsonResBytes.length >= chunkLength) { jsonBytes = jsonResBytes.slice(0, chunkLength); } else { needleLog(PLUGIN, `Remote GLB unexpected response (${jsonRes.status}, ${jsonResBytes.length} bytes, expected ${chunkLength}): ${url}`, "warn"); return null; } const filenameFromJsonRes = parseContentDispositionFilename(jsonRes.headers.get("Content-Disposition")); resolvedFilename = filename ?? filenameFromJsonRes; } if (resolvedFilename === null) { try { const headRes = await fetch(url, { method: "HEAD" }); resolvedFilename = parseContentDispositionFilename(headRes.headers.get("Content-Disposition")); } catch (_e) { /* ignore — fall back to URL-based name */ } } const json = /** @type {Record<string, unknown>} */ ( JSON.parse(jsonBytes.toString("utf8").replace(/\u0000+$/g, "")) ); _remoteGlbCache.set(url, { lastModified, json, filename: resolvedFilename }); return { json, filename: resolvedFilename }; } catch (e) { needleLog(PLUGIN, `Failed to fetch remote GLB: ${url}${/** @type {any} */ (e)?.message ?? e}`, "warn"); return null; } }