@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.
453 lines (408 loc) • 16.8 kB
JavaScript
import { spawn } from "child_process";
import { NEEDLE_CLOUD_CLI_NAME } from "./cloud.js";
import { existsSync } from "fs";
import http from "http";
import https from "https";
const port = 8424;
const localServerUrl = `http://localhost:${port}`;
const licenseServerUrl = `http://localhost:${port}/api/license`;
const projectIdentifierUrl = `http://localhost:${port}/api/public_key`;
const needleCloudApiEndpoint = "https://cloud.needle.tools/api";
/**
* @typedef {{loglevel?:"verbose"}} DefaultOptions
*/
/**
* Replace license string - used for webpack
* @param {string} code
* @param {DefaultOptions & {accessToken?:string, team:string|undefined}} opts
*/
export async function replaceLicense(code, opts) {
const index = code?.indexOf("NEEDLE_ENGINE_LICENSE_TYPE");
if (index >= 0) {
const licenseType = await resolveLicense(opts);
if (!licenseType) {
return code;
}
const end = code.indexOf(";", index);
if (end >= 0) {
const line = code.substring(index, end);
const replaced = "NEEDLE_ENGINE_LICENSE_TYPE = \"" + licenseType + "\"";
code = code.replace(line, replaced);
return code;
}
}
return code;
}
/**
* Resolve the license using the needle engine licensing server
* @param {DefaultOptions & {accessToken?:string, team?:string} | null} args
* @returns {Promise<string | null>}
*/
export async function resolveLicense(args = null) {
let accessToken = args?.accessToken;
// If a process.env.NEEDLE_CLOUD_TOKEN is set we want to use this (e.g. via nextjs)
if (!accessToken) {
if (process.env.NEEDLE_CLOUD_TOKEN) {
console.log("[needle-license] INFO: Using Needle Cloud access token from NEEDLE_CLOUD_TOKEN environment variable");
accessToken = process.env.NEEDLE_CLOUD_TOKEN;
}
else if (process.env.CI) {
console.warn("[needle-license] WARN: Missing NEEDLE_CLOUD_TOKEN for CI environment");
}
}
if (accessToken) {
const timeout = AbortSignal.timeout(10_000);
const url = new URL(`${needleCloudApiEndpoint}/v1/account/get/licenses`);
const res = await fetch(url, {
method: "GET",
signal: timeout,
headers: {
Authorization: `Bearer ${accessToken}`,
"x-needle": "cli"
}
}).catch(err => {
return { ok: false, error: err.message };
});
if ("error" in res) {
console.error(`[needle-license] Could not fetch license from Needle Cloud API (${res.error})`);
return null;
}
if (res.ok) {
const text = await res.text();
return tryParseLicense(text);
}
else {
console.error(`[needle-license] Could not fetch license from Needle Cloud API (${res.status})`);
if (process.env.CI) {
return null;
}
}
}
else if (process.env.CI) {
const isGithubCI = process.env.GITHUB_ACTIONS;
let message = "[needle-license] WARN: Missing NEEDLE_CLOUD_TOKEN for CI environment run.";
if (isGithubCI) {
const repositoryUrl = process.env.GITHUB_REPOSITORY;
const url = `${repositoryUrl}/settings/secrets/actions`;
message += `\nPlease add the token to your GitHub repository secrets: ${url}`;
}
console.warn(message);
return null;
}
if (!canRunCLI(args)) {
console.error("[needle-license] License server CLI is not available. Please use an access token for authorization.");
return null;
}
// Fallback to use CLI
// Wait for the server to start
if (!await waitForLicenseServer(args || undefined)) {
console.error("[needle-license] ERR: Failed to start license server...");
return null;
}
const url = new URL(licenseServerUrl);
if (args?.team)
url.searchParams.append("org", args.team);
if (accessToken) {
url.searchParams.append("token", accessToken);
}
console.log(`[needle-license] INFO: Fetching license...`);
const timeout = AbortSignal.timeout(10_000);
const licenseResponse = await fetch(url.toString(), {
method: "GET",
signal: timeout
}).catch(err => {
if (args?.loglevel === "verbose") console.error("Error fetching license", err.message);
if (err.cause?.code === "ECONNREFUSED") {
return { error: "[needle-license] ERR: Failed to connect to license server (ECONNREFUSED)" };
}
else {
return { error: "[needle-license] ERR: Failed to fetch license." };
}
});
if (!licenseResponse) {
console.warn("[needle-license] WARN: Failed to fetch license");
return null;
}
else if ("error" in licenseResponse) {
console.error(licenseResponse.error);
return null;
}
else if (!licenseResponse.ok) {
if (licenseResponse.status === 500)
console.error(`[needle-license] ERROR: Failed to fetch license (${licenseResponse.status})`);
else
console.log(`[needle-license] No license found (${licenseResponse.status})`);
return null;
}
const text = await licenseResponse.text();
return tryParseLicense(text);
}
/**
* @param {string} str License string
*/
function tryParseLicense(str) {
try {
/** @type {{needle_engine_license:string}} */
const licenseJson = JSON.parse(str);
if (licenseJson.needle_engine_license) {
console.log(`[needle-license] INFO: Successfully received \"${licenseJson.needle_engine_license?.toUpperCase()}\" license`)
return licenseJson.needle_engine_license;
}
if ("error" in licenseJson) {
console.error(`[needle-license] ERROR in license check: \"${licenseJson.error}\"`);
}
else if (licenseJson.needle_engine_license == null) {
return null;
}
else {
console.warn("[needle-license] WARN: Received invalid license.", licenseJson);
}
return null;
}
catch (err) {
console.error("[needle-license] ERROR: Failed to parse license response");
return null;
}
}
/**
* @param {string | undefined} project_id
* @param {DefaultOptions | undefined} opts
*/
export async function getPublicIdentifier(project_id, opts = undefined) {
let accessToken = undefined;
if (!accessToken) {
if (opts?.loglevel === "verbose" && process.env.CI) console.debug("[needle-identifier] INFO: Running in CI environment");
if (process.env.NEEDLE_CLOUD_TOKEN) {
console.log("[needle-identifier] INFO: Using Needle Cloud access token from environment variable");
accessToken = process.env.NEEDLE_CLOUD_TOKEN;
}
if (accessToken) {
const url = new URL(`${needleCloudApiEndpoint}/v1/account/public_key`);
const body = {
project_id: project_id || process.env.GITHUB_REPOSITORY || undefined,
machine_id: process.env.GITHUB_REPOSITORY_ID || "unknown",
}
const timeout = AbortSignal.timeout(10_000);
const res = await fetch(url, {
method: "POST",
signal: timeout,
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"x-needle": "cli"
},
body: JSON.stringify(body),
}).catch(err => {
if (opts?.loglevel === "verbose") {
console.error(err);
}
return { ok: false, error: err.message };
});
if ("error" in res) {
console.error(`[needle-identifier] Could not fetch project identifier from Needle Cloud API (${res.error})`);
return null;
}
if (res.ok) {
const text = await res.text();
try {
/** @type {{public_key:string}} */
const json = JSON.parse(text);
console.log(`[needle-identifier] INFO: Successfully received public project identifier`);
return json.public_key;
}
catch (err) {
console.error("[needle-identifier] ERROR: Failed to parse project identifier response");
return null;
}
}
else {
const message = await res.text();
console.error(`[needle-identifier] Could not fetch project identifier from Needle Cloud API (${res.status}, ${res.statusText}, ${message})`);
return null;
}
}
}
if (!canRunCLI(opts)) {
console.error("[needle-license] License server CLI is not available. Please use an access token for authorization.");
return null;
}
// Wait for the server to start
if (!await waitForLicenseServer(opts)) {
console.error("[needle-identifier] ERR: Failed to start license server...");
return null;
}
console.log(`[needle-identifier] INFO: Fetching project identifier...`);
const url = new URL(projectIdentifierUrl);
if (project_id) url.searchParams.append("project_id", project_id);
const timeout = AbortSignal.timeout(10_000);
const res = await fetch(projectIdentifierUrl, {
method: "GET",
signal: timeout
}).catch(err => {
if (opts?.loglevel === "verbose") {
console.error(err);
}
if (err.cause?.code === "ECONNREFUSED") {
return { error: "[needle-identifier] Could not connect to the license server: The connection was actively refused" };
}
else {
return { error: "[needle-identifier] ERR: Failed to fetch project identifier." };
}
})
if (!res) {
console.warn("[needle-identifier] WARN: Failed to fetch project identifier");
return null;
}
else if ("error" in res) {
console.error(res.error);
return null;
}
else if (!res.ok) {
console.error("[needle-identifier] ERROR: Failed to fetch project identifier");
return null;
}
const text = await res.text();
try {
/** @type {{public_key:string}} */
const json = JSON.parse(text);
return json.public_key;
}
catch (err) {
// TODO: report error to backend
if (opts?.loglevel === "verbose") console.error(err);
return null;
}
};
let licenseServerStarted = undefined;
/**
* If we run the build command without an editor and the license server is just being started we need to to wait for the root URL to return a response
* @param {DefaultOptions | undefined} opts
* @returns {Promise<boolean>}
*/
async function waitForLicenseServer(opts) {
if (opts?.loglevel === "verbose") console.log("[needle] INFO: Waiting for license server to start...");
// Make sure the licensing server is running - but only call this once
if (licenseServerStarted === undefined) {
licenseServerStarted = true;
const startcmd = runCommand("npx", ["--yes", NEEDLE_CLOUD_CLI_NAME, "start-server"], opts);
startcmd.then(() => licenseServerStarted = false);
}
let attempts = 0;
const maxAttempts = 5;
do {
const timeout = AbortSignal.timeout(10_000);
// When using `fetch` then webcontainers do sometimes throw and exit the process silently - so we use http.get instead
const response = await fetch_WaitForServer(localServerUrl, {
method: "GET",
signal: timeout
}).catch(err => {
if (opts?.loglevel === "verbose") {
console.error("ERROR connecting to local license server url at " + localServerUrl, err.message);
}
if (typeof err?.stack === "string" && err.stack?.includes("staticblitz.com")) {
if (opts?.loglevel === "verbose") console.log("[needle] INFO: Running in Stackblitz environment, skipping license server check.");
return { abort: true, error: err }; // Stackblitz does not support the license server
}
if (err.cause?.code === "ECONNREFUSED" || err?.message?.includes("ECONNREFUSED")) {
if (!licenseServerStarted) {
console.error("[needle] ERR: Failed to connect to license server (ECONNREFUSED)");
}
else {
// Waiting for server to start
// EConnectRefuse happen if starting the license server for the first time
}
}
else {
if (attempts === maxAttempts) {
console.error("[needle] ERR: Failed to start license server...", err.message);
}
}
});
if (typeof response === "object") {
if ("abort" in response) {
return false; // Abort signal was triggered, e.g. in Stackblitz
}
}
if (response) {
if (opts?.loglevel === "verbose") console.log(`[needle] INFO: License server is running and reachable at ${localServerUrl}`);
return true;
}
if (attempts === 0) {
console.log("[needle] INFO: Waiting for license server to start...");
}
attempts++;
if (attempts <= maxAttempts) {
await new Promise(res => setTimeout(res, 1000));
}
} while (attempts < maxAttempts);
return false;
}
/**
* @param {DefaultOptions | undefined | null} opts
*/
function canRunCLI(opts) {
if (process.env.CI) {
if (opts?.loglevel === "verbose") console.log("[needle-license] INFO: Running in CI environment");
return false; // We cannot run the CLI in CI environments
}
// Note: the .stackblitz directory doesnt always exist it seems
if (existsSync("/home/.stackblitz")) {
if (opts?.loglevel === "verbose") console.log("[needle-license] INFO: Running in Stackblitz environment");
return false;
}
// Default to true:
return true;
}
/**
* @param {string} processName
* @param {string[]} args
* @param {DefaultOptions | undefined} opts
*/
async function runCommand(processName, args, opts) {
if (opts?.loglevel === "verbose") console.log(`[needle-license] INFO: Running command: ${processName} ${args.join(" ")}`);
const process = spawn(processName, [...args], {
shell: true,
timeout: 30_000,
stdio: opts?.loglevel === "verbose" ? "inherit" : "ignore",
// detached: true,
});
return new Promise((resolve, _reject) => {
process.on('close', (code) => {
if (opts?.loglevel === "verbose")
console.log(`[needle-license] INFO: \"${processName}\" process exited with code ${code}`);
if (code === 0 || code === null || code === undefined) {
resolve(true);
} else {
console.warn(`[needle-license] WARN: \"${processName}\" process exited with code ${code}\nProcess Arguments: ${args.join(" ")}`);
resolve(false);
}
});
process.on('error', (err) => {
if (opts?.loglevel === "verbose") console.error("Error running " + processName, err);
resolve(err);
});
});
}
/**
* Fetches the content from the given URL and returns it as a string.
* @param {string} url - The URL to fetch
* @param {http.RequestOptions | https.RequestOptions} [options] - Optional request options
* @return {Promise<string>} - The content of the URL
*/
function fetch_WaitForServer(url, options) {
const module = url.startsWith("https") ? https : http;
return new Promise((resolve, reject) => {
if (!options) { options = {}; }
module.get(url, options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
resolve(data);
});
}).on("error", (err) => {
reject(err);
});
});
}