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