@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.
659 lines (657 loc) • 23.9 kB
JavaScript
import { BUILD_TIME, GENERATOR, PUBLIC_KEY, VERSION } from "./engine_constants.js";
import { ContextEvent, ContextRegistry } from "./engine_context_registry.js";
import { onInitialized } from "./engine_lifecycle_api.js";
import { isLocalNetwork } from "./engine_networking_utils.js";
import { Context } from "./engine_setup.js";
import { SSR } from "./engine_ssr.js";
import { getParam } from "./engine_utils.js";
const debug = getParam("__debuglic__");
const qKUrR = [];
// DO NOT EDIT MANUALLY
let EzdGPQg = "";
// eslint-disable-next-line prefer-const
let _$InwX = "";
if (debug) {
console.log("License Type: " + EzdGPQg);
if (_$InwX) {
console.log("License JWT: " + _$InwX);
try {
const payload = JSON.parse(atob(_$InwX.split(".")[1].replace(/-/g, '+').replace(/_/g, '/')));
console.log("License JWT payload:", payload);
}
catch {
console.log("License JWT payload: (failed to decode)");
}
}
else {
console.log("License JWT: (none)");
}
}
/** @internal */
export function _cxKhKwDL() {
switch (EzdGPQg) {
case "pro":
case "enterprise":
return true;
}
;
return false;
}
/** @internal */
export function __otwqOR() {
switch (EzdGPQg) {
case "indie":
return true;
}
return false;
}
/** @internal */
export function __mPmwPS() {
switch (EzdGPQg) {
case "edu":
return true;
}
return false;
}
/** @internal */
export function LynjGsV() {
return _cxKhKwDL() || __otwqOR() || __mPmwPS();
}
/** @internal */
export function $yRlAEF(cb) {
if (_cxKhKwDL() || __otwqOR() || __mPmwPS())
return cb(true);
qKUrR.push(cb);
}
function cLH(result) {
for (const cb of qKUrR) {
try {
cb(result);
}
catch {
// ignore
}
}
}
// #region JWT
// ECDSA P-256 public key for verifying license JWTs (verification-only, safe to ship)
/* eslint-disable no-secrets/no-secrets -- public key, not a secret */
const ACBIZP = {
kty: "EC",
crv: "P-256",
x: "A34nyKMjhQYVgzeE4tyLUYdx34TAKogDa7v7PRaO9Lg",
y: "JZI9IQavGCpGjEG_-pa0J-MHQYWJYINUM-MnvSu0TmE",
};
/* eslint-enable no-secrets/no-secrets */
/** Base64url decode (RFC 7515) */
function base64urlDecode(str) {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Verify a JWT license token and return the `type` claim if valid.
* Returns null if the JWT is missing, malformed, or has an invalid signature.
*/
async function IKPk(jwt) {
if (!jwt)
return null;
try {
const parts = jwt.split(".");
if (parts.length !== 3)
return null;
const [headerB64, payloadB64, signatureB64] = parts;
const key = await crypto.subtle.importKey("jwk", ACBIZP, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const signature = base64urlDecode(signatureB64);
const valid = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, signature.buffer, signingInput);
if (!valid) {
if (debug)
console.warn("JWT: signature verification failed");
return null;
}
const payloadStr = new TextDecoder().decode(base64urlDecode(payloadB64));
const payload = JSON.parse(payloadStr);
if (typeof payload.type !== "string" || payload.type.length === 0) {
if (debug)
console.warn("JWT: missing or invalid 'type' claim");
return null;
}
// Optional: check expiration if present (future-proof — server doesn't set this yet)
if (typeof payload.exp === "number") {
const nowSeconds = Math.floor(Date.now() / 1000);
if (nowSeconds > payload.exp) {
if (debug)
console.warn("JWT: token has expired (exp=" + payload.exp + ", now=" + nowSeconds + ")");
return null;
}
}
// Optional: check audience/domain if present (future-proof — server doesn't set this yet)
if (payload.aud) {
const currentHost = typeof window !== "undefined" ? window.location.hostname : undefined;
if (currentHost) {
const allowed = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!allowed.some((a) => currentHost === a || currentHost.endsWith("." + a))) {
if (debug)
console.warn("JWT: domain '" + currentHost + "' not in allowed audience: " + allowed.join(", "));
return null;
}
}
}
if (debug)
console.log("JWT: verified license type: " + payload.type);
return payload.type;
}
catch (err) {
if (debug)
console.error("JWT: verification error", err);
return null;
}
}
/** Verify the injected JWT and update the license type if valid.
* The engine ONLY trusts the JWT — the plain EzdGPQg string is ignored. */
let _jwtVerificationPromise = undefined;
async function _EyK() {
// NOTE: do NOT add an `if (!_$InwX) return` guard here.
// esbuild (Vite dependency pre-bundling) would see the variable as constant ""
// and tree-shake the entire JWT verification code path.
const jwt = _$InwX;
// Clear after reading — this reassignment also prevents esbuild from
// constant-folding the variable (it now has multiple assignment sites).
_$InwX = "";
const verifiedType = await IKPk(jwt);
if (verifiedType) {
EzdGPQg = verifiedType;
if (debug)
console.log("License type set from verified JWT: " + verifiedType);
cLH(LynjGsV());
}
else {
EzdGPQg = "basic";
if (debug && jwt)
console.warn("JWT verification failed — license reset to basic");
}
}
// #endregion JWT License Verification
// #region Telemetry
export var Telemetry;
(function (Telemetry) {
if (typeof window !== "undefined") {
window.addEventListener("error", (event) => {
sendError(Context.Current, "unhandled_error", event);
});
window.addEventListener("unhandledrejection", (event) => {
sendError(Context.Current, "unhandled_promise_rejection", {
message: event.reason?.message,
stack: event.reason?.stack,
timestamp: Date.now(),
});
});
}
function init() {
if (!SSR)
onInitialized((ctx => sendPageViewEvent(ctx)), { once: true });
}
Telemetry.init = init;
function sendPageViewEvent(ctx) {
if (!isAllowed(ctx)) {
if (debug)
console.debug("Telemetry is disabled via no-telemetry attribute");
return;
}
return doFetch({
site_id: "dabb8317376f",
type: "pageview",
pathname: window.location.pathname,
hostname: window.location.hostname,
page_title: document.title,
referrer: document.referrer,
user_agent: navigator.userAgent,
querystring: window.location.search,
language: navigator.language,
screenWidth: window.screen.width,
screenHeight: window.screen.height,
event_name: "page_view"
}).then(res => {
if (res instanceof Response && res.ok && isLocalNetwork()) {
const src = ctx.domElement?.getAttribute("src") || "";
const sessionKey = src + VERSION + GENERATOR + BUILD_TIME + PUBLIC_KEY;
if (window.sessionStorage.getItem("session_key") !== sessionKey) {
window.sessionStorage.setItem("session_key", sessionKey);
sendEvent(ctx, "info", {
src: ctx.domElement?.getAttribute("src") || "",
version: VERSION,
generator: GENERATOR,
build_time: BUILD_TIME,
public_key: PUBLIC_KEY,
});
}
}
return;
});
}
function isAllowed(context) {
let domElement = context?.domElement;
if (!domElement)
domElement = document.querySelector("needle-engine");
if (!domElement && !context)
return false;
const attribute = domElement?.getAttribute("no-telemetry");
if (attribute === "" || attribute === "true" || attribute === "1") {
if (EzdGPQg === "pro" || EzdGPQg === "enterprise") {
if (debug)
console.debug("Telemetry is disabled via no-telemetry attribute");
return false;
}
}
return true;
}
Telemetry.isAllowed = isAllowed;
const id = "dabb8317376f";
/**
* Sends a telemetry event
*/
async function sendEvent(context, eventName, properties) {
if (!isAllowed(context)) {
if (debug)
console.debug("Telemetry is disabled");
return;
}
const body = {
site_id: id,
type: "custom_event",
pathname: window.location.pathname,
event_name: eventName,
properties: properties ? JSON.stringify(properties) : undefined,
};
return doFetch(body);
}
Telemetry.sendEvent = sendEvent;
async function sendError(context, errorName, error) {
if (!isAllowed(context)) {
if (debug)
console.debug("Telemetry is disabled");
return;
}
if (error instanceof ErrorEvent) {
error = {
message: error.message,
stack: error.error?.stack,
filename: error.filename,
lineno: error.lineno,
colno: error.colno,
timestamp: error.timeStamp || Date.now(),
};
}
else if (error instanceof Error) {
error = {
message: error.message,
stack: error.stack,
timestamp: Date.now(),
};
}
const body = {
site_id: id,
type: "error",
event_name: errorName || "error",
properties: JSON.stringify({
error_name: errorName,
message: error.message,
stack: error.stack,
filename: error.filename,
lineno: error.lineno,
colno: error.colno,
timestamp: error.timestamp,
})
};
return doFetch(body);
}
Telemetry.sendError = sendError;
function doFetch(body) {
try {
const url = "https://needle.tools/api/v1/rum/t";
return fetch(url, {
method: "POST",
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json'
},
// Ensures request completes even if page unloads
keepalive: true,
// Allow CORS requests
mode: 'cors',
// Low priority to avoid blocking other requests
// @ts-ignore
priority: 'low',
}).catch(e => {
if (debug)
console.error("Failed to send telemetry", e);
});
}
catch (err) {
if (debug)
console.error(err);
}
return Promise.resolve();
}
})(Telemetry || (Telemetry = {}));
export function _$AnFl() {
if (EzdGPQg === "")
EzdGPQg = "basic";
// Start JWT verification — must be here (not top-level) to avoid tree-shaking
_jwtVerificationPromise = _EyK();
Telemetry.init();
ContextRegistry.registerCallback(ContextEvent.ContextRegistered, evt => {
$vyRL(evt.context);
uZY(evt.context);
setTimeout(() => cEROvL(evt.context), 2000);
});
}
export let _$oYJ = undefined;
let applicationIsForbidden = false;
let applicationForbiddenText = "";
async function __lVbhf() {
// Only perform the runtime license check once
if (_$oYJ)
return _$oYJ;
// Wait for JWT verification to complete first (if running)
if (_jwtVerificationPromise) {
await _jwtVerificationPromise;
}
if (EzdGPQg === "basic") {
try {
const licenseUrl = "https://needle.tools/api/v1/needle-engine/check?location=" + encodeURIComponent(window.location.href) + "&version=" + VERSION + "&generator=" + encodeURIComponent(GENERATOR);
const res = await fetch(licenseUrl, {
method: "GET",
}).catch(_err => {
if (debug)
console.error("License check failed", _err);
return undefined;
});
if (res?.status === 200) {
applicationIsForbidden = false;
if (debug)
console.log("License check succeeded");
EzdGPQg = "pro";
cLH(true);
}
else if (res?.status === 403) {
cLH(false);
applicationIsForbidden = true;
applicationForbiddenText = await res.text();
}
else {
cLH(false);
if (debug)
console.log("License check failed with status " + res?.status);
}
}
catch (err) {
cLH(false);
if (debug)
console.error("License check failed", err);
}
}
else if (debug)
console.log("Runtime license check is skipped because license is already applied as \"" + EzdGPQg + "\"");
}
_$oYJ = __lVbhf();
async function uZY(ctx) {
function createForbiddenElement() {
const div = document.createElement("div");
div.className = "needle-forbidden";
div.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: all;
zIndex: 2147483647;
line-height: 1.5;
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
`;
const expectedStyle = div.style.cssText;
const text = document.createElement("div");
div.appendChild(text);
text.style.cssText = `
position: absolute;
left: 0;
right: 0;
top:0;
bottom: 0;
padding: 10%;
color: white;
font-size: 20px;
font-family: sans-serif;
text-align: center;
pointer-events: all;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0,0,0,.3);
text-shadow: 0 0 2px black;
`;
const expectedTextStyle = text.style.cssText;
const forbiddenText = applicationForbiddenText?.length > 1 ? applicationForbiddenText : "This web application has been paused.<br/>You might be in violation of the Needle Engine terms of use.<br/>Please contact the Needle support if you think this is a mistake.";
text.innerHTML = forbiddenText;
setInterval(() => {
if (text.innerHTML !== forbiddenText)
text.innerHTML = forbiddenText;
if (text.parentNode !== div)
div.appendChild(text);
if (div.style.cssText !== expectedStyle)
div.style.cssText = expectedStyle;
if (text.style.cssText !== expectedTextStyle)
text.style.cssText = expectedTextStyle;
}, 500);
return div;
}
let forbiddenElement = createForbiddenElement();
const expectedCSS = forbiddenElement.style.cssText;
setInterval(() => {
if (applicationIsForbidden === true) {
if (forbiddenElement.style.cssText !== expectedCSS)
forbiddenElement = createForbiddenElement();
if (ctx.domElement.shadowRoot) {
if (forbiddenElement.parentNode !== ctx.domElement.shadowRoot)
ctx.domElement.shadowRoot?.appendChild(forbiddenElement);
}
else if (forbiddenElement.parentNode != document.body) {
document.body.appendChild(forbiddenElement);
}
}
}, 500);
}
async function $vyRL(ctx) {
try {
if (!_cxKhKwDL() && !__otwqOR()) {
return LGWV(ctx);
}
}
catch (err) {
if (debug)
console.log("License check failed", err);
return LGWV(ctx);
}
if (debug)
LGWV(ctx);
}
async function LGWV(ctx) {
// if the engine loads faster than the license check, we need to capture the ready event here
let isReady = false;
ctx.domElement.addEventListener("ready", () => isReady = true);
await _$oYJ?.catch(() => { });
if (_cxKhKwDL() || __otwqOR())
return;
if (LynjGsV() === false)
_kYgJQd();
// check if the engine is already ready (meaning has finished loading)
if (isReady) {
_$XDPaPw(ctx);
}
else {
ctx.domElement.addEventListener("ready", () => {
_$XDPaPw(ctx);
});
}
}
// const licenseElementIdentifier = "needle-license-element";
// const licenseDuration = 10000;
// const licenseDelay = 1200;
function _$XDPaPw(ctx) {
const style = `
position: relative;
display: block;
background-size: 20px;
background-position: 10px 5px;
background-repeat:no-repeat;
background-image:url('${base64Logo}');
background-max-size: 40px;
padding: 10px;
padding-left: 30px;
`;
if (EzdGPQg === "edu") {
if (navigator.webdriver) {
console.log("This project is supported by Needle for Education – https://needle.tools");
}
else {
console.log("%c " + "This project is supported by Needle for Education – https://needle.tools", style);
}
}
else {
// if the user has a basic license we already show the logo in the menu and log a license message
return;
}
const banner = document.createElement("div");
banner.className = "needle-non-commercial-use";
banner.innerHTML = "Made with Needle for Education";
ctx.domElement.shadowRoot?.appendChild(banner);
let bannerStyle = `
position: absolute;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-size: 12px;
color: rgb(100, 100, 100);
/*mix-blend-mode: difference;*/
background-color: transparent;
z-index: 10000;
cursor: pointer;
user-select: none;
opacity: 0;
bottom: 6px;
right: 12px;
transform: translateY(0px);
transition: all .5s ease-in-out 1s;
`;
banner.style.cssText = bannerStyle;
banner.addEventListener("click", () => { window.open("https://needle.tools", "_blank"); });
let expectedBannerStyle = banner.style.cssText;
setTimeout(() => {
bannerStyle = bannerStyle.replace("opacity: 0", "opacity: 1");
bannerStyle = bannerStyle.replace("transform: translateY(10px)", "transform: translateY(0)");
banner.style.cssText = bannerStyle;
expectedBannerStyle = banner.style.cssText;
}, 100);
// ensure the banner is always visible
const interval = setInterval(() => {
const parent = ctx.domElement.shadowRoot || ctx.domElement;
if (banner.parentNode !== parent) {
parent.appendChild(banner);
}
if (expectedBannerStyle != banner.style.cssText) {
banner.style.cssText = bannerStyle;
expectedBannerStyle = banner.style.cssText;
}
}, 1000);
if (__mPmwPS()) {
const removeDelay = 20_000;
setTimeout(() => {
clearInterval(interval);
banner?.remove();
// show the logo every x minutes
const intervalInMinutes = 5;
setTimeout(() => {
if (ctx.domElement.parentNode)
_$XDPaPw(ctx);
}, 1000 * 60 * intervalInMinutes);
}, removeDelay);
}
}
const base64Logo = "data:image/webp;base64,UklGRrABAABXRUJQVlA4WAoAAAAQAAAAHwAAHwAAQUxQSKEAAAARN6CmbSM4WR7vdARON11EBDq3fLiNbVtVzpMCPlKAEzsx0Y/x+Ovuv4dn0EFE/ydAvz6YggXzgh5sVgXM/zOC/4sii7qgGvB5N7hmuQYwkvazWAu1JPW41FXSHq6pnaQWvqYH18Fc0j1hO/BFTtIeSBlJi5w6qIIO7IOrwhFsB2Yxukif0FTRLpXswHR8MxbslKe9VZsn/Ub5C7YFOpqSTABWUDgg6AAAAFAGAJ0BKiAAIAA+7VyoTqmkpCI3+qgBMB2JbACdMt69DwMIQBLhkTO6XwY00UEDK6cNIDnuNibPf0EgAP7Y1myuiQHLDsF/0h5unrGh6WAbv7aegg2ZMd3uRKfT/3SJztcaujYfTvMXspfCTmYcoO6a+vhC3ss4M8uM58t4siiu59I4aOl59e9Sr6xoxYlHf2v+NnBNpJYeJf8jABQAId/PXuBkLEFkiCucgSGEcfhvajql/j3reCGl0M5/9gQWy7ayNPs+wlvIxFnNfSlfuND4CZOCyxOHhRqOmHN4ULHo3tCSrUNvgAA=";
let lastLogTime = 0;
async function _kYgJQd(_logo) {
const now = Date.now();
if (now - lastLogTime < 2000)
return;
lastLogTime = now;
const style = `
position: relative;
display: block;
font-size: 18px;
background-size: 20px;
background-position: 10px 5px;
background-repeat:no-repeat;
background-image:url('${base64Logo}');
background-max-size: 40px;
margin-bottom: 5px;
margin-top: .3em;
margin-bottom: .5em;
padding: .2em;
padding-left: 25px;
border-radius: .5em;
border: 2px solid rgba(160,160,160,.3);
`;
// url must contain https for firefox to make it clickable
const version = VERSION;
const licenseText = `Needle Engine — No license active, commercial use is not allowed. Visit https://needle.tools/pricing for more information and licensing options! v${version}`;
if (Context.Current?.xr || navigator.webdriver) {
console.log(licenseText);
}
else {
console.log("%c " + licenseText, style);
}
}
async function cEROvL(context) {
// We can't send beacons from cross-origin isolated pages
if (window.crossOriginIsolated)
return;
if (!Telemetry.isAllowed(context)) {
if (debug)
console.debug("Telemetry is disabled via no-telemetry attribute");
return;
}
try {
const analyticsUrl = "htt" + "ps://" + "needle" + ".tools/" + "api/v1/needle-engine/ping";
if (analyticsUrl) {
// current url without query parameters
const currentUrl = window.location.href.split("?")[0];
const license = EzdGPQg;
const beaconData = {
license,
url: currentUrl,
hostname: window.location.hostname,
pathname: window.location.pathname,
// search: window.location.search,
// hash: window.location.hash,
version: VERSION,
generator: GENERATOR,
build_time: BUILD_TIME,
public_key: PUBLIC_KEY,
};
const res = navigator.sendBeacon?.(analyticsUrl, JSON.stringify(beaconData));
if (debug)
console.debug("Sent beacon: " + res);
}
}
catch (err) {
if (debug)
console.log("Failed to send non-commercial usage message to analytics backend", err);
}
}
//# sourceMappingURL=engine_license.js.map