UNPKG

@ndbx/runtime

Version:

The `@ndbx/runtime` package provides a runtime environment to embed NodeBox visualizations directly into React applications. NodeBox is a powerful tool for creating interactive and generative visualizations, and this runtime allows you to integrate those

516 lines (470 loc) 17 kB
import JSON5 from "json5"; import { Project, Item, Parameter, Port, FunctionItem, Choice, ParameterType, PortType, WidgetType, Section, } from "./types"; import Context from "./context"; import RuntimeNode, { defaultValueForType, defaultWidgetForType } from "./runtime-node"; import { updateFormatVersion } from "./upgrades"; import { evalTemplate, startCase } from "./string-utils"; import { findNodeStatements } from "./lexer"; interface LoadResult { status: "ok" | "error"; message?: string; assetsUrlTemplate?: string; project?: Project; } export async function loadProjectThroughApi(url: string): Promise<LoadResult> { const token = localStorage.getItem("token"); const response = await fetch(url, { method: "GET", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token || ""}`, }, }); if (!response.ok) { try { const result = await response.json(); return result; } catch (e) { return { status: "error", message: response.statusText }; } } try { const result = await response.json(); return result; } catch (e) { return { status: "error", message: "Failed to parse response" }; } } export async function loadProjectDirectly(url: string): Promise<LoadResult> { try { const response = await fetch(url); if (!response.ok) { try { const errBody = await response.json(); return { status: "error", message: errBody?.message || response.statusText, }; } catch { return { status: "error", message: response.statusText }; } } try { const data = await response.json(); return { status: "ok", project: data }; } catch (e: any) { return { status: "error", message: "Failed to parse JSON: " + String(e), }; } } catch (e: any) { return { status: "error", message: String(e) }; } } export const CURRENT_FORMAT_VERSION = 1; const envType = typeof window === "undefined" ? "node" : "browser"; export const config = { apiRoot: "https://new.nodebox.live", publishedUrlTemplate: "https://nodeboxlive.ams3.digitaloceanspaces.com/users/{{ userId }}/{{ projectId }}/versions/published.json", // Template for asset URLs assetsUrlTemplate: "https://nodeboxlive.ams3.cdn.digitaloceanspaces.com/users/{{ userId }}/{{ projectId }}/blobs/{{ hash }}", // Template for library URLs libUrlTemplate: "https://nodeboxlive.ams3.cdn.digitaloceanspaces.com/users/{{ userId }}/{{ projectId }}/lib/{{ file }}.js", // Used to replace @ndbx/g with https://esm.sh/@ndbx/g bareImportReplacer: (name: string) => `https://esm.sh/${name}`, }; interface ProjectLoader { assetMap: Map<string, any>; projectMap: Map<string, Project>; } export async function loadMainProject(userId: string, projectId: string, version: string): Promise<Context> { const projectKey = `${userId}/${projectId}`; const loader: ProjectLoader = { assetMap: new Map(), projectMap: new Map() }; const project = await loadProject(userId, projectId, version, loader); const dependencies = structuredClone(loader.projectMap); dependencies.delete(projectKey); const cx = new Context(project, loader.assetMap, dependencies); // await Promise.all(project.items.map((item: Item) => loadItem(cx, projectKey, item))); return cx; } async function loadProject( userId: string, projectId: string, version: string, loader: ProjectLoader, ): Promise<Project> { const projectKey = `${userId}/${projectId}`; if (loader.projectMap.has(projectKey)) { return loader.projectMap.get(projectKey)!; } let result: LoadResult; if (version !== "published") { // If it's not a published project, we're going through the API const projectUrl = `${config.apiRoot}/api/projects/${userId}/${projectId}/${version}`; result = await loadProjectThroughApi(projectUrl); } else { // If it is a published project, we'll use the publishedRoot prefix. // Note that this is not an API call! We'll just receive the project.json, so we can't check `result.status`. const projectUrl = evalTemplate(config.publishedUrlTemplate, { userId, projectId }); result = await loadProjectDirectly(projectUrl); } if (result.status !== "ok") { throw new Error(`Error loading project '${userId}/${projectId}': ${result.message}`); } if (result.assetsUrlTemplate) { config.assetsUrlTemplate = result.assetsUrlTemplate; } const project: Project = result.project!; if (project === undefined) { throw new Error(`Failed to load project ${userId}/${projectId}@${version}: ${result.message}`); } const loadedProject = updateFormatVersion(project); setMetaDataForItems(project); analyzeFunctions(loadedProject); loader.projectMap.set(projectKey, loadedProject); await loadAssets(userId, projectId, loadedProject, loader); await loadDependencies(loadedProject, loader); return loadedProject; } function setMetaDataForItems(project: Project) { for (const item of project.items) { item.width = item.width || 1000; item.height = item.height || 1000; } } function analyzeFunctions(project: Project) { if (project === undefined) return; for (const item of project.items) { if (item.type === "FUNCTION") { const nodeStatements = findNodeStatements(item.source); const { parameters, sections, inputPorts, outputPorts } = parseNodeStatements(nodeStatements); item.parameters = parameters; item.sections = sections; item.inputPorts = inputPorts; item.outputPorts = outputPorts; const itemDoc = extractJSDoc(item.source); item.description = itemDoc.description; item.category = itemDoc.category; } } } export function parseNodeStatements(statements: string[]): { parameters: Parameter[]; sections: Section[]; inputPorts: Port[]; outputPorts: Port[]; } { let currentSection = undefined; const parameters: Parameter[] = []; const sections: Section[] = []; const inputPorts: Port[] = []; const outputPorts: Port[] = []; const PARAMETER_TYPE_MAP = new Map<string, ParameterType>([ ["numberIn", ParameterType.Number], ["stringIn", ParameterType.String], ["booleanIn", ParameterType.Boolean], ["pointIn", ParameterType.Point], ["colorIn", ParameterType.Color], ["fileIn", ParameterType.File], ["choiceIn", ParameterType.Choice], ]); const INPUT_TYPE_MAP = new Map<string, PortType>([ ["tableIn", PortType.Table], ["shapeIn", PortType.Shape], ["specIn", PortType.Spec], ]); const OUTPUT_TYPE_MAP = new Map<string, PortType>([ ["tableOut", PortType.Table], ["shapeOut", PortType.Shape], ["specOut", PortType.Spec], ]); for (const statement of statements) { const match = statement.match(/node\.(\w+)\s*\(/); if (!match) continue; const [, methodName] = match; const argsString = statement.slice(statement.indexOf("(") + 1, statement.lastIndexOf(")")); const args = parseArgs(argsString); if (PARAMETER_TYPE_MAP.has(methodName)) { const type = PARAMETER_TYPE_MAP.get(methodName)!; parameters.push(createParameter(type, args, currentSection?.name)); } else if (INPUT_TYPE_MAP.has(methodName)) { inputPorts.push(createPort(methodName, args, INPUT_TYPE_MAP)); } else if (OUTPUT_TYPE_MAP.has(methodName)) { outputPorts.push(createPort(methodName, args, OUTPUT_TYPE_MAP)); } else if (methodName === "pushSection") { currentSection = createSection(args); sections.push(currentSection); } else if (methodName === "popSection") { currentSection = undefined; } else if (methodName === "error") { // Ignore error methods: // C:\GitHub\nodeboxlive\packages\runtime\functions\g\explode-data.js // C:\GitHub\nodeboxlive\packages\runtime\functions\g\flatten-data.js // -> node.error("Invalid source type."); } else { console.warn(`Unknown method: ${methodName}`); } } return { parameters, sections, inputPorts, outputPorts }; } function parseArgs(argsString: string): Record<string, any> { if (argsString === "") return {}; try { return JSON5.parse(argsString) as Record<string, any>; } catch (error) { console.error(`Failed to parse arguments: \`${argsString}\``); throw error; } } export function parseChoices(choices: string[] | string[][]): Choice[] { if (choices.length === 0) return []; if (typeof choices[0] === "string") { return (choices as string[]).map((name) => ({ name, label: startCase(name) })); } else { return (choices as string[][]).map(([name, label]) => ({ name, label })); } } function createSection(args: Record<string, any>): Section { return { name: args.name, collapsed: args.collapsed || false, }; } function createParameter(type: ParameterType, args: Record<string, any>, section?: string): Parameter { return { name: args.name, type: type, widget: (args.widget as WidgetType) || defaultWidgetForType(type), label: args.label || convertToLabel(args.name), section, defaultValue: args.value ?? defaultValueForType(type), choices: args.choices && parseChoices(args.choices), min: args.min ?? -Infinity, max: args.max ?? Infinity, step: args.step || 1, }; } function createPort(methodName: string, args: Record<string, any>, typeMap: Map<string, PortType>): Port { return { name: args.name, type: typeMap.get(methodName)!, }; } interface ItemDoc { description: string; category: string; } function extractJSDoc(sourceCode: string): ItemDoc { const commentRegex = /\/\*\*([\s\S]*?)\*\//; const match = sourceCode.match(commentRegex); if (!match) { return { description: "", category: "" }; } const lines = match[1].split("\n").map((l) => l.trim().replace(/^\*\s*/, "")); const firstTagIndex = lines.findIndex((l) => l.startsWith("@")); const description = lines.slice(0, firstTagIndex).join("\n"); const tagMap = new Map<string, string>(); for (const line of lines.slice(firstTagIndex)) { if (!line.startsWith("@")) continue; const [tag, ...name] = line.split(" "); tagMap.set(tag.slice(1), name.join(" ")); } return { description: description.trim(), category: tagMap.get("category") ?? "", }; } function loadAssets(userId: string, projectId: string, project: Project, loader: ProjectLoader) { if (project === undefined) return; if (typeof project.assets !== "object") return; const loaders = Object.entries(project.assets).map(([filename, hash]) => { return _loadAsset(userId, projectId, filename, hash).then((asset) => { loader.assetMap.set(filename, asset); }); }); return Promise.all(loaders); } const TEXT_BASED_FILE_EXTENSIONS = new Set([ ".csv", ".txt", ".json", ".html", ".svg", ".xml", ".css", ".geojson", ".log", ".mdf", ".yaml", ]); function assetUrl(userId: string, projectId: string, hash: string): string { return evalTemplate(config.assetsUrlTemplate, { userId, projectId, hash }); } export async function loadAsset(cx: Context, userId: string, projectId: string, filename: string): Promise<any> { const asset = await _loadAsset(userId, projectId, filename, filename); cx.assetMap.set(filename, asset); return asset; } async function _loadAsset(userId: string, projectId: string, filename: string, hash: string): Promise<any> { const url = assetUrl(userId, projectId, hash); const ext = fileExtension(filename).toLowerCase(); if (ext === ".jpg" || ext === ".png") { return loadImage(url); } else if (ext === ".js") { return loadScript(url); } else if (TEXT_BASED_FILE_EXTENSIONS.has(ext)) { const res = await fetch(url); const text = await res.text(); return text; } else { const res = await fetch(url); const buffer = await res.arrayBuffer(); return buffer; } } export function fileExtension(filename: string): string { const parts = filename.split("/"); const baseName = parts[parts.length - 1]; const dot = baseName.lastIndexOf("."); if (dot === -1) return ""; return filename.substring(dot); } async function loadImage(url: string): Promise<HTMLImageElement> { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = "Anonymous"; img.onload = () => resolve(img); img.onerror = reject; img.src = url; }); } async function loadScript(url: string): Promise<void> { return new Promise((resolve, reject) => { const script = document.createElement("script"); script.onload = () => resolve(); script.onerror = reject; script.src = url; document.body.appendChild(script); }); } function loadDependencies(project: Project, loader: ProjectLoader) { if (project === undefined) return; const loaders = Object.keys(project.dependencies).map((key) => { const [userId, projectId] = key.split("/"); // Dependencies always load the published version of the project return loadProject(userId, projectId, "published", loader); }); return Promise.all(loaders); } export async function loadItem(cx: Context, item: Item, itemKey: string) { if (item.type === "FUNCTION") { await loadFunction(cx, item as FunctionItem, itemKey); } } export async function loadFunction(cx: Context, item: FunctionItem, itemKey: string) { if (cx.initializers.has(itemKey)) { return; } const itemName = itemKey.split("/").pop(); if (envType === "browser") { const source = fixupSource(item.source, itemKey); const blob = new Blob([source], { type: "application/javascript" }); const url = URL.createObjectURL(blob); let error; try { const module = await import(/* @vite-ignore */ url); cx.initializers.set(itemKey, module.default || makeEmptyModule(module, itemName!)); } catch (e) { console.error(`Failed to load function ${itemKey}: ${e}`); cx.warnings.push(`Failed to load function ${itemKey}: ${e}`); error = e; } finally { URL.revokeObjectURL(url); } if (error) { throw error; } } else if (envType === "node") { try { const importSource = `data:text/javascript,${encodeURIComponent(item.source)}`; const module = await import(/* @vite-ignore */ importSource); cx.initializers.set(itemKey, module.default); } catch (error) { console.error(`Failed to load function ${itemKey}: ${error}`); cx.warnings.push(`Failed to load function ${itemKey}: ${error}`); } } } function makeEmptyModule(module: Record<string, any>, itemName: string) { return (node: RuntimeNode) => { node.onRender = () => { let md = ""; md += "This module does not contain a default export and is treated as a utility module.\n"; md += "The following exports are available:\n\n"; md += "```text\n"; for (const key in module) { md += `- ${key}\n`; } md += "```\n\n"; md += "Import it into another function like this:\n"; md += "```javascript\n"; md += `import * as util from 'project:${itemName}';\n`; md += "```\n\n"; if (Object.keys(module).length > 0) { md += "Or import a specific item like this:\n"; const firstKey = Object.keys(module)[0]; md += "```javascript\n"; md += `import { ${firstKey} } from 'project:${itemName}';\n`; md += "```\n\n"; } md += "If you expected this code to work as a node, make sure it looks like this:\n"; md += "```javascript\n"; md += "export default function(node) {\n"; md += " node.onRender = () => {\n"; md += " // your code here\n"; md += " }\n"; md += "}\n"; node.message = md; }; }; } function fixupSource(source: string, itemKey: string) { const graphicsImportRe = /import\s+(?:\{\s*[\w\s,]+\s*\}|\*\s+as\s+\w+)\s+from\s+"(@ndbx\/g)";/g; source = source.replace(graphicsImportRe, (match, graphicsLibrary) => { return match.replace(`"${graphicsLibrary}"`, `"${config.bareImportReplacer(graphicsLibrary)}"`); }); if (!source.includes('from "project:')) { return source; } let [userId, projectId, _] = itemKey.split("/"); if (userId === "self" && projectId === "self") { userId = document.location.pathname.split("/")[1]; projectId = document.location.pathname.split("/")[2]; } const regex = /from "project:(.*?)"/g; const libraryName = regex.exec(source)![1].toLowerCase().replaceAll(" ", "-"); const libUrl = evalTemplate(config.libUrlTemplate, { userId, projectId, file: libraryName }); source = source.replace(regex, `from "${libUrl}"`); return source; } function convertToLabel(parameterName: string): string { // Replace camelCase with space between words const spaced = parameterName.replace(/([a-z])([A-Z])/g, "$1 $2"); // Capitalize the first letter and convert the rest to lowercase return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); }