diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7048424 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules/ +.git/ +*.log* +.env* +coverage/ +target/ +dist/ +build/ +.venv/ +.Rhistory +.RData +.Ruserdata +.Rproj.user/ diff --git a/.gitignore b/.gitignore index e7745ec..2ed1985 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,15 @@ app/src/pages/metapop/cached-baseline.json # Rust target/ +# R +.Rproj.user/ +.Rhistory +.RData +.Ruserdata +*.Rproj +packrat/ +renv/ + # Scaffolded test projects test-project-python/ test-project-rust/ @@ -51,7 +60,14 @@ cfasim-ui/docs/* !cfasim-ui/docs/vitest.config.ts !cfasim-ui/docs/drift.test.ts -/CLAUDE.md .vscode/* .DS_Store .cache/ + +# ai + spec kit +/CLAUDE.md +/AGENTS.md +/specs +/.codex +/.agents +/.specify diff --git a/.prettierignore b/.prettierignore index 9e278d5..4f1e8c2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,4 +11,5 @@ cfasim-ui/docs/charts/ cfasim-ui/docs/shared/ cfasim-ui/docs/pyodide/ cfasim-ui/docs/wasm/ +cfasim-ui/docs/rwasm/ cfasim-ui/docs/theme/ diff --git a/cfasim-model-r/DESCRIPTION b/cfasim-model-r/DESCRIPTION new file mode 100644 index 0000000..5b76048 --- /dev/null +++ b/cfasim-model-r/DESCRIPTION @@ -0,0 +1,8 @@ +Package: cfasim +Title: cfasim Model Helpers +Version: 0.3.14 +Description: Minimal R helpers for returning cfasim model outputs. +License: Apache License (>= 2) +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 diff --git a/cfasim-model-r/NAMESPACE b/cfasim-model-r/NAMESPACE new file mode 100644 index 0000000..7df9169 --- /dev/null +++ b/cfasim-model-r/NAMESPACE @@ -0,0 +1,7 @@ +export(bool) +export(enum) +export(f64) +export(i32) +export(model_output) +export(model_outputs) +export(u32) diff --git a/cfasim-model-r/R/model-output.R b/cfasim-model-r/R/model-output.R new file mode 100644 index 0000000..d6acef1 --- /dev/null +++ b/cfasim-model-r/R/model-output.R @@ -0,0 +1,54 @@ +f64 <- function(x) { + list(type = "f64", values = as.numeric(x)) +} + +i32 <- function(x) { + list(type = "i32", values = as.integer(x)) +} + +u32 <- function(x) { + list(type = "u32", values = as.integer(x)) +} + +bool <- function(x) { + list(type = "bool", values = as.logical(x)) +} + +enum <- function(indices, labels) { + list( + type = "enum", + values = as.integer(indices), + enumLabels = as.character(labels) + ) +} + +model_output <- function(...) { + columns <- list(...) + data <- unname(lapply(columns, function(col) col$values)) + lengths <- vapply(data, length, integer(1)) + if (length(lengths) > 0 && length(unique(lengths)) != 1) { + stop("cfasim model output columns must have equal length") + } + + descriptors <- unname(Map( + function(name, col) { + descriptor <- list(name = name, type = col$type) + if (!is.null(col$enumLabels)) { + descriptor$enumLabels <- col$enumLabels + } + descriptor + }, + names(columns), + columns + )) + + list( + length = if (length(lengths) == 0) 0 else lengths[[1]], + columns = descriptors, + data = data + ) +} + +model_outputs <- function(...) { + list(`__modelOutputs` = TRUE, outputs = list(...)) +} diff --git a/cfasim-ui/cfasim-ui/package.json b/cfasim-ui/cfasim-ui/package.json index 3426929..2901b56 100644 --- a/cfasim-ui/cfasim-ui/package.json +++ b/cfasim-ui/cfasim-ui/package.json @@ -23,6 +23,8 @@ "./wasm/vite": "./src/wasm-vite.js", "./pyodide": "./src/pyodide.ts", "./pyodide/vite": "./src/pyodide-vite.js", + "./rwasm": "./src/rwasm.ts", + "./rwasm/vite": "./src/rwasm-vite.js", "./shared": "./src/shared.ts", "./theme": { "default": "./src/theme/cfasim.css" @@ -49,6 +51,7 @@ "@cfasim-ui/docs": "workspace:*", "@cfasim-ui/wasm": "workspace:*", "@cfasim-ui/pyodide": "workspace:*", + "@cfasim-ui/rwasm": "workspace:*", "@cfasim-ui/shared": "workspace:*", "@cfasim-ui/theme": "workspace:*" }, diff --git a/cfasim-ui/cfasim-ui/src/rwasm-vite.js b/cfasim-ui/cfasim-ui/src/rwasm-vite.js new file mode 100644 index 0000000..f5b638f --- /dev/null +++ b/cfasim-ui/cfasim-ui/src/rwasm-vite.js @@ -0,0 +1 @@ +export * from "@cfasim-ui/rwasm/vite"; diff --git a/cfasim-ui/cfasim-ui/src/rwasm.test.ts b/cfasim-ui/cfasim-ui/src/rwasm.test.ts new file mode 100644 index 0000000..0961bd3 --- /dev/null +++ b/cfasim-ui/cfasim-ui/src/rwasm.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); + +describe("cfasim-ui rwasm subpaths", () => { + it("defines unified runtime and vite re-exports", () => { + expect(existsSync(resolve(root, "src/rwasm.ts"))).toBe(true); + expect(existsSync(resolve(root, "src/rwasm-vite.js"))).toBe(true); + }); +}); diff --git a/cfasim-ui/cfasim-ui/src/rwasm.ts b/cfasim-ui/cfasim-ui/src/rwasm.ts new file mode 100644 index 0000000..14f1a69 --- /dev/null +++ b/cfasim-ui/cfasim-ui/src/rwasm.ts @@ -0,0 +1 @@ +export * from "@cfasim-ui/rwasm"; diff --git a/cfasim-ui/docs/drift.test.ts b/cfasim-ui/docs/drift.test.ts index e129779..c31c62a 100644 --- a/cfasim-ui/docs/drift.test.ts +++ b/cfasim-ui/docs/drift.test.ts @@ -15,6 +15,10 @@ describe("@cfasim-ui/docs generator", () => { ); const entries = [...index.content.components, ...index.content.charts]; expect(entries.length).toBeGreaterThan(0); + expect(existsSync(resolve(PACKAGE_ROOT, "rwasm/index.ts"))).toBe(true); + expect( + readFileSync(resolve(PACKAGE_ROOT, "rwasm/vitePlugin.js"), "utf-8"), + ).toContain("cfasim-model-r"); for (const entry of entries) { for (const field of ["docs", "source"] as const) { diff --git a/cfasim-ui/docs/package.json b/cfasim-ui/docs/package.json index b88f6a8..2399808 100644 --- a/cfasim-ui/docs/package.json +++ b/cfasim-ui/docs/package.json @@ -21,6 +21,7 @@ "shared", "pyodide", "wasm", + "rwasm", "theme" ] } diff --git a/cfasim-ui/rwasm/package.json b/cfasim-ui/rwasm/package.json new file mode 100644 index 0000000..866c60c --- /dev/null +++ b/cfasim-ui/rwasm/package.json @@ -0,0 +1,36 @@ +{ + "name": "@cfasim-ui/rwasm", + "version": "0.3.14", + "type": "module", + "description": "R/WebAssembly integration for cfasim-ui", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/CDCgov/cfa-simulator.git", + "directory": "cfasim-ui/rwasm" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "src" + ], + "scripts": { + "test": "vitest run" + }, + "exports": { + ".": "./src/index.ts", + "./vite": "./src/vitePlugin.js" + }, + "dependencies": { + "@cfasim-ui/shared": "workspace:*", + "webr": "^0.5.5" + }, + "peerDependencies": { + "vue": "^3.5.0" + }, + "devDependencies": { + "happy-dom": "^20.8.9", + "vitest": "^4.1.0" + } +} diff --git a/cfasim-ui/rwasm/src/index.test.ts b/cfasim-ui/rwasm/src/index.test.ts new file mode 100644 index 0000000..99015dd --- /dev/null +++ b/cfasim-ui/rwasm/src/index.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import * as api from "./index.js"; + +describe("@cfasim-ui/rwasm exports", () => { + it("exports runtime API", () => { + expect(api.loadModel).toBeTypeOf("function"); + expect(api.runR).toBeTypeOf("function"); + expect(api.useModel).toBeTypeOf("function"); + }); +}); diff --git a/cfasim-ui/rwasm/src/index.ts b/cfasim-ui/rwasm/src/index.ts new file mode 100644 index 0000000..ea3fcaa --- /dev/null +++ b/cfasim-ui/rwasm/src/index.ts @@ -0,0 +1,11 @@ +export { + createRwasmWorkerClient, + loadModel, + runR, + type JsonValue, + type RRunOptions, + type RwasmBundleManifest, + type RWorkerRequest, + type RWorkerResponse, +} from "./rwasmWorkerApi.js"; +export { useModel } from "./useModel.js"; diff --git a/cfasim-ui/rwasm/src/rwasm.worker.test.ts b/cfasim-ui/rwasm/src/rwasm.worker.test.ts new file mode 100644 index 0000000..56e0e81 --- /dev/null +++ b/cfasim-ui/rwasm/src/rwasm.worker.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { + convertRModelOutputs, + manifestUrl, + normalizeWebRValue, +} from "./rwasm.worker.js"; + +describe("rwasm worker helpers", () => { + it("builds manifest URL for a model", () => { + expect(manifestUrl("r_example")).toContain( + "/rwasm/r_example/manifest.json", + ); + }); + + it("converts R structured outputs to transferable model outputs", () => { + const converted = convertRModelOutputs({ + __modelOutputs: true, + outputs: { + series: { + length: 3, + columns: [{ name: "time", type: "f64" }], + data: [[0, 1, 2]], + }, + }, + }); + + expect(converted?.outputs.series.length).toBe(3); + expect(converted?.outputs.series.buffers[0]).toBeInstanceOf(ArrayBuffer); + }); + + it("normalizes webR maps before conversion", () => { + const value = normalizeWebRValue( + new Map([ + ["__modelOutputs", true], + [ + "outputs", + new Map([ + [ + "series", + new Map([ + ["length", 3], + [ + "columns", + [ + new Map([ + ["name", "time"], + ["type", "f64"], + ]), + ], + ], + ["data", [[0, 1, 2]]], + ]), + ], + ]), + ], + ]), + ); + + const converted = convertRModelOutputs(value); + + expect(converted?.outputs.series.columns[0]).toEqual({ + name: "time", + type: "f64", + }); + }); + + it("decodes serialized webR data before conversion", () => { + const value = normalizeWebRValue({ + type: "list", + names: ["__modelOutputs", "outputs"], + values: [ + { type: "logical", names: null, values: [true] }, + { + type: "list", + names: ["series"], + values: [ + { + type: "list", + names: ["length", "columns", "data"], + values: [ + { type: "integer", names: null, values: [3] }, + { + type: "list", + names: null, + values: [ + { + type: "list", + names: ["name", "type"], + values: [ + { type: "character", names: null, values: ["time"] }, + { type: "character", names: null, values: ["f64"] }, + ], + }, + ], + }, + { + type: "list", + names: null, + values: [{ type: "double", names: null, values: [0, 1, 2] }], + }, + ], + }, + ], + }, + ], + }); + + const converted = convertRModelOutputs(value); + + expect(converted?.outputs.series.length).toBe(3); + expect(converted?.outputs.series.buffers[0]).toBeInstanceOf(ArrayBuffer); + }); + + it("accepts named R lists for columns and data", () => { + const converted = convertRModelOutputs({ + __modelOutputs: true, + outputs: { + series: { + length: 3, + columns: { + time: { name: "time", type: "f64" }, + values: { name: "values", type: "f64" }, + }, + data: { + time: [0, 1, 2], + values: [0, 2, 4], + }, + }, + }, + }); + + expect(converted?.outputs.series.columns).toEqual([ + { name: "time", type: "f64" }, + { name: "values", type: "f64" }, + ]); + expect(converted?.outputs.series.buffers).toHaveLength(2); + }); + + it("converts enum and bool helper output shapes", () => { + const converted = convertRModelOutputs({ + __modelOutputs: true, + outputs: { + states: { + length: 3, + columns: [ + { name: "status", type: "enum", enumLabels: ["low", "high"] }, + { name: "active", type: "bool" }, + ], + data: [ + [0, 1, 0], + [true, false, true], + ], + }, + }, + }); + + expect(converted?.outputs.states.columns[0]).toEqual({ + name: "status", + type: "enum", + enumLabels: ["low", "high"], + }); + expect(converted?.outputs.states.buffers).toHaveLength(2); + }); +}); diff --git a/cfasim-ui/rwasm/src/rwasm.worker.ts b/cfasim-ui/rwasm/src/rwasm.worker.ts new file mode 100644 index 0000000..9b5056a --- /dev/null +++ b/cfasim-ui/rwasm/src/rwasm.worker.ts @@ -0,0 +1,253 @@ +import { + postErrorWithTransfer, + postModelOutputsWithTransfer, + postWithTransfer, +} from "@cfasim-ui/shared"; +import type { ColumnDescriptor, ModelOutputsWire } from "@cfasim-ui/shared"; +import type { + JsonValue, + RwasmBundleManifest, + RWorkerRequest, +} from "./rwasmWorkerApi.js"; + +interface RSession { + webR: any; + manifest: RwasmBundleManifest; +} + +const sessions = new Map>(); +const baseUrl = import.meta.env.BASE_URL ?? "/"; + +function joinUrl(base: string, path: string): string { + return `${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`; +} + +function appUrl(path: string): string { + if (/^https?:\/\//.test(path)) return path; + return joinUrl(`${self.location.origin}${baseUrl}`, path); +} + +export function manifestUrl(model: string): string { + const base = `${self.location.origin}${baseUrl}`; + return joinUrl(base, `rwasm/${model}/manifest.json`); +} + +function assertIdentifier(name: string): void { + if (!/^[A-Za-z.][A-Za-z0-9._]*$/.test(name)) { + throw new Error(`Invalid R function name: ${name}`); + } +} + +function rLiteral(value: JsonValue): string { + if (value === null) return "NULL"; + if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("R arguments must be finite"); + return String(value); + } + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return `list(${value.map(rLiteral).join(", ")})`; + return `list(${Object.entries(value) + .map(([key, val]) => `${key} = ${rLiteral(val)}`) + .join(", ")})`; +} + +function buildCall(fn: string, params?: Record): string { + assertIdentifier(fn); + const args = Object.entries(params ?? {}) + .map(([key, val]) => `${key} = ${rLiteral(val)}`) + .join(", "); + return `${fn}(${args})`; +} + +function asArrayBuffer(value: unknown, type: string): ArrayBuffer { + const values = Array.from(value as Iterable); + switch (type) { + case "f64": + return new Float64Array(values as number[]).buffer; + case "i32": + return new Int32Array(values as number[]).buffer; + case "u32": + case "enum": + return new Uint32Array(values as number[]).buffer; + case "bool": + return new Uint8Array(values.map((v) => (v ? 1 : 0))).buffer; + default: + throw new Error(`Unsupported R output column type: ${type}`); + } +} + +function arrayFromNamedList(value: unknown): unknown[] | null { + if (Array.isArray(value)) return value; + if (value && typeof value === "object") return Object.values(value); + return null; +} + +export function normalizeWebRValue(value: unknown): unknown { + if (value instanceof Map) { + return Object.fromEntries( + Array.from(value, ([key, val]) => [String(key), normalizeWebRValue(val)]), + ); + } + if (Array.isArray(value)) return value.map(normalizeWebRValue); + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return value; + if (isWebRDataJs(value)) return decodeWebRDataJs(value); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, val]) => [key, normalizeWebRValue(val)]), + ); + } + return value; +} + +function isWebRDataJs(value: unknown): value is { + type: string; + names?: string[] | null; + values?: unknown[]; +} { + return ( + !!value && + typeof value === "object" && + typeof (value as { type?: unknown }).type === "string" && + ("values" in value || (value as { type?: string }).type === "null") + ); +} + +function decodeWebRDataJs(value: { + type: string; + names?: string[] | null; + values?: unknown[]; +}): unknown { + if (value.type === "null") return null; + const values = (value.values ?? []).map(normalizeWebRValue); + const names = value.names ?? null; + if (names?.some(Boolean)) { + return Object.fromEntries(names.map((name, i) => [name, values[i]])); + } + if (value.type === "list") return values; + return values.length === 1 ? values[0] : values; +} + +async function evalRValue(webR: any, code: string): Promise { + const proxy = await webR.evalR(code); + try { + const value = + proxy && typeof proxy.toJs === "function" ? await proxy.toJs() : proxy; + return normalizeWebRValue(value); + } finally { + if (proxy && typeof proxy.destroy === "function") proxy.destroy(); + } +} + +export function convertRModelOutputs(value: unknown): ModelOutputsWire | null { + if (!value || typeof value !== "object") return null; + const maybe = value as { __modelOutputs?: unknown; outputs?: unknown }; + if ( + !maybe.__modelOutputs || + !maybe.outputs || + typeof maybe.outputs !== "object" + ) { + return null; + } + + const outputs: ModelOutputsWire["outputs"] = {}; + for (const [name, output] of Object.entries( + maybe.outputs as Record, + )) { + const columns = arrayFromNamedList(output.columns) as + | ColumnDescriptor[] + | null; + const data = arrayFromNamedList(output.buffers ?? output.data); + if (!columns || !data) { + throw new Error(`Invalid R ModelOutput: ${name}`); + } + + const buffers = columns.map((col, i) => asArrayBuffer(data[i], col.type)); + const length = + typeof output.length === "number" + ? output.length + : (data[0]?.length ?? 0); + + outputs[name] = { + __modelOutput: true, + length, + columns, + buffers, + }; + } + return { __modelOutputs: true, outputs }; +} + +async function loadManifest(model: string): Promise { + const response = await fetch(manifestUrl(model)); + if (!response.ok) { + throw new Error(`Failed to load R model manifest for ${model}`); + } + return (await response.json()) as RwasmBundleManifest; +} + +async function createSession(model: string): Promise { + const manifest = await loadManifest(model); + const { WebR } = await import("webr"); + const webR = new WebR( + manifest.webRBaseUrl ? { baseUrl: manifest.webRBaseUrl } : undefined, + ); + await webR.init(); + + if (manifest.packages.length > 0) { + if (!manifest.repoUrl) { + throw new Error(`R model ${model} lists packages without a repoUrl`); + } + const packages = manifest.packages.map(JSON.stringify).join(", "); + await webR.evalRVoid( + `webr::install(c(${packages}), repos = ${JSON.stringify(appUrl(manifest.repoUrl))})`, + ); + } + + if (manifest.libraryImageUrl) { + throw new Error("R filesystem image bundles are not implemented yet"); + } + + if (manifest.modelPackage) { + await webR.evalRVoid( + `library(${JSON.stringify(manifest.modelPackage)}, character.only = TRUE)`, + ); + } else { + const entryUrl = joinUrl(appUrl(manifest.modelBaseUrl), manifest.entry); + const entry = await fetch(entryUrl); + if (!entry.ok) throw new Error(`Failed to load R model entry: ${entryUrl}`); + await webR.evalRVoid(await entry.text()); + } + return { webR, manifest }; +} + +function ensureSession(model: string): Promise { + if (!sessions.has(model)) { + const promise = createSession(model); + promise.catch(() => sessions.delete(model)); + sessions.set(model, promise); + } + return sessions.get(model)!; +} + +self.onmessage = async (event: MessageEvent) => { + const { id, type, model, fn, params } = event.data; + try { + const session = await ensureSession(model); + if (type === "loadModel") { + postWithTransfer(self, id, true); + return; + } + + if (!fn) throw new Error("R function name is required"); + const result = await evalRValue(session.webR, buildCall(fn, params)); + const modelOutputs = convertRModelOutputs(result); + if (modelOutputs) { + postModelOutputsWithTransfer(self, id, modelOutputs); + } else { + postWithTransfer(self, id, result); + } + } catch (error) { + postErrorWithTransfer(self, id, error); + } +}; diff --git a/cfasim-ui/rwasm/src/rwasmWorkerApi.test.ts b/cfasim-ui/rwasm/src/rwasmWorkerApi.test.ts new file mode 100644 index 0000000..3a424f3 --- /dev/null +++ b/cfasim-ui/rwasm/src/rwasmWorkerApi.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { createRwasmWorkerClient } from "./rwasmWorkerApi.js"; + +class FakeWorker extends EventTarget { + messages: unknown[] = []; + + postMessage(message: unknown) { + this.messages.push(message); + const { id, type } = message as { id: number; type: string }; + setTimeout(() => { + this.dispatchEvent( + new MessageEvent("message", { + data: + type === "loadModel" ? { id, result: true } : { id, result: 10 }, + }), + ); + }); + } +} + +describe("rwasm worker API", () => { + it("sends loadModel and runR requests", async () => { + const baseline = new FakeWorker() as unknown as Worker; + const intervention = new FakeWorker() as unknown as Worker; + const client = createRwasmWorkerClient({ baseline, intervention }); + + await expect(client.loadModel("r_example")).resolves.toEqual({ + result: true, + }); + await expect(client.runR("r_example", "double", { n: 5 })).resolves.toEqual( + { + result: 10, + }, + ); + + expect((baseline as unknown as FakeWorker).messages).toMatchObject([ + { type: "loadModel", model: "r_example" }, + { type: "run", model: "r_example", fn: "double", params: { n: 5 } }, + ]); + }); +}); diff --git a/cfasim-ui/rwasm/src/rwasmWorkerApi.ts b/cfasim-ui/rwasm/src/rwasmWorkerApi.ts new file mode 100644 index 0000000..700053a --- /dev/null +++ b/cfasim-ui/rwasm/src/rwasmWorkerApi.ts @@ -0,0 +1,161 @@ +import type { ColumnDescriptor, TransferableResponse } from "@cfasim-ui/shared"; +import { unwrapResponse } from "@cfasim-ui/shared"; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export interface RRunOptions { + worker?: WorkerName; +} + +export interface RwasmBundleManifest { + name: string; + entry: string; + modelPackage?: string; + modelBaseUrl: string; + packages: string[]; + repoUrl?: string; + libraryImageUrl?: string; + webRBaseUrl?: string; + createdBy: "cfasimRwasm"; +} + +export interface RWorkerRequest { + id: number; + type: "loadModel" | "run"; + model: string; + fn?: string; + params?: Record; +} + +export interface RWorkerResponse extends TransferableResponse { + id: number; + modelOutputs?: { + outputs: Record< + string, + { length: number; columns: ColumnDescriptor[]; buffers: ArrayBuffer[] } + >; + }; +} + +export type WorkerName = "baseline" | "intervention"; + +interface PendingRequest { + resolve: (value: { result?: unknown; error?: string }) => void; + reject: (reason?: unknown) => void; +} + +export interface RwasmWorkerClient { + loadModel: ( + modelName: string, + options?: RRunOptions, + ) => Promise<{ result?: true; error?: string }>; + runR: ( + modelName: string, + fn: string, + params?: Record, + options?: RRunOptions, + ) => Promise<{ result?: unknown; error?: string }>; +} + +function createWorker(): Worker { + return new Worker(new URL("./rwasm.worker.ts", import.meta.url), { + type: "module", + }); +} + +export function createRwasmWorkerClient( + workers: Record = { + baseline: createWorker(), + intervention: createWorker(), + }, +): RwasmWorkerClient { + let lastId = 0; + const pending = new Map(); + + for (const worker of Object.values(workers)) { + worker.addEventListener( + "message", + (event: MessageEvent) => { + const id = event.data?.id; + if (!pending.has(id)) return; + const request = pending.get(id)!; + pending.delete(id); + if (event.data.error) { + request.resolve({ error: event.data.error }); + } else { + request.resolve({ result: unwrapResponse(event.data) }); + } + }, + ); + + worker.addEventListener("error", (event) => { + for (const [id, request] of pending) { + pending.delete(id); + request.reject(event.error ?? new Error(event.message)); + } + }); + } + + function requestResponse( + workerName: WorkerName, + msg: Omit, + ): Promise<{ result?: unknown; error?: string }> { + const id = ++lastId; + const promise = new Promise<{ result?: unknown; error?: string }>( + (resolve, reject) => { + pending.set(id, { resolve, reject }); + }, + ); + workers[workerName].postMessage({ id, ...msg } satisfies RWorkerRequest); + return promise; + } + + return { + async loadModel(modelName, options) { + const response = await requestResponse(options?.worker ?? "baseline", { + type: "loadModel", + model: modelName, + }); + return response.error + ? { error: response.error } + : { result: true as const }; + }, + runR(modelName, fn, params, options) { + return requestResponse(options?.worker ?? "baseline", { + type: "run", + model: modelName, + fn, + params, + }); + }, + }; +} + +let defaultClient: RwasmWorkerClient | undefined; + +function getDefaultClient(): RwasmWorkerClient { + defaultClient ??= createRwasmWorkerClient(); + return defaultClient; +} + +export function loadModel( + modelName: string, + options?: RRunOptions, +): Promise<{ result?: true; error?: string }> { + return getDefaultClient().loadModel(modelName, options); +} + +export function runR( + modelName: string, + fn: string, + params?: Record, + options?: RRunOptions, +): Promise<{ result?: unknown; error?: string }> { + return getDefaultClient().runR(modelName, fn, params, options); +} diff --git a/cfasim-ui/rwasm/src/useModel.test.ts b/cfasim-ui/rwasm/src/useModel.test.ts new file mode 100644 index 0000000..9a3a2fb --- /dev/null +++ b/cfasim-ui/rwasm/src/useModel.test.ts @@ -0,0 +1,34 @@ +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadModel = vi.fn(); +const runR = vi.fn(); + +vi.mock("./rwasmWorkerApi.js", () => ({ loadModel, runR })); + +describe("useModel", () => { + beforeEach(() => { + loadModel.mockReset().mockResolvedValue({ result: true }); + runR.mockReset().mockResolvedValue({ result: 10 }); + }); + + it("tracks loading and result for run calls", async () => { + const { useModel } = await import("./useModel.js"); + const model = useModel("r_example"); + await nextTick(); + await model.run("double", { n: 5 }); + + expect(runR).toHaveBeenCalledWith("r_example", "double", { n: 5 }); + expect(model.result.value).toBe(10); + expect(model.loading.value).toBe(false); + }); + + it("tracks errors", async () => { + runR.mockResolvedValue({ error: "boom" }); + const { useModel } = await import("./useModel.js"); + const model = useModel("r_example"); + await model.run("fail"); + + expect(model.error.value).toBe("boom"); + }); +}); diff --git a/cfasim-ui/rwasm/src/useModel.ts b/cfasim-ui/rwasm/src/useModel.ts new file mode 100644 index 0000000..963f0e7 --- /dev/null +++ b/cfasim-ui/rwasm/src/useModel.ts @@ -0,0 +1,78 @@ +import { ref, toRaw, toValue, watch } from "vue"; +import type { MaybeRef } from "vue"; +import type { ModelOutput } from "@cfasim-ui/shared"; +import { loadModel, runR } from "./rwasmWorkerApi.js"; +import type { JsonValue } from "./rwasmWorkerApi.js"; + +export function useModel(modelName: string) { + const result = ref(); + const error = ref(); + const loading = ref(true); + + const loaded = loadModel(modelName); + loaded.then((response) => { + if (response.error) error.value = response.error; + loading.value = false; + }); + + async function run(fn: string, params?: Record) { + loading.value = true; + error.value = undefined; + try { + await loaded; + const plain = params + ? Object.fromEntries( + Object.entries(params).map(([key, value]) => [key, toRaw(value)]), + ) + : undefined; + const response = await runR(modelName, fn, plain); + if (response.error) { + error.value = response.error; + } else { + result.value = response.result as T; + } + } catch (e) { + error.value = e instanceof Error ? e.message : String(e); + } finally { + loading.value = false; + } + } + + function useOutputs

>( + fn: string, + params: MaybeRef

, + ) { + const outputs = ref>(); + const outputsError = ref(); + const outputsLoading = ref(true); + + watch( + () => toValue(params), + async (p) => { + outputsLoading.value = true; + outputsError.value = undefined; + try { + await loaded; + const plain = Object.fromEntries( + Object.entries(p).map(([key, value]) => [key, toRaw(value)]), + ) as Record; + const response = await runR(modelName, fn, plain); + if (response.error) { + outputsError.value = response.error; + } else { + outputs.value = response.result as Record; + } + } catch (e) { + outputsError.value = e instanceof Error ? e.message : String(e); + } finally { + outputsLoading.value = false; + } + }, + { immediate: true, deep: true }, + ); + + return { outputs, error: outputsError, loading: outputsLoading }; + } + + return { run, result, error, loading, useOutputs }; +} diff --git a/cfasim-ui/rwasm/src/vitePlugin.js b/cfasim-ui/rwasm/src/vitePlugin.js new file mode 100644 index 0000000..7ba2676 --- /dev/null +++ b/cfasim-ui/rwasm/src/vitePlugin.js @@ -0,0 +1,258 @@ +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_IMAGE = "ghcr.io/r-wasm/webr:main"; +const CFASIM_PACKAGE_NAME = "cfasim"; +const DEFAULT_CFASIM_PACKAGE_DIR = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../cfasim-model-r", +); + +function normalizeName(name) { + return name.replace(/-/g, "_"); +} + +function resolveEntry(modelDir, entry) { + if (entry) return entry; + if (existsSync(resolve(modelDir, "R", "model.R"))) return "R/model.R"; + return "model.R"; +} + +function dockerOptions(options) { + if (typeof options?.docker === "object") return options.docker; + return {}; +} + +function rVector(items) { + return `c(${items.map((pkg) => `"${pkg}"`).join(", ")})`; +} + +export function dockerCommand({ + modelDir, + outDir, + cfasimPackageDir, + modelPackage, + packages, + docker, +}) { + const image = docker.image ?? DEFAULT_IMAGE; + const externalPackages = packages.filter( + (pkg) => pkg !== CFASIM_PACKAGE_NAME && pkg !== modelPackage, + ); + const buildExternal = + externalPackages.length > 0 + ? [ + `rwasm::build(${rVector(externalPackages)}, out_dir = bin, dependencies = NA)`, + ] + : []; + const buildModel = modelPackage + ? [`rwasm::build("/model", out_dir = bin, dependencies = FALSE)`] + : []; + const rCommand = + docker.rCommand ?? + [ + `pak::pak("r-wasm/rwasm")`, + `pak::pkg_install("/cfasim-model-r")`, + `rv <- getRversion()`, + `bin <- file.path("/output/repo/bin/emscripten/contrib", paste0(rv$major, ".", rv$minor))`, + `dir.create(bin, recursive = TRUE, showWarnings = FALSE)`, + `dir.create("/output/repo/src/contrib", recursive = TRUE, showWarnings = FALSE)`, + `rwasm::build("/cfasim-model-r", out_dir = bin, dependencies = FALSE)`, + ...buildExternal, + ...buildModel, + `rwasm::write_packages("/output/repo")`, + ].join("; "); + const mounts = cfasimPackageDir + ? ["-v", `${cfasimPackageDir}:/cfasim-model-r:ro`] + : []; + + return { + file: "docker", + args: [ + "run", + "--rm", + "-v", + `${modelDir}:/model:ro`, + ...mounts, + "-v", + `${outDir}:/output`, + "-w", + "/output", + image, + "R", + "-q", + "-e", + rCommand, + ], + }; +} + +function shouldRunDocker(outDir, packages, docker) { + if (packages.length === 0) return false; + const rebuild = docker.rebuild ?? "auto"; + if (rebuild === "always") return true; + if (rebuild === "never") return false; + return !repoHasPackages(join(outDir, "repo"), packages); +} + +function readPackageIndexes(repoDir) { + if (!existsSync(repoDir)) return []; + const indexes = []; + for (const entry of readdirSync(repoDir)) { + const path = join(repoDir, entry); + const stat = statSync(path); + if (stat.isDirectory()) indexes.push(...readPackageIndexes(path)); + if (stat.isFile() && entry === "PACKAGES") { + indexes.push(readFileSync(path, "utf-8")); + } + } + return indexes; +} + +function repoHasPackages(repoDir, packages) { + const indexes = readPackageIndexes(repoDir).join("\n"); + return packages.every((pkg) => + new RegExp( + `^Package:\\s*${pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, + "m", + ).test(indexes), + ); +} + +function copyModel(modelDir, outDir) { + const target = join(outDir, "model"); + rmSync(target, { recursive: true, force: true }); + mkdirSync(target, { recursive: true }); + cpSync(modelDir, target, { recursive: true }); +} + +function uniquePackages(packages) { + return Array.from(new Set(packages.filter(Boolean))); +} + +function readDescriptionPackages(modelDir) { + if (!existsSync(resolve(modelDir, "DESCRIPTION"))) return []; + return Array.from( + readFileSync(resolve(modelDir, "DESCRIPTION"), "utf-8").matchAll( + /^(?:Imports|Depends):\s*(.+)$/gm, + ), + ).flatMap((match) => + match[1] + .split(",") + .map((pkg) => pkg.trim().replace(/\s*\(.+\)$/, "")) + .filter((pkg) => pkg && pkg !== "R"), + ); +} + +function readDescriptionPackageName(modelDir) { + if (!existsSync(resolve(modelDir, "DESCRIPTION"))) return undefined; + const match = readFileSync(resolve(modelDir, "DESCRIPTION"), "utf-8").match( + /^Package:\s*(.+)$/m, + ); + return match?.[1]?.trim(); +} + +function writeManifest({ + outDir, + name, + entry, + modelPackage, + packages, + webRBaseUrl, +}) { + const manifest = { + name, + entry, + modelPackage, + modelBaseUrl: `rwasm/${name}/model/`, + packages, + repoUrl: packages.length > 0 ? `rwasm/${name}/repo/` : undefined, + webRBaseUrl, + createdBy: "cfasimRwasm", + }; + writeFileSync( + join(outDir, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); +} + +export function buildRwasmBundle(root, options = {}) { + const modelDir = resolve(root, options.model ?? "model"); + if (!existsSync(modelDir)) + throw new Error(`R model directory not found: ${modelDir}`); + + const name = normalizeName(options.name ?? basename(root)); + const entry = resolveEntry(modelDir, options.entry); + const entryPath = resolve(modelDir, entry); + if (!existsSync(entryPath)) + throw new Error(`R model entry not found: ${entryPath}`); + + const outDir = resolve(root, "public", "rwasm", name); + mkdirSync(outDir, { recursive: true }); + copyModel(modelDir, outDir); + + const cfasimPackageDir = resolve( + root, + options.cfasimPackage ?? DEFAULT_CFASIM_PACKAGE_DIR, + ); + if (!existsSync(cfasimPackageDir)) { + throw new Error( + `cfasim R package directory not found: ${cfasimPackageDir}`, + ); + } + + const modelPackage = + options.modelPackage ?? readDescriptionPackageName(modelDir); + const packages = uniquePackages([ + CFASIM_PACKAGE_NAME, + modelPackage, + ...(options.packages ?? readDescriptionPackages(modelDir)), + ]); + + const docker = + options.docker === false ? { rebuild: "never" } : dockerOptions(options); + if (shouldRunDocker(outDir, packages, docker)) { + const command = dockerCommand({ + modelDir, + outDir, + cfasimPackageDir, + modelPackage, + packages, + docker, + }); + execFileSync(command.file, command.args, { cwd: root, stdio: "pipe" }); + } + + if (packages.length > 0 && !repoHasPackages(join(outDir, "repo"), packages)) { + throw new Error(`R package assets missing: ${join(outDir, "repo")}`); + } + + writeManifest({ + outDir, + name, + entry, + modelPackage, + packages, + webRBaseUrl: options.webRBaseUrl, + }); +} + +export function cfasimRwasm(options) { + return { + name: "cfasim-rwasm", + configResolved(config) { + buildRwasmBundle(config.root, options); + }, + }; +} diff --git a/cfasim-ui/rwasm/src/vitePlugin.test.js b/cfasim-ui/rwasm/src/vitePlugin.test.js new file mode 100644 index 0000000..58c4e95 --- /dev/null +++ b/cfasim-ui/rwasm/src/vitePlugin.test.js @@ -0,0 +1,135 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildRwasmBundle, dockerCommand } from "./vitePlugin.js"; + +const roots = []; + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), "cfasim-rwasm-")); + roots.push(root); + await mkdir(join(root, "model", "R"), { recursive: true }); + writeFileSync( + join(root, "model", "R", "model.R"), + "double <- function(n) n * 2\n", + ); + writeFileSync( + join(root, "model", "DESCRIPTION"), + [ + "Package: r.example", + "Title: R Example", + "Version: 0.0.1", + "Description: Test package.", + "License: CC0", + "Encoding: UTF-8", + "Imports: cfasim", + "", + ].join("\n"), + ); + return root; +} + +afterEach(async () => { + await Promise.all( + roots.map((root) => rm(root, { recursive: true, force: true })), + ); + roots.length = 0; +}); + +describe("cfasimRwasm build", () => { + it("writes model assets and manifest", async () => { + const root = await fixture(); + mkdirSync( + join(root, "public", "rwasm", "r_example", "repo", "src", "contrib"), + { + recursive: true, + }, + ); + writeFileSync( + join( + root, + "public", + "rwasm", + "r_example", + "repo", + "src", + "contrib", + "PACKAGES", + ), + "Package: cfasim\nVersion: 0.3.14\n\nPackage: r.example\nVersion: 0.0.1\n", + ); + buildRwasmBundle(root, { model: "model", name: "r_example" }); + + const manifest = JSON.parse( + readFileSync( + join(root, "public", "rwasm", "r_example", "manifest.json"), + "utf-8", + ), + ); + expect(manifest).toMatchObject({ + name: "r_example", + entry: "R/model.R", + modelPackage: "r.example", + modelBaseUrl: "rwasm/r_example/model/", + packages: ["cfasim", "r.example"], + createdBy: "cfasimRwasm", + }); + }); + + it("does not accept an empty package repo as cached assets", async () => { + const root = await fixture(); + mkdirSync(join(root, "public", "rwasm", "r_example", "repo"), { + recursive: true, + }); + + expect(() => + buildRwasmBundle(root, { + model: "model", + name: "r_example", + docker: false, + }), + ).toThrow(/R package assets missing/); + }); + + it("fails for missing model directory", () => { + const root = mkdtempSync(join(tmpdir(), "cfasim-rwasm-")); + roots.push(root); + expect(() => buildRwasmBundle(root, { model: "missing" })).toThrow( + /R model directory not found/, + ); + }); + + it("fails for missing entry file", async () => { + const root = await fixture(); + expect(() => + buildRwasmBundle(root, { model: "model", entry: "missing.R" }), + ).toThrow(/R model entry not found/); + }); + + it("constructs Docker package build command", async () => { + const root = await fixture(); + const command = dockerCommand({ + modelDir: join(root, "model"), + outDir: join(root, "public", "rwasm", "r_example"), + cfasimPackageDir: join(root, "cfasim-model-r"), + modelPackage: "r.example", + packages: ["cfasim", "r.example", "jsonlite"], + docker: {}, + }); + expect(command.file).toBe("docker"); + expect(command.args).toContain("ghcr.io/r-wasm/webr:main"); + expect(command.args).toContain( + `${join(root, "cfasim-model-r")}:/cfasim-model-r:ro`, + ); + expect(command.args.at(-1)).toContain("rwasm::build"); + expect(command.args.at(-1)).toContain( + 'pak::pkg_install("/cfasim-model-r")', + ); + expect(command.args.at(-1)).toContain('"/cfasim-model-r"'); + expect(command.args.at(-1)).toContain('"/model"'); + expect(command.args.at(-1)).toContain("rwasm::write_packages"); + expect(command.args.at(-1)).toContain("jsonlite"); + }); +}); diff --git a/cfasim-ui/rwasm/vitest.config.ts b/cfasim-ui/rwasm/vitest.config.ts new file mode 100644 index 0000000..6615138 --- /dev/null +++ b/cfasim-ui/rwasm/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.{ts,js}"], + environment: "happy-dom", + }, +}); diff --git a/cfasim/playwright.config.ts b/cfasim/playwright.config.ts index 566c408..8773a34 100644 --- a/cfasim/playwright.config.ts +++ b/cfasim/playwright.config.ts @@ -5,4 +5,7 @@ export default defineConfig({ testMatch: "*.spec.ts", testIgnore: ["src/templates/**", "**/node_modules/**"], timeout: 120_000, + use: { + ignoreHTTPSErrors: true, + }, }); diff --git a/cfasim/src/init.rs b/cfasim/src/init.rs index b49f01c..bae61b1 100644 --- a/cfasim/src/init.rs +++ b/cfasim/src/init.rs @@ -16,6 +16,7 @@ static TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/templates"); pub enum Template { Python, Rust, + R, } impl Template { @@ -23,6 +24,7 @@ impl Template { match self { Template::Python => "python/", Template::Rust => "rust/", + Template::R => "R/", } } } @@ -32,6 +34,7 @@ impl fmt::Display for Template { match self { Template::Python => write!(f, "Python"), Template::Rust => write!(f, "Rust (WASM)"), + Template::R => write!(f, "R"), } } } @@ -111,16 +114,22 @@ fn to_module_name(name: &str) -> String { name.replace('-', "_") } +fn to_r_package_name(name: &str) -> String { + name.replace(['-', '_'], ".") +} + fn render(template: &str, name: &str) -> String { template .replace("%%project_name%%", name) .replace("%%module_name%%", &to_module_name(name)) + .replace("%%r_package_name%%", &to_r_package_name(name)) .replace("%%cfasim_version%%", env!("CARGO_PKG_VERSION")) } fn render_path(path: &str, name: &str) -> String { path.replace("%%project_name%%", name) .replace("%%module_name%%", &to_module_name(name)) + .replace("%%r_package_name%%", &to_r_package_name(name)) } fn write_file(base: &Path, relative: &str, content: &str) -> std::io::Result<()> { @@ -251,6 +260,11 @@ pub fn run( "Rust", "Compiles to WebAssembly via wasm-bindgen", ) + .item( + Template::R, + "R", + "Bundles R packages for WebAssembly via rwasm", + ) .interact()?, }; diff --git a/cfasim/src/main.rs b/cfasim/src/main.rs index 87b72c4..02538b6 100644 --- a/cfasim/src/main.rs +++ b/cfasim/src/main.rs @@ -27,7 +27,7 @@ enum Commands { #[arg(long, default_missing_value = ".")] dir: Option, - /// Template: python or rust (skips interactive prompt) + /// Template: python, rust, or r (skips interactive prompt) #[arg(long)] template: Option, @@ -70,6 +70,7 @@ enum Commands { enum TemplateArg { Python, Rust, + R, } fn main() -> Result<()> { @@ -91,6 +92,7 @@ fn main() -> Result<()> { let template = template.map(|t| match t { TemplateArg::Python => init::Template::Python, TemplateArg::Rust => init::Template::Rust, + TemplateArg::R => init::Template::R, }); init::run(dir, template, local).map_err(|e| anyhow::anyhow!("{e}")) } diff --git a/cfasim/src/templates/R/.gitignore b/cfasim/src/templates/R/.gitignore new file mode 100644 index 0000000..4def718 --- /dev/null +++ b/cfasim/src/templates/R/.gitignore @@ -0,0 +1,7 @@ +dist/ +node_modules/ +.Rhistory +.RData +.Ruserdata +.Rproj.user/ +test-results/ diff --git a/cfasim/src/templates/R/AGENTS.md b/cfasim/src/templates/R/AGENTS.md new file mode 100644 index 0000000..c9b82e0 --- /dev/null +++ b/cfasim/src/templates/R/AGENTS.md @@ -0,0 +1,14 @@ +# AGENTS.md + +`%%project_name%%` is a cfasim simulation built with R, Vue 3, rwasm, and webR. + +- R model package: `DESCRIPTION`, `NAMESPACE`, and `R/model.R` +- Vue frontend: `interactive/src/App.vue` +- Runtime hook: `useModel` from `cfasim-ui/rwasm` + +## Commands + +- `pnpm dev` — start Vite +- `pnpm build` — build static assets +- `pnpm typecheck` — run Vue type checking +- `pnpm test:e2e` — run Playwright diff --git a/cfasim/src/templates/R/CLAUDE.md b/cfasim/src/templates/R/CLAUDE.md new file mode 100644 index 0000000..3c04bb2 --- /dev/null +++ b/cfasim/src/templates/R/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +See `AGENTS.md`. diff --git a/cfasim/src/templates/R/DESCRIPTION b/cfasim/src/templates/R/DESCRIPTION new file mode 100644 index 0000000..e713ca8 --- /dev/null +++ b/cfasim/src/templates/R/DESCRIPTION @@ -0,0 +1,7 @@ +Package: %%r_package_name%% +Title: %%project_name%% +Version: 0.1.0 +Description: cfasim R model. +License: CC0 +Encoding: UTF-8 +Imports: cfasim diff --git a/cfasim/src/templates/R/NAMESPACE b/cfasim/src/templates/R/NAMESPACE new file mode 100644 index 0000000..72c0957 --- /dev/null +++ b/cfasim/src/templates/R/NAMESPACE @@ -0,0 +1,2 @@ +export(simulate) +import(cfasim) diff --git a/cfasim/src/templates/R/R/model.R b/cfasim/src/templates/R/R/model.R new file mode 100644 index 0000000..57f8c8e --- /dev/null +++ b/cfasim/src/templates/R/R/model.R @@ -0,0 +1,10 @@ +simulate <- function(steps, rate) { + time <- seq(0, steps - 1) + values <- time * rate + model_outputs( + series = model_output( + time = f64(time), + values = f64(values) + ) + ) +} diff --git a/cfasim/src/templates/R/interactive/app.spec.ts b/cfasim/src/templates/R/interactive/app.spec.ts new file mode 100644 index 0000000..575b798 --- /dev/null +++ b/cfasim/src/templates/R/interactive/app.spec.ts @@ -0,0 +1,6 @@ +import { test, expect } from "@playwright/test"; + +test("app loads", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("h1")).toContainText("%%project_name%%"); +}); diff --git a/cfasim/src/templates/R/interactive/index.html b/cfasim/src/templates/R/interactive/index.html new file mode 100644 index 0000000..fa207c8 --- /dev/null +++ b/cfasim/src/templates/R/interactive/index.html @@ -0,0 +1,2 @@ +

+ diff --git a/cfasim/src/templates/R/interactive/src/App.vue b/cfasim/src/templates/R/interactive/src/App.vue new file mode 100644 index 0000000..7e7755c --- /dev/null +++ b/cfasim/src/templates/R/interactive/src/App.vue @@ -0,0 +1,34 @@ + + + diff --git a/cfasim/src/templates/R/interactive/src/env.d.ts b/cfasim/src/templates/R/interactive/src/env.d.ts new file mode 100644 index 0000000..ab8ee34 --- /dev/null +++ b/cfasim/src/templates/R/interactive/src/env.d.ts @@ -0,0 +1,9 @@ +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent< + Record, + Record, + unknown + >; + export default component; +} diff --git a/cfasim/src/templates/R/interactive/src/main.ts b/cfasim/src/templates/R/interactive/src/main.ts new file mode 100644 index 0000000..6c1e3dd --- /dev/null +++ b/cfasim/src/templates/R/interactive/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "cfasim-ui/theme"; + +createApp(App).mount("#app"); diff --git a/cfasim/src/templates/R/package.json b/cfasim/src/templates/R/package.json new file mode 100644 index 0000000..45d7670 --- /dev/null +++ b/cfasim/src/templates/R/package.json @@ -0,0 +1,23 @@ +{ + "name": "%%project_name%%", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "vue-tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "vue": "^3.5.30", + "cfasim-ui": "^%%cfasim_version%%" + }, + "devDependencies": { + "@cfasim-ui/docs": "^%%cfasim_version%%", + "@playwright/test": "^1.58.2", + "@vitejs/plugin-vue": "^6.0.5", + "typescript": "^5.9.3", + "vite": "^8.0.1", + "vue-tsc": "^3.2.6" + } +} diff --git a/cfasim/src/templates/R/playwright.config.ts b/cfasim/src/templates/R/playwright.config.ts new file mode 100644 index 0000000..45a3e6b --- /dev/null +++ b/cfasim/src/templates/R/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./interactive", + testMatch: "*.spec.ts", + timeout: 60_000, + webServer: { + command: "pnpm exec vite --port 7300 --strictPort", + port: 7300, + reuseExistingServer: false, + timeout: 60_000, + }, + use: { + baseURL: "http://localhost:7300", + }, +}); diff --git a/cfasim/src/templates/R/tsconfig.json b/cfasim/src/templates/R/tsconfig.json new file mode 100644 index 0000000..eb9a3fc --- /dev/null +++ b/cfasim/src/templates/R/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["interactive/src/**/*.ts", "interactive/src/**/*.vue"] +} diff --git a/cfasim/src/templates/R/vite.config.ts b/cfasim/src/templates/R/vite.config.ts new file mode 100644 index 0000000..a2779d1 --- /dev/null +++ b/cfasim/src/templates/R/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { cfasimRwasm } from "cfasim-ui/rwasm/vite"; + +export default defineConfig({ + root: "interactive", + build: { outDir: "../dist", emptyOutDir: true }, + plugins: [vue(), cfasimRwasm({ model: "..", name: "%%module_name%%" })], +}); diff --git a/docs/cfasim-ui/index.md b/docs/cfasim-ui/index.md index febe191..194025b 100644 --- a/docs/cfasim-ui/index.md +++ b/docs/cfasim-ui/index.md @@ -11,6 +11,7 @@ cfasim-ui is the shared component and theming library you use to make simulators | `@cfasim-ui/theme` | Design tokens, reset, and utility classes | | `@cfasim-ui/pyodide` | Run Python models in the browser via [Pyodide](https://pyodide.org) Web Workers | | `@cfasim-ui/wasm` | Run Rust/WASM models in the browser via a Web Worker | +| `@cfasim-ui/rwasm` | Run bundled R models in the browser via webR | | `@cfasim-ui/shared` | Shared utilities, including the [`useUrlParams`](./shared) composable | ## Components @@ -36,3 +37,4 @@ cfasim-ui is the shared component and theming library you use to make simulators - [Pyodide](./pyodide) — run Python models via Pyodide Web Workers - [WASM](./wasm) — run Rust/WASM models via a Web Worker +- [rwasm](./rwasm) — run bundled R models via webR diff --git a/docs/cfasim-ui/rwasm.md b/docs/cfasim-ui/rwasm.md new file mode 100644 index 0000000..5e65cb6 --- /dev/null +++ b/docs/cfasim-ui/rwasm.md @@ -0,0 +1,56 @@ +# rwasm + +`@cfasim-ui/rwasm` runs bundled R models in the browser with webR. + +## useModel + +```ts +import { useModel } from "@cfasim-ui/rwasm"; + +const { useOutputs } = useModel("my_model"); +const { outputs, error, loading } = useOutputs("simulate", params); +``` + +`useOutputs(fn, params)` matches the Python and Rust runtime packages. R functions return `cfasim` helper output, and the worker converts it into named `ModelOutput` tables for display. + +## Vite + +```ts +import { cfasimRwasm } from "@cfasim-ui/rwasm/vite"; + +cfasimRwasm({ + model: "model", + name: "my_model", + packages: ["jsonlite"], + docker: true, +}); +``` + +Docker is the recommended build environment for R package dependencies. The `packages` option is for external R dependencies; the local `cfasim` helper package and local model package are included automatically. The deployed app serves static assets only. + +## R model packages + +R models are ordinary minimal R packages. Add `cfasim` to `DESCRIPTION`: + +```text +Imports: cfasim +``` + +Export callable model functions and import the helper package in `NAMESPACE`: + +```r +export(simulate) +import(cfasim) +``` + +The `cfasim` package provides: + +- `model_outputs(...)` +- `model_output(...)` +- `f64(x)` +- `i32(x)` +- `u32(x)` +- `bool(x)` +- `enum(indices, labels)` + +These helpers create the same structured model-output contract used by the Python `cfasim_model` module and Rust `cfasim-model` crate. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5d797c7..73c0717 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -63,7 +63,7 @@ And finally, create a new project: cfasim init ``` -`cfasim init` prompts for a project name and language (Python or Rust), then generates a Vite + Vue app wired up to run your model in the browser. +`cfasim init` prompts for a project name and language (Python, Rust, or R), then generates a Vite + Vue app wired up to run your model in the browser. ## Packages @@ -74,6 +74,7 @@ cfasim init | `@cfasim-ui/theme` | Design tokens, reset, and utility classes | | `@cfasim-ui/pyodide` | Run Python models in the browser via Pyodide | | `@cfasim-ui/wasm` | Run Rust/WASM models in the browser | +| `@cfasim-ui/rwasm` | Run bundled R models in the browser via webR | ## Choose your guide @@ -81,5 +82,6 @@ Follow the guide for the language your model is written in: - **[Python Projects](./guide/python)** — Vite + Vue + Pyodide. Write your model in Python, build it as a wheel, and run it in the browser. - **[Rust Projects](./guide/rust)** — Vite + Vue + WebAssembly. Write your model in Rust, compile to WASM, and run it in the browser. +- **[R Projects](./guide/r)** — Vite + Vue + webR. Write your model in R, bundle dependencies with rwasm, and run it in the browser. Both guides walk through project setup, model creation, Vite configuration, and wiring up the UI from scratch. diff --git a/docs/guide/r.md b/docs/guide/r.md new file mode 100644 index 0000000..0b2902d --- /dev/null +++ b/docs/guide/r.md @@ -0,0 +1,103 @@ +# R Projects + +This guide walks through setting up a cfasim-ui project that runs an R model in the browser with [webR](https://docs.r-wasm.org/webr/latest/) and bundles R package assets with rwasm. + +## Prerequisites + +- [Node.js](https://nodejs.org/) v24+ +- [pnpm](https://pnpm.io/) v10+ (enabled via `corepack enable`) +- Docker, for building browser-compatible R package assets + +## Model Package + +R models are ordinary minimal R packages. In a scaffolded project the model package lives at the project root; in the examples app it lives under `models/src/r-example/model`. + +```text +model/ +├── DESCRIPTION +├── NAMESPACE +└── R/ + └── model.R +``` + +Add the helper package to `DESCRIPTION`: + +```text +Imports: cfasim +``` + +Export callable model functions in `NAMESPACE`: + +```r +export(simulate) +import(cfasim) +``` + +Then define those functions in `model/R/model.R` or other `.R` files in the +`model/R` directory: + +```r +simulate <- function(steps, rate) { + time <- seq(0, steps - 1) + values <- time * rate + model_outputs( + series = model_output( + time = f64(time), + values = f64(values) + ) + ) +} +``` + +The model package is built and installed into webR, then the frontend calls the exported function by name. Internal helper functions can stay unexported. + +## Vite + +`cfasim-ui/rwasm/vite` provides a Vite plugin that copies the model package, builds the local `cfasim` helper package and model package with rwasm, and writes static assets under `public/rwasm/{name}/`. + +```ts +import { cfasimRwasm } from "cfasim-ui/rwasm/vite"; + +plugins: [ + vue(), + cfasimRwasm({ + model: "..", + name: "my_model", + docker: true, + }), +]; +``` + +`name` is the browser bundle name used by `useModel`. The plugin includes the local `cfasim` helper package and model package automatically. Use `packages` for additional external R dependencies: + +```ts +cfasimRwasm({ + model: "..", + name: "my_model", + packages: ["jsonlite"], +}); +``` + +Docker is required at build time. Runtime deployment is static. + +## UI + +```ts +import { useModel } from "cfasim-ui/rwasm"; + +const { useOutputs } = useModel("my_model"); +const { outputs, loading, error } = useOutputs("simulate", params); +``` + +`useModel("my_model")` must match the Vite plugin `name`. `useOutputs("simulate", params)` watches the reactive params object, calls the exported R function, and returns named `ModelOutput` tables for charts and tables. + +## Run It + +From the project root: + +```bash +pnpm install +pnpm run dev +``` + +The Vite plugin builds the R package assets on startup. `pnpm run build` produces a static site in `dist/`. diff --git a/models/models.spec.ts b/models/models.spec.ts index 09f684f..709c819 100644 --- a/models/models.spec.ts +++ b/models/models.spec.ts @@ -4,10 +4,11 @@ test("home page lists all models", async ({ page }) => { await page.goto("/"); await expect(page.locator("h1")).toContainText("Models"); const cards = page.locator(".model-card"); - await expect(cards).toHaveCount(3); + await expect(cards).toHaveCount(4); await expect(cards.nth(0)).toContainText("Reed-Frost Epidemic"); await expect(cards.nth(1)).toContainText("Python Example"); - await expect(cards.nth(2)).toContainText("Fetch Example"); + await expect(cards.nth(2)).toContainText("R Example"); + await expect(cards.nth(3)).toContainText("Fetch Example"); }); test("reed-frost model renders", async ({ page }) => { @@ -27,6 +28,15 @@ test("fetch example renders", async ({ page }) => { await expect(page.locator("h1")).toContainText("NSSP Emergency Department"); }); +test("r example renders", async ({ page }) => { + await page.goto("/r-example"); + await expect(page.locator("h1")).toContainText("R Example"); + await expect(page.getByLabel("Steps")).toBeVisible(); + await expect(page.locator("li").nth(2)).toContainText("t=2, v=5", { + timeout: 30_000, + }); +}); + test("python-example syncs params to URL and hydrates from URL", async ({ page, }) => { @@ -55,3 +65,32 @@ test("python-example syncs params to URL and hydrates from URL", async ({ await expect.poll(() => new URL(page.url()).search).toBe(""); await expect(stepsInput).toHaveValue("10"); }); + +test("r-example syncs params to URL and hydrates from URL", async ({ + page, +}) => { + await page.goto("/r-example?steps=25&rate=4.5"); + const stepsInput = page.getByLabel("Steps"); + const rateInput = page.getByLabel("Rate"); + await expect(stepsInput).toHaveValue("25"); + await expect(rateInput).toHaveValue("4.5"); + await expect(page.locator("li").nth(2)).toContainText("t=2, v=9", { + timeout: 30_000, + }); + + await stepsInput.fill("40"); + await stepsInput.press("Tab"); + await expect + .poll(() => new URL(page.url()).searchParams.get("steps")) + .toBe("40"); + + await rateInput.fill("2.5"); + await rateInput.press("Tab"); + await expect + .poll(() => new URL(page.url()).searchParams.get("rate")) + .toBeNull(); + + await page.getByRole("button", { name: "Reset" }).click(); + await expect.poll(() => new URL(page.url()).search).toBe(""); + await expect(stepsInput).toHaveValue("10"); +}); diff --git a/models/package.json b/models/package.json index 942b301..208dfdc 100644 --- a/models/package.json +++ b/models/package.json @@ -10,6 +10,7 @@ "@cfasim-ui/charts": "workspace:*", "@cfasim-ui/components": "workspace:*", "@cfasim-ui/pyodide": "workspace:*", + "@cfasim-ui/rwasm": "workspace:*", "@cfasim-ui/shared": "workspace:*", "@cfasim-ui/theme": "workspace:*", "@cfasim-ui/wasm": "workspace:*", diff --git a/models/src/r-example/Page.vue b/models/src/r-example/Page.vue new file mode 100644 index 0000000..15d9106 --- /dev/null +++ b/models/src/r-example/Page.vue @@ -0,0 +1,37 @@ + + + diff --git a/models/src/r-example/model/DESCRIPTION b/models/src/r-example/model/DESCRIPTION new file mode 100644 index 0000000..c15e120 --- /dev/null +++ b/models/src/r-example/model/DESCRIPTION @@ -0,0 +1,7 @@ +Package: r.example +Title: cfasim R Example +Version: 0.0.1 +Description: Minimal cfasim R model example. +License: CC0 +Encoding: UTF-8 +Imports: cfasim diff --git a/models/src/r-example/model/NAMESPACE b/models/src/r-example/model/NAMESPACE new file mode 100644 index 0000000..72c0957 --- /dev/null +++ b/models/src/r-example/model/NAMESPACE @@ -0,0 +1,2 @@ +export(simulate) +import(cfasim) diff --git a/models/src/r-example/model/R/model.R b/models/src/r-example/model/R/model.R new file mode 100644 index 0000000..57f8c8e --- /dev/null +++ b/models/src/r-example/model/R/model.R @@ -0,0 +1,10 @@ +simulate <- function(steps, rate) { + time <- seq(0, steps - 1) + values <- time * rate + model_outputs( + series = model_output( + time = f64(time), + values = f64(values) + ) + ) +} diff --git a/models/src/router.ts b/models/src/router.ts index f0b1587..83f5eed 100644 --- a/models/src/router.ts +++ b/models/src/router.ts @@ -14,6 +14,12 @@ export const models = [ description: "Simple simulation model running via Pyodide", component: () => import("./python-example/Page.vue"), }, + { + path: "/r-example", + name: "R Example", + description: "Simple simulation model running via webR", + component: () => import("./r-example/Page.vue"), + }, { path: "/fetch-example", name: "Fetch Example", diff --git a/models/vite.config.ts b/models/vite.config.ts index 62c7ec8..1871346 100644 --- a/models/vite.config.ts +++ b/models/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import { cfasimWasm } from "@cfasim-ui/wasm/vite"; import { cfasimPyodide } from "@cfasim-ui/pyodide/vite"; +import { cfasimRwasm } from "@cfasim-ui/rwasm/vite"; export default defineConfig({ base: process.env.BASE_URL || "/", @@ -12,5 +13,9 @@ export default defineConfig({ model: "src/python-example/model", pypiDeps: ["cfasim-model"], }), + cfasimRwasm({ + model: "src/r-example/model", + name: "r_example", + }), ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aecb5ff..4c80283 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,10 +22,10 @@ importers: version: 5.9.3 vite: specifier: ^8.0.5 - version: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4) + version: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)) + version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.9.3) @@ -44,6 +44,9 @@ importers: '@cfasim-ui/pyodide': specifier: workspace:* version: link:../pyodide + '@cfasim-ui/rwasm': + specifier: workspace:* + version: link:../rwasm '@cfasim-ui/shared': specifier: workspace:* version: link:../shared @@ -98,7 +101,7 @@ importers: version: 1.0.5 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4))(vue@3.5.30(typescript@5.9.3)) + version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0))(vue@3.5.30(typescript@5.9.3)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -110,10 +113,10 @@ importers: version: 3.0.1 vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.5.0)(rollup@4.59.1)(typescript@5.9.3)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)) + version: 4.5.4(@types/node@25.5.0)(rollup@4.59.1)(typescript@5.9.3)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)) + version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) cfasim-ui/components: dependencies: @@ -126,7 +129,7 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4))(vue@3.5.30(typescript@5.9.3)) + version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0))(vue@3.5.30(typescript@5.9.3)) '@vue/test-utils': specifier: ^2.4.6 version: 2.4.6 @@ -135,10 +138,10 @@ importers: version: 20.8.9 vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.5.0)(rollup@4.59.1)(typescript@5.9.3)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)) + version: 4.5.4(@types/node@25.5.0)(rollup@4.59.1)(typescript@5.9.3)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)) + version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) cfasim-ui/docs: {} @@ -154,6 +157,25 @@ importers: specifier: ^3.5.0 version: 3.5.30(typescript@5.9.3) + cfasim-ui/rwasm: + dependencies: + '@cfasim-ui/shared': + specifier: workspace:* + version: link:../shared + vue: + specifier: ^3.5.0 + version: 3.5.30(typescript@5.9.3) + webr: + specifier: ^0.5.5 + version: 0.5.9 + devDependencies: + happy-dom: + specifier: ^20.8.9 + version: 20.8.9 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) + cfasim-ui/shared: dependencies: vue: @@ -168,7 +190,7 @@ importers: version: 20.8.9 vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)) + version: 4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) cfasim-ui/theme: dependencies: @@ -214,10 +236,10 @@ importers: version: 1.58.2 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4))(vue@3.5.30(typescript@5.9.3)) + version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0))(vue@3.5.30(typescript@5.9.3)) vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@25.5.0)(lightningcss@1.32.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@25.5.0)(lightningcss@1.32.0)(postcss@8.5.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) models: dependencies: @@ -230,6 +252,9 @@ importers: '@cfasim-ui/pyodide': specifier: workspace:* version: link:../cfasim-ui/pyodide + '@cfasim-ui/rwasm': + specifier: workspace:* + version: link:../cfasim-ui/rwasm '@cfasim-ui/shared': specifier: workspace:* version: link:../cfasim-ui/shared @@ -254,7 +279,7 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4))(vue@3.5.30(typescript@5.9.3)) + version: 6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0))(vue@3.5.30(typescript@5.9.3)) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -354,6 +379,27 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@codemirror/autocomplete@6.20.1': + resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/lint@6.9.5': + resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==} + + '@codemirror/search@6.7.0': + resolution: {integrity: sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/view@6.41.1': + resolution: {integrity: sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==} + '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -711,6 +757,18 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@microsoft/api-extractor-model@7.33.5': resolution: {integrity: sha512-Xh4dXuusndVQqVz4nEN9xOp0DyzsKxeD2FFJkSPg4arAjDSKPcy6cAc7CaeBPA7kF2wV1fuDlo2p/bNMpVr8yg==} @@ -724,6 +782,10 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@msgpack/msgpack@2.8.0': + resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.2': resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} peerDependencies: @@ -1290,6 +1352,14 @@ packages: peerDependencies: vue: ^3.5.0 + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1385,6 +1455,15 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + codemirror-lang-r@0.1.1: + resolution: {integrity: sha512-ke9Bm7IPKOoEk0p8LxZJaRlqp8CGOOZns9eKyj/WUaNV58h4uEeWbMpWeJJhVIPvfiuXYkv4FG1hD70gguWJLQ==} + + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1421,6 +1500,12 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1599,6 +1684,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1639,10 +1727,16 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-lazy@4.0.0: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} @@ -1666,6 +1760,9 @@ packages: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1684,6 +1781,9 @@ packages: resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} engines: {node: '>=14'} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -1694,6 +1794,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -1701,6 +1804,12 @@ packages: kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + lezer-r@0.1.3: + resolution: {integrity: sha512-tk+7Q54+ZYHKlLZj69GuZNC8+nMYPIFhGjrSe2fTyQAk9GyUsxgRsmF8V4r7cUiB65+lRu5/SrePeTEKQx5ZAQ==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1778,6 +1887,10 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -1846,6 +1959,10 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1858,6 +1975,12 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1917,6 +2040,12 @@ packages: engines: {node: '>=14'} hasBin: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -1930,6 +2059,46 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + react-accessible-treeview@2.11.2: + resolution: {integrity: sha512-qui0g/gBDpP7VbtqelgJezAzAjKOY3IVi1Rq1NRJ7Z627RXKyKiQ4ooxLK2yauxTvNyU0ke9S0a2d9YUMbJJbA==} + peerDependencies: + classnames: ^2.2.6 + prop-types: ^15.7.2 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-data-grid@7.0.0-beta.59: + resolution: {integrity: sha512-iAp/UYWjfmXYFsyKDtGDMP1IvhwtQSjCP6G/wFEbMNuumWGOEZF8Ut1S2Bp4XxVpOrBkEVKXn+QC3rs14AcB7A==} + peerDependencies: + react: ^19.2 + react-dom: ^19.2 + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-icons@4.12.0: + resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==} + peerDependencies: + react: '*' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-resizable-panels@2.1.9: + resolution: {integrity: sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -1948,6 +2117,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -1966,6 +2138,12 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + search-insights@2.17.3: resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} @@ -1983,6 +2161,9 @@ packages: engines: {node: '>=10'} hasBin: true + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2037,6 +2218,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2056,6 +2240,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -2096,6 +2283,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2129,6 +2321,9 @@ packages: us-atlas@3.0.1: resolution: {integrity: sha512-wEIZCq0ImPvGblTd8gZMqNNCPkQshugMUG/8nkSWXb02+XqNFREg9atHOKP9w6prLZTpqcLhSvdBW81MkV3/0Q==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -2301,6 +2496,13 @@ packages: typescript: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + webr@0.5.9: + resolution: {integrity: sha512-Hzg6AK7+GiegMz+nrHZQLWA3q3F5Ku6WNkuUPqQN2yJXYFtRXdikMebfXUXu5MAixvoeXzWXu+bZQhJgxuz1Jg==} + engines: {node: '>=17.0.0'} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -2347,6 +2549,15 @@ packages: utf-8-validate: optional: true + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + + xterm-readline@1.2.2: + resolution: {integrity: sha512-+jKS8fkP1kF7cNWyznAt2TvLB8/MzPMO4T/ON5FgsRQQfE87YO/Krh0sGnpPxr4B5Isipyt66RDJS+4eEy1RYw==} + peerDependencies: + '@xterm/xterm': ^5.5.0 || ^6.0.0 + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -2480,11 +2691,57 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@codemirror/autocomplete@6.20.1': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/common': 1.5.2 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.5': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + crelt: 1.0.6 + + '@codemirror/search@6.7.0': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.41.1': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@docsearch/css@3.8.2': {} - '@docsearch/js@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3)': + '@docsearch/js@3.8.2(@algolia/client-search@5.49.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': dependencies: - '@docsearch/react': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3) + '@docsearch/react': 3.8.2(@algolia/client-search@5.49.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) preact: 10.29.0 transitivePeerDependencies: - '@algolia/client-search' @@ -2493,13 +2750,15 @@ snapshots: - react-dom - search-insights - '@docsearch/react@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3)': + '@docsearch/react@3.8.2(@algolia/client-search@5.49.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': dependencies: '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.17.3) '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2) '@docsearch/css': 3.8.2 algoliasearch: 5.49.2 optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) search-insights: 2.17.3 transitivePeerDependencies: - '@algolia/client-search' @@ -2712,6 +2971,18 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@marijn/find-cluster-break@1.0.2': {} + '@microsoft/api-extractor-model@7.33.5(@types/node@25.5.0)': dependencies: '@microsoft/tsdoc': 0.16.0 @@ -2748,6 +3019,8 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@msgpack/msgpack@2.8.0': {} + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 @@ -3078,10 +3351,10 @@ snapshots: vite: 5.4.21(@types/node@25.5.0)(lightningcss@1.32.0) vue: 3.5.30(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4))(vue@3.5.30(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.5(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0))(vue@3.5.30(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4) + vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0) vue: 3.5.30(typescript@5.9.3) '@vitest/expect@4.1.0': @@ -3093,13 +3366,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4))': + '@vitest/mocker@4.1.0(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4) + vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0) '@vitest/pretty-format@4.1.0': dependencies: @@ -3284,6 +3557,12 @@ snapshots: dependencies: vue: 3.5.30(typescript@5.9.3) + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + abbrev@2.0.0: {} acorn@8.16.0: {} @@ -3366,6 +3645,23 @@ snapshots: character-entities-legacy@3.0.0: {} + classnames@2.5.1: {} + + codemirror-lang-r@0.1.1: + dependencies: + '@codemirror/language': 6.12.3 + lezer-r: 0.1.3 + + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.5 + '@codemirror/search': 6.7.0 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3395,6 +3691,10 @@ snapshots: dependencies: is-what: 5.5.0 + core-util-is@1.0.3: {} + + crelt@1.0.6: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3538,7 +3838,6 @@ snapshots: '@esbuild/win32-arm64': 0.27.4 '@esbuild/win32-ia32': 0.27.4 '@esbuild/win32-x64': 0.27.4 - optional: true esprima@4.0.1: {} @@ -3587,6 +3886,10 @@ snapshots: function-bind@1.1.2: {} + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3647,8 +3950,12 @@ snapshots: html-void-elements@3.0.0: {} + immediate@3.0.6: {} + import-lazy@4.0.0: {} + inherits@2.0.4: {} + ini@1.3.8: {} internmap@2.0.3: {} @@ -3663,6 +3970,8 @@ snapshots: is-what@5.5.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} jackspeak@3.4.3: @@ -3683,6 +3992,8 @@ snapshots: js-cookie@3.0.5: {} + js-tokens@4.0.0: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -3696,10 +4007,26 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + kind-of@6.0.3: {} kolorist@1.8.0: {} + lezer-r@0.1.3: + dependencies: + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -3757,6 +4084,10 @@ snapshots: lodash@4.18.1: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@10.4.3: {} lru-cache@6.0.0: @@ -3829,6 +4160,8 @@ snapshots: dependencies: abbrev: 2.0.0 + object-assign@4.1.1: {} + obug@2.1.1: {} ohash@2.0.11: {} @@ -3841,6 +4174,10 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + + pako@2.1.0: {} + path-browserify@1.0.1: {} path-key@3.1.1: {} @@ -3892,6 +4229,14 @@ snapshots: prettier@3.8.1: {} + process-nextick-args@2.0.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@7.1.0: {} proto-list@1.2.4: {} @@ -3906,6 +4251,49 @@ snapshots: quansync@0.2.11: {} + react-accessible-treeview@2.11.2(classnames@2.5.1)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-data-grid@7.0.0-beta.59(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-icons@4.12.0(react@18.3.1): + dependencies: + react: 18.3.1 + + react-is@16.13.1: {} + + react-resizable-panels@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -3934,6 +4322,8 @@ snapshots: require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -3994,6 +4384,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.1 fsevents: 2.3.3 + safe-buffer@5.1.2: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + search-insights@2.17.3: {} section-matter@1.0.0: @@ -4007,6 +4403,8 @@ snapshots: semver@7.7.4: {} + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4056,6 +4454,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -4073,6 +4475,8 @@ snapshots: strip-json-comments@3.1.1: {} + style-mod@4.1.3: {} + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -4104,6 +4508,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.9.3: {} ufo@1.6.3: {} @@ -4137,6 +4548,8 @@ snapshots: us-atlas@3.0.1: {} + util-deprecate@1.0.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -4147,7 +4560,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-dts@4.5.4(@types/node@25.5.0)(rollup@4.59.1)(typescript@5.9.3)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)): + vite-plugin-dts@4.5.4(@types/node@25.5.0)(rollup@4.59.1)(typescript@5.9.3)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)): dependencies: '@microsoft/api-extractor': 7.58.1(@types/node@25.5.0) '@rollup/pluginutils': 5.3.0(rollup@4.59.1) @@ -4160,7 +4573,7 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4) + vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - rollup @@ -4176,7 +4589,7 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.32.0 - vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4): + vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4187,11 +4600,12 @@ snapshots: '@types/node': 25.5.0 esbuild: 0.27.4 fsevents: 2.3.3 + tsx: 4.21.0 - vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@25.5.0)(lightningcss@1.32.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@25.5.0)(lightningcss@1.32.0)(postcss@8.5.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 - '@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3) + '@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) '@iconify-json/simple-icons': 1.2.74 '@shikijs/core': 2.5.0 '@shikijs/transformers': 2.5.0 @@ -4237,10 +4651,10 @@ snapshots: - typescript - universal-cookie - vitest@4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)): + vitest@4.1.0(@types/node@25.5.0)(happy-dom@20.8.9)(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)) + '@vitest/mocker': 4.1.0(vite@8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -4257,7 +4671,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4) + vite: 8.0.6(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 @@ -4294,6 +4708,35 @@ snapshots: optionalDependencies: typescript: 5.9.3 + w3c-keyname@2.2.8: {} + + webr@0.5.9: + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/commands': 6.10.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.1 + '@msgpack/msgpack': 2.8.0 + '@xterm/addon-fit': 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': 5.5.0 + classnames: 2.5.1 + codemirror: 6.0.2 + codemirror-lang-r: 0.1.1 + jszip: 3.10.1 + lezer-r: 0.1.3 + lightningcss: 1.32.0 + pako: 2.1.0 + prop-types: 15.8.1 + react: 18.3.1 + react-accessible-treeview: 2.11.2(classnames@2.5.1)(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-data-grid: 7.0.0-beta.59(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) + react-icons: 4.12.0(react@18.3.1) + react-resizable-panels: 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tsx: 4.21.0 + xmlhttprequest-ssl: 2.1.2 + xterm-readline: 1.2.2(@xterm/xterm@5.5.0) + whatwg-mimetype@3.0.0: {} which@2.0.2: @@ -4321,6 +4764,13 @@ snapshots: ws@8.20.0: {} + xmlhttprequest-ssl@2.1.2: {} + + xterm-readline@1.2.2(@xterm/xterm@5.5.0): + dependencies: + '@xterm/xterm': 5.5.0 + string-width: 4.2.3 + yallist@4.0.0: {} zwitch@2.0.4: {} diff --git a/scripts/generate_docs.mjs b/scripts/generate_docs.mjs index 6e81178..1fbd0f2 100644 --- a/scripts/generate_docs.mjs +++ b/scripts/generate_docs.mjs @@ -34,6 +34,7 @@ const WORKSPACE_PACKAGES = [ "shared", "pyodide", "wasm", + "rwasm", "theme", ];