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.

527 lines (477 loc) • 18.9 kB
// @ts-check 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"; import { needleLog } from "../vite/logging.js"; 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"; /** @param {string} message @param {"error" | "log" | "warn"} [level] */ function logLicense(message, level = "log") { needleLog("needle-license", message, level); } /** @param {string} message @param {"error" | "log" | "warn"} [level] */ function logIdentifier(message, level = "log") { needleLog("needle-identifier", message, level); } /** * Ensure response streams are fully released to avoid lingering sockets. * @param {Response | null | undefined} response */ async function releaseResponse(response) { if (!response?.body) return; try { await response.arrayBuffer(); } catch { try { await response.body.cancel(); } catch { // ignore } } } /** * @param {string} path * @param {{ method:"GET"|"POST", accessToken:string, body?: Record<string, any>, timeoutMs:number }} options * @returns {Promise<NeedleCloudHttpResponse>} */ async function requestNeedleCloud(path, options) { const url = new URL(`${needleCloudApiEndpoint}${path}`); const requestBody = options.body ? JSON.stringify(options.body) : undefined; return new Promise((resolve, reject) => { const req = https.request(url, { method: options.method, agent: false, timeout: options.timeoutMs, headers: { Authorization: `Bearer ${options.accessToken}`, "x-needle": "cli", "Connection": "close", ...(requestBody ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(requestBody) } : {}), } }, (res) => { let text = ""; res.setEncoding("utf8"); res.on("data", (/** @type {Buffer|string} */ chunk) => text += chunk); res.on("end", () => { resolve({ ok: (res.statusCode || 500) >= 200 && (res.statusCode || 500) < 300, status: res.statusCode || 500, statusText: res.statusMessage || "", text, }); }); }); req.on("timeout", () => req.destroy(new Error("Request timed out"))); req.on("error", reject); if (requestBody) req.write(requestBody); req.end(); }); } /** * @typedef {{loglevel?:"verbose"}} DefaultOptions */ /** @typedef {{ ok: boolean, status: number, statusText: string, text: string }} NeedleCloudHttpResponse */ /** * 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) { logLicense("INFO: Using Needle Cloud access token from NEEDLE_CLOUD_TOKEN environment variable"); accessToken = process.env.NEEDLE_CLOUD_TOKEN; } else if (process.env.CI) { logLicense("WARN: Missing NEEDLE_CLOUD_TOKEN for CI environment", "warn"); } } if (accessToken) { const res = await requestNeedleCloud("/v1/account/get/licenses", { method: "GET", accessToken, timeoutMs: 10_000, }).catch((/** @type {{ message?: string }} */ err) => { return { ok: false, error: err.message }; }); if ("error" in res) { logLicense(`Could not fetch license from Needle Cloud API (${res.error})`, "error"); return null; } if (res.ok) { return tryParseLicense(res.text); } else { logLicense(`Could not fetch license from Needle Cloud API (${res.status})`, "error"); if (process.env.CI) { return null; } } } else if (process.env.CI) { const isGithubCI = process.env.GITHUB_ACTIONS; let message = "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}`; } logLicense(message, "warn"); return null; } if (!canRunCLI(args)) { logLicense("License server CLI is not available. Please use an access token for authorization.", "error"); return null; } // Fallback to use CLI // Wait for the server to start if (!await waitForLicenseServer(args || undefined)) { logLicense("ERR: Failed to start license server...", "error"); return null; } const url = new URL(licenseServerUrl); if (args?.team) url.searchParams.append("org", args.team); if (accessToken) { url.searchParams.append("token", accessToken); } const includeFetchLineInSuccessLog = true; const timeout = AbortSignal.timeout(10_000); const licenseResponse = await fetch(url.toString(), { method: "GET", signal: timeout, headers: { "Connection": "close" } }).catch((/** @type {{ message?: string, cause?: { code?: string } }} */ 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) { logLicense("WARN: Failed to fetch license", "warn"); return null; } else if ("error" in licenseResponse) { console.error(licenseResponse.error); return null; } else if (!licenseResponse.ok) { if (licenseResponse.status === 500) logLicense(`ERROR: Failed to fetch license (${licenseResponse.status})`, "error"); else logLicense(`No license found (${licenseResponse.status})`); await releaseResponse(licenseResponse); return null; } const text = await licenseResponse.text(); return tryParseLicense(text, { includeFetchLine: includeFetchLineInSuccessLog }); } /** * @param {string} str License string * @param {{includeFetchLine?: boolean}} [options] */ function tryParseLicense(str, options = undefined) { try { /** @type {{needle_engine_license:string}} */ const licenseJson = JSON.parse(str); if (licenseJson.needle_engine_license) { const success = `INFO: Successfully received \"${licenseJson.needle_engine_license?.toUpperCase()}\" license`; if (options?.includeFetchLine) { logLicense(`INFO: Fetching license...\n${success}`); } else { logLicense(success); } return licenseJson.needle_engine_license; } if ("error" in licenseJson) { logLicense(`ERROR in license check: \"${licenseJson.error}\"`, "error"); } else if (licenseJson.needle_engine_license == null) { return null; } else { logLicense("WARN: Received invalid license. " + JSON.stringify(licenseJson), "warn"); } return null; } catch (/** @type {unknown} */ err) { logLicense("ERROR: Failed to parse license response", "error"); return null; } } /** * @param {string | undefined} project_id * @param {DefaultOptions | undefined} opts */ export async function getPublicIdentifier(project_id, opts = undefined) { let accessToken = /** @type {string | undefined} */ (undefined); if (!accessToken) { if (opts?.loglevel === "verbose" && process.env.CI) logIdentifier("INFO: Running in CI environment"); if (process.env.NEEDLE_CLOUD_TOKEN) { logIdentifier("INFO: Using Needle Cloud access token from environment variable"); accessToken = process.env.NEEDLE_CLOUD_TOKEN; } if (accessToken) { const body = { project_id: project_id || process.env.GITHUB_REPOSITORY || undefined, machine_id: process.env.GITHUB_REPOSITORY_ID || "unknown", } const res = await requestNeedleCloud("/v1/account/public_key", { method: "POST", accessToken, timeoutMs: 10_000, body, }).catch((/** @type {{ message?: string }} */ err) => { if (opts?.loglevel === "verbose") { console.error(err); } return { ok: false, error: err.message }; }); if ("error" in res) { logIdentifier(`Could not fetch project identifier from Needle Cloud API (${res.error})`, "error"); return null; } if (res.ok) { try { /** @type {{public_key:string}} */ const json = JSON.parse(res.text); logIdentifier(`INFO: Successfully received public project identifier`); return json.public_key; } catch (/** @type {unknown} */ err) { logIdentifier("ERROR: Failed to parse project identifier response", "error"); return null; } } else { logIdentifier(`Could not fetch project identifier from Needle Cloud API (${res.status}, ${res.statusText}, ${res.text})`, "error"); return null; } } } if (!canRunCLI(opts)) { logLicense("License server CLI is not available. Please use an access token for authorization.", "error"); return null; } // Wait for the server to start if (!await waitForLicenseServer(opts)) { logIdentifier("ERR: Failed to start license server...", "error"); return null; } logIdentifier(`INFO: Fetching project identifier...`); const url = new URL(projectIdentifierUrl); if (project_id) url.searchParams.append("project_id", project_id); const timeout = AbortSignal.timeout(5_000); const res = await fetch(projectIdentifierUrl, { method: "GET", signal: timeout, headers: { "Connection": "close" } }).catch((/** @type {{ message?: string, cause?: { code?: string } }} */ 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) { logIdentifier("WARN: Failed to fetch project identifier", "warn"); return null; } else if ("error" in res) { console.error(res.error); return null; } else if (!res.ok) { logIdentifier("ERROR: Failed to fetch project identifier", "error"); await releaseResponse(res); return null; } const text = await res.text(); try { /** @type {{public_key:string}} */ const json = JSON.parse(text); return json.public_key; } catch (/** @type {unknown} */ err) { // TODO: report error to backend if (opts?.loglevel === "verbose") console.error(err); return null; } }; /** @type {boolean | undefined} */ 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 = 3; do { const timeout = AbortSignal.timeout(5_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((/** @type {{ message?: string, stack?: string, cause?: { code?: string } }} */ 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); } } return undefined; }); 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") logLicense("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") logLicense("INFO: Running in Stackblitz environment"); return false; } // Default to true: return true; } /** * @param {string} processName * @param {string[]} args * @param {DefaultOptions | undefined} opts * @returns {Promise<boolean>} */ async function runCommand(processName, args, opts) { if (opts?.loglevel === "verbose") logLicense(`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") logLicense(`INFO: \"${processName}\" process exited with code ${code}`); if (code === 0 || code === null || code === undefined) { resolve(true); } else { logLicense(`WARN: \"${processName}\" process exited with code ${code}\nProcess Arguments: ${args.join(" ")}`, "warn"); resolve(false); } }); process.on('error', (err) => { if (opts?.loglevel === "verbose") console.error("Error running " + processName, err); resolve(false); }); }); } /** * 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', (/** @type {Buffer|string} */ chunk) => { data += chunk; }); res.on('end', () => { resolve(data); }); }).on("error", (err) => { reject(err); }); }); }