Runnable examples and consumer verification
The seven Node examples and one browser-storage example use installed public packages, not source aliases or test utilities. Copy the repository's examples/ directory outside the checkout and run inside that copy:
npm install
node node-inspect-validate/index.mjs
node revision-checked-edit-save/index.mjs
node publish-restore/index.mjs
node mcp-stdio-client/index.mjs
node reward-panel-states/index.mjs
node reward-panel-layout/index.mjs
node reward-card-generation/index.mjsWithout arguments, each command creates a separate temporary project and prints projectPath in its JSON output. Files remain available for inspection. The first two accept a .fairy path; the second modifies it and expects Main/MainView/title, so use a copy. The third and fifth through seventh commands create only their own examples and accept no user-directory override. Use pack:check below for unpublished branch changes; registry packages do not represent the current checkout.
Three-state reward panel
This is the complete SDK implementation of the first editing task, runnable on stable 0.6.1. After creating a temporary two-package project, it obtains unique IDs for Main/RewardPanel, claimButton, and claimedMark from the outline. One transaction adds a rewardState controller and three gears (text, look, and display) for Locked, Claimable, and Claimed states. Existing layout, other components, and Shared package images are preserved.
To let an agent perform the task, create only the unedited project:
node reward-panel-states/index.mjs --createRunning node reward-panel-states/index.mjs (or npm run reward) creates another independent project and completes the SDK edit, validation, save, and UAM reread. Both commands print the actual projectPath; the no-argument command does not continue editing a project from a previous --create run.
export async function editRewardPanel(projectPath, runtime = createNodeBackendRuntime({
allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
const { sessionId } = data(await runtime.openSession({ projectPath }));
let keepOpen = false;
try {
const outline = data(await runtime.getProjectOutline({ sessionId }));
const packages = outline.packages.filter((pkg) => pkg.name === 'Main');
assert.equal(packages.length, 1, 'Expected one Main package');
const components = packages[0].resources.filter((resource) => resource.kind === 'component' && resource.name === 'RewardPanel');
assert.equal(components.length, 1, 'Expected one Main/RewardPanel component');
const selector = { packageId: packages[0].id, componentResourceId: components[0].id };
const targets = {};
for (const name of ['claimButton', 'claimedMark']) {
const nodes = components[0].component.displayList.filter((node) => node.name === name);
assert.equal(nodes.length, 1, `Expected one ${name} node`);
targets[name] = data(await runtime.queryEntity({ sessionId,
target: { kind: 'displayNode', selector: { ...selector, displayNodeId: nodes[0].id } },
}));
}
assert.equal(targets.claimButton.revision, targets.claimedMark.revision, 'Refresh targets after a concurrent edit');
const controllerName = 'rewardState';
const pages = [
{ id: 'locked', name: 'Locked', remark: '' },
{ id: 'claimable', name: 'Claimable', remark: '' },
{ id: 'claimed', name: 'Claimed', remark: '' },
];
const common = { name: '', controllerName, condition: '', positionsInPercent: false,
tween: false, tweenDuration: 0.3, tweenDelay: 0, easeType: 5, customEasePath: '' };
const transaction = { sessionId, expectedRevision: targets.claimButton.revision, operations: [
{ kind: 'addController', selector: { ...selector, controllerName }, controller: {
name: controllerName, selectedIndex: 0, autoRadioGroupDepth: false, alias: '', exported: true,
homePageType: 'default', homePage: '', pages, actions: [],
} },
{ kind: 'addGear', selector: { ...targets.claimButton.target.selector, kind: 'text', controllerName }, gear: {
...common, kind: 'text', defaultValue: { text: '未达成' },
states: pages.map((page, index) => ({ pageId: page.id, value: { text: ['未达成', '领取奖励', '已领取'][index] } })),
} },
{ kind: 'addGear', selector: { ...targets.claimButton.target.selector, kind: 'look', controllerName }, gear: {
...common, kind: 'look', defaultValue: { alpha: 1, rotation: 0, grayed: true, touchable: false },
states: pages.map((page, index) => ({ pageId: page.id, value: { alpha: 1, rotation: 0, grayed: index !== 1, touchable: index === 1 } })),
} },
{ kind: 'addGear', selector: { ...targets.claimedMark.target.selector, kind: 'display', controllerName },
gear: { kind: 'display', name: '', controllerName, visibleOnPageIds: ['claimed'] } },
] };
const preview = data(await runtime.preflightTransaction(transaction));
const changed = data(await runtime.applyTransaction(transaction));
keepOpen = true;
const validation = data(await runtime.validateSession({ sessionId }));
if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
const project = await readProjectAsUam(new NodeIO(), projectPath);
keepOpen = false;
return { projectPath, selector, revision: saved.revision, dirty: saved.dirty, preview, validation, project };
} catch (cause) {
if (!keepOpen) throw cause;
throw Object.assign(new Error('Reward panel edit needs host recovery; the session remains open.', { cause }), {
recovery: { runtime, sessionId, projectPath },
});
} finally {
if (!keepOpen) data(await runtime.closeSession({ sessionId }));
}
}Import editRewardPanel(projectPath, runtime?) to drive the same workflow from a host. Validation, save, or reread failures after apply keep the session open and throw recovery: { runtime, sessionId, projectPath }, preserving the failure report in cause. Follow the recovery rules in the single-field example below. Repeating the same task is rejected because the controller already exists; the host should query and replan.
Consumer checks execute this editing function against both the SDK and real MCP stdio. Preview leaves files untouched. An independent full-UAM comparison after saving allows only the specified controller and three gears to be added. The complete file list is unchanged; only assets/Main/RewardPanel.xml bytes change, preserving all other files, including PNGs. Reopening can query the controller, and repeating the task is rejected without changing files. These checks do not render FairyGUI or exercise clicks. Follow the three-state table and workflow for visual acceptance in an editor or runtime.
Reward panel layout and entrance animation
Advanced task B starts from A's saved three-state panel, adjusts spacing, dimensions, and positions, and adds a one-shot entrance animation. Stable 0.6.1 supports it. Controllers, gears, text, and image bytes remain unchanged.
To let an agent perform B, create a project with A completed and the original layout intact:
node reward-panel-layout/index.mjs --createFollow the setup guide to authorize the parent directory of the output projectPath and restart the MCP connection. Then give the agent this task:
Resize
Main/RewardPanelat<projectPath>to 420 × 320 and update its five child nodes using the table below. Add anintrotransition at 30 fps: start two 12-frame QuadOut tweens at frame 0, fading the panel itself from alpha 0 to 1 and moving it from offset (0, 24) to (0, 0). Autoplay once on entry without delay. Preserve the three-pagerewardStatecontroller, all gears, text, other components, and resource bytes. Query exact IDs and the current revision, then preview, apply, validate, save, and reopen one batch of seven operations. Stop and report ambiguous targets, a missing A controller, an existingintro, revision conflicts, or incomplete validation, preserving applied but unsaved work.
| Node | Original → new position | Original → new size |
|---|---|---|
background | (0, 0) → (0, 0) | 360 × 280 → 420 × 320 |
title | (24, 24) → (32, 28) | 312 × 32 → 356 × 36 |
rewardIcon | (152, 80) → (178, 104) | 56 × 56 → 64 × 64 |
claimButton | (80, 160) → (110, 204) | 200 × 48, unchanged |
claimedMark | (24, 228) → (32, 272) | 312 × 28 → 356 × 28 |
The seven operations are one setComponentProps, five setDisplayNodeProps, and one addTransition. UAM transition times and durations use frames: 12 / 30 = 0.4 seconds. Both items have an empty targetNodeId, targeting the panel itself; movement is relative to its host position. The animation does not deliver rewards or change controller pages.
export async function redesignRewardPanel(projectPath, runtime = createNodeBackendRuntime({
allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
const { sessionId } = data(await runtime.openSession({ projectPath }));
let keepOpen = false;
try {
const outline = data(await runtime.getProjectOutline({ sessionId }));
const packages = outline.packages.filter((pkg) => pkg.name === 'Main');
assert.equal(packages.length, 1, 'Expected one Main package');
const components = packages[0].resources.filter((resource) => resource.kind === 'component' && resource.name === 'RewardPanel');
assert.equal(components.length, 1, 'Expected one Main/RewardPanel component');
const selector = { packageId: packages[0].id, componentResourceId: components[0].id };
const current = data(await runtime.queryEntity({ sessionId, target: { kind: 'component', selector } }));
const controller = data(await runtime.queryEntity({ sessionId, target: { kind: 'controller', selector: { ...selector, controllerName: 'rewardState' } } }));
assert.equal(controller.revision, current.revision, 'Refresh after a concurrent edit');
assert.deepEqual(controller.entity.properties.pages.map((page) => page.name), ['Locked', 'Claimable', 'Claimed'], 'Complete task A before this example');
assert(!components[0].component.transitions.some((transition) => transition.name === 'intro'), 'An intro transition already exists; query and replan');
const layout = {
background: { size: { width: 420, height: 320 } },
title: { position: { x: 32, y: 28 }, size: { width: 356, height: 36 } },
rewardIcon: { position: { x: 178, y: 104 }, size: { width: 64, height: 64 } },
claimButton: { position: { x: 110, y: 204 } },
claimedMark: { position: { x: 32, y: 272 }, size: { width: 356, height: 28 } },
};
const operations = [{ kind: 'setComponentProps', selector, props: { size: { width: 420, height: 320 } } }];
for (const [name, props] of Object.entries(layout)) {
const nodes = components[0].component.displayList.filter((node) => node.name === name);
assert.equal(nodes.length, 1, `Expected one ${name} node`);
const target = data(await runtime.queryEntity({ sessionId, target: { kind: 'displayNode', selector: { ...selector, displayNodeId: nodes[0].id } } }));
assert.equal(target.revision, current.revision, 'Refresh targets after a concurrent edit');
operations.push({ kind: 'setDisplayNodeProps', selector: target.target.selector, props });
}
// UAM timing is in frames: 12 frames / 30 fps = 0.4 seconds. Empty target means this component.
const item = { name: '', time: 0, targetNodeId: '', tween: true, duration: 12,
easeType: EaseType.QuadOut, repeat: 0, yoyo: false, endLabel: '', path: '', customEasePath: '' };
operations.push({ kind: 'addTransition', selector: { ...selector, transitionName: 'intro' }, transition: {
name: 'intro', autoPlay: true, autoPlayTimes: 1, autoPlayDelay: 0, options: 0, fps: 30,
items: [
{ ...item, actionType: TransitionActionType.Alpha, startValue: [0], endValue: [1], label: 'fade-in' },
{ ...item, actionType: TransitionActionType.XY, startValue: [0, 24], endValue: [0, 0], label: 'slide-up' },
],
} });
const transaction = { sessionId, expectedRevision: current.revision, operations };
const preview = data(await runtime.preflightTransaction(transaction));
const changed = data(await runtime.applyTransaction(transaction));
keepOpen = true;
const validation = data(await runtime.validateSession({ sessionId }));
if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
const project = await readProjectAsUam(new NodeIO(), projectPath);
keepOpen = false;
return { projectPath, selector, revision: saved.revision, dirty: saved.dirty, preview, validation, project };
} catch (cause) {
if (!keepOpen) throw cause;
throw Object.assign(new Error('Reward panel redesign needs host recovery; the session remains open.', { cause }), {
recovery: { runtime, sessionId, projectPath },
});
} finally {
if (!keepOpen) data(await runtime.closeSession({ sessionId }));
}
}Import redesignRewardPanel(projectPath, runtime?) from a host; failures after apply use the same recovery handle and rules as A. Running node reward-panel-layout/index.mjs (or npm run reward-layout) creates another independent project, completes B, and publishes the before/after .fui files and atlases separately. JSON fields before.files / after.files list actual paths. Both output directories are outside the project directory. This does not overwrite user projects or continue a previous --create result.
Render and animation acceptance
In an existing FairyGUI/LayaAir host, load the packages from each output directory separately and create Main/RewardPanel. Set its host position before adding it to the stage. The redesigned panel autoplays intro once; replay with panel.getTransition('intro').play(). An editor can open projectPath directly to inspect the project and timeline.
These actual published-artifact screenshots use the same 520 × 420 viewport and Claimable page, with OpenFairyGUI 0.4.0, LayaAir 3.3.10 / FairyGUI, and Chromium 151.0.7922.34:
| Before | After | Animation midpoint (0.2 seconds) |
|---|---|---|
![]() | ![]() | ![]() |
| Animation time | Panel alpha | Offset from the host position |
|---|---|---|
| 0 seconds | 0 | (0, 24) |
| 0.2 seconds | 0.75 | (0, 6) |
| 0.4 seconds | 1 | (0, 0) |
This native-runtime acceptance run checked autoplay completion, these time samples, all three A pages, and real mouse clicks responding only on Claimable, with no console errors. Repeat these visual checks when changing the project, styles, or runtime.
B's pack:check coverage includes SDK and real MCP queries, previews, save/reread, full UAM/file comparisons, rejection of a repeated task, and published binary dimensions and 0.4-second animation timing. B changes only assets/Main/RewardPanel.xml, preserving other file bytes. The FairyGUI screenshot verification above ran separately. The consumer gate's Chromium tests still exercise the OPFS storage page below, not this FairyGUI rendering scenario.
Generate reward cards from a template
Task C generates three exported components from an existing Main/RewardCardTemplate, using stable 0.6.1. Each new component contains one Label instance referencing the template, with formal instance properties for title and icon. The template's children remain in the original component. Icons reference the Shared package's existing 2 × 2 red and blue PNG test swatches.
Create an independent project containing the template and images, without generated cards (A/B are not prerequisites):
node reward-card-generation/index.mjs --createAuthorize the actual projectPath using the setup guide, then give the agent this task and configuration table:
Add the three exported components below to the Main package in
<projectPath>. Query unique IDs forRewardCardTemplateand the Shared images. Check that the template is a Label containing atitletext child and aniconLoader, and that the images are already exported. Each new component must match the template size (240 × 180 in this example) and contain only one component instance namedcard, at (0, 0) with the same size, referencing the original template and setting Label title/icon properties. Build icon URLs from actual package and resource IDs. Preserve existing resources without copying template children or images. Require all queries to share one revision. Preview and apply threeaddComponentoperations in one batch, then require complete validation, save, and reopen to verify. Stop and report ambiguous targets, occupied IDs/names, revision conflicts, or incomplete validation; retain applied but unsaved work.
| New component | Stable resource ID | Title | Existing image |
|---|---|---|---|
DailyRewardCard | cardday1 | 每日奖励 ×100 | Shared/red |
WeeklyRewardCard | cardweek | 连签奖励 ×500 | Shared/blue |
BonusRewardCard | cardbon1 | 额外奖励 ×20 | Shared/red |
const cards = [
{ id: 'cardday1', name: 'DailyRewardCard', title: '每日奖励 ×100', image: 'red' },
{ id: 'cardweek', name: 'WeeklyRewardCard', title: '连签奖励 ×500', image: 'blue' },
{ id: 'cardbon1', name: 'BonusRewardCard', title: '额外奖励 ×20', image: 'red' },
];
export async function generateRewardCards(projectPath, runtime = createNodeBackendRuntime({
allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
const { sessionId } = data(await runtime.openSession({ projectPath }));
let keepOpen = false;
try {
const outline = data(await runtime.getProjectOutline({ sessionId }));
const packages = outline.packages.filter((pkg) => pkg.name === 'Main');
const sharedPackages = outline.packages.filter((pkg) => pkg.name === 'Shared');
assert.equal(packages.length, 1, 'Expected one Main package');
assert.equal(sharedPackages.length, 1, 'Expected one Shared package');
const main = packages[0]; const shared = sharedPackages[0];
const templates = main.resources.filter((resource) => resource.kind === 'component' && resource.name === 'RewardCardTemplate');
assert.equal(templates.length, 1, 'Expected one RewardCardTemplate');
const selector = { packageId: main.id, componentResourceId: templates[0].id };
const template = data(await runtime.queryEntity({ sessionId, target: { kind: 'component', selector } }));
assert.equal(template.revision, outline.revision, 'Refresh after a concurrent edit');
assert.equal(template.entity.properties.properties.extensionType, 'Label', 'Expected a Label template');
for (const [name, kind] of [['title', 'text'], ['icon', 'loader']]) {
const nodes = templates[0].component.displayList.filter((node) => node.name === name);
assert.equal(nodes.length, 1, `Expected one child named ${name}`);
assert.equal(nodes[0].kind, kind, `Expected ${name} to be a ${kind}`);
}
// Build only new wrappers through public Core defaults; do not copy or reconstruct the template.
const document = new Document();
const generated = document.createPackage(main.name).setId(main.id);
const { width, height } = template.entity.properties.size;
for (const card of cards) {
assert(!main.resources.some((resource) => resource.id === card.id || resource.name === card.name), `Generated card already exists: ${card.name}; query and replan`);
const images = shared.resources.filter((resource) => resource.kind === 'image' && resource.name === card.image);
assert.equal(images.length, 1, `Expected one Shared/${card.image} image`);
const icon = data(await runtime.queryEntity({ sessionId, target: { kind: 'resource', selector: { packageId: shared.id, resourceId: images[0].id } } }));
assert.equal(icon.revision, outline.revision, 'Refresh resource references after a concurrent edit');
assert(icon.entity.properties.exported, 'The icon must already be exported for its ui:// reference');
const wrapper = document.createComponent(card.name).setId(card.id).setPath('/').setSize(width, height).setExported(true);
wrapper.addChild(document.createGComponent('card').setId('card').setSrc(templates[0].id).setPackageId(main.id).setSize(width, height)
.setInstanceExtType('Label').setInstanceTitle(card.title).setInstanceIcon(`ui://${shared.id}${images[0].id}`));
generated.addResource(wrapper);
}
const resources = liftDocumentToUamProject(document).packages[0].resources;
const transaction = { sessionId, expectedRevision: outline.revision, operations: resources.map((component, index) => ({
kind: 'addComponent', selector: { packageId: main.id }, component, atIndex: main.resources.length + index,
})) };
const preview = data(await runtime.preflightTransaction(transaction));
const changed = data(await runtime.applyTransaction(transaction));
keepOpen = true;
const validation = data(await runtime.validateSession({ sessionId }));
if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
const project = await readProjectAsUam(new NodeIO(), projectPath);
keepOpen = false;
return { projectPath, template: selector, generated: resources.map(({ id, name }) => ({ id, name })), revision: saved.revision, dirty: saved.dirty, preview, validation, project };
} catch (cause) {
if (!keepOpen) throw cause;
throw Object.assign(new Error('Reward card generation needs host recovery; the session remains open.', { cause }), {
recovery: { runtime, sessionId, projectPath },
});
} finally {
if (!keepOpen) data(await runtime.closeSession({ sessionId }));
}
}Hosts can import generateRewardCards(projectPath, runtime?); recovery after apply follows A. Running node reward-card-generation/index.mjs (or npm run reward-cards) without arguments creates another project, generates, validates, saves, rereads, and publishes it. JSON generated lists new component IDs/names; published.files lists .fui and atlas paths. Publishing is outside the project directory. This command does not continue a previous --create result.
Generated structure and rendering acceptance
Consumer checks run generation through the SDK and real MCP stdio: preview writes nothing; only three component XML files are added and assets/Main/package.xml is updated. After independently rereading and removing the three new components, the complete UAM must equal the original. Every existing file except Main's resource manifest remains byte-identical, including the template XML, other components, and PNGs. Separate checks cover generated structure, titles, icons, template references, queries after reopening, duplicate rejection, and references/instance properties in published binaries.
Load the published Shared/Main packages in a real FairyGUI/LayaAir host and create the template and three new components. This screenshot uses OpenFairyGUI 0.4.0, LayaAir 3.3.10 / FairyGUI, and Chromium 151.0.7922.34. Reading left to right, top to bottom: template, daily reward, weekly reward, bonus reward.

This runtime acceptance checked all four objects' text, dimensions, template URLs, and red/blue RGBA pixels at icon centers. Changing the daily card's instance text left the other cards and template unchanged; the console had no errors. Instances retain the template reference, so later template style changes affect them all. Wrapper dimensions are captured at generation time and do not automatically follow later template resizing. The example contains no reward fulfillment logic.
Screenshot acceptance runs separately from pack:check; it is not an automated browser rendering gate. Repeat it after changing the template, assets, or runtime. UAM, XML, binary, or dirty: false checks cannot replace actual rendering.
Read and validate
The example returns the existing InspectReport and project validation report. Exit codes are 0 for valid, 1 for invalid, and 3 for incomplete. This code is included directly from the source executed by the consumer check:
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { NodeIO } from '@openfairygui/core/node';
import { inspect } from '@openfairygui/functions';
import { validateProjectNode } from '@openfairygui/functions/node';
import { createDemoProject } from '../create-demo-project.mjs';
// #region example
export async function inspectAndValidate(projectPath) {
const document = await new NodeIO().readProject(projectPath);
return {
inspection: inspect(document),
validation: await validateProjectNode(projectPath),
};
}
// #endregion example
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const projectPath = process.argv[2] ?? await createDemoProject();
const result = await inspectAndValidate(projectPath);
console.log(JSON.stringify({ projectPath, ...result }, null, 2));
process.exitCode = result.validation.status === 'valid' ? 0 : result.validation.status === 'invalid' ? 1 : 3;
}The corresponding CLI commands are ofgui inspect <project-path> --json and ofgui validate <project-path> --json. Original reports are in the shared envelope's result; JSON read failures use the same envelope, without human logs on stdout. See CLI machine output for shapes, exits and offline schemas. Human mode retains its terminal report.
Revision-checked edit, save and reread
The example obtains IDs from the outline, then reads current properties and the revision with queryEntity. It previews and applies the same text edit, requires validation to be valid and complete: true, saves using the transaction's returned revision, rereads through public Node I/O, and releases the session lock. Preview reserves no revision. Errors or incomplete validation stop execution; stale writes are not blindly retried.
Validation, save or reread failures after apply keep the session open and throw an error with recovery: { runtime, sessionId, projectPath }; the cause chain preserves the original error and Backend/validation report. A host importing editAndSave must catch that error, resolve the fault, validate and explicitly save the same session, then close it. The optional third argument accepts a host-owned runtime. Failures before apply close the clean session. Recovery handles live only in the current process; the standalone command does not persist in-memory edits after exiting on failure.
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { readProjectAsUam } from '@openfairygui/core';
import { NodeIO } from '@openfairygui/core/node';
import { createNodeBackendRuntime } from '@openfairygui/backend/node';
import { createDemoProject } from '../create-demo-project.mjs';
function data(result) {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`, { cause: result });
return result.data;
}
// #region example
export async function editAndSave(projectPath, text = 'Saved by a consumer', runtime = createNodeBackendRuntime({
allowedProjectRoots: [path.dirname(path.resolve(projectPath))],
})) {
const opened = data(await runtime.openSession({ projectPath }));
const sessionId = opened.sessionId;
let keepOpen = false;
try {
const outline = data(runtime.getProjectOutline({ sessionId }));
const pkg = outline.packages.find((entry) => entry.name === 'Main');
const component = pkg?.resources.find((entry) => entry.name === 'MainView' && entry.kind === 'component');
const title = component?.component?.displayList.find((entry) => entry.name === 'title' && entry.kind === 'text');
if (!title) throw new Error('This example expects Main/MainView with a text node named title.');
const selector = { packageId: pkg.id, componentResourceId: component.id, displayNodeId: title.id };
const current = data(runtime.queryEntity({ sessionId, target: { kind: 'displayNode', selector } }));
const transaction = {
sessionId, expectedRevision: current.revision,
operations: [{ kind: 'setDisplayNodeProps', selector, props: { text } }],
};
// Preview executes on an isolated snapshot; apply still rechecks this revision.
data(await runtime.preflightTransaction(transaction));
const changed = data(await runtime.applyTransaction(transaction));
keepOpen = true;
const validation = data(runtime.validateSession({ sessionId }));
if (validation.status !== 'valid' || !validation.complete) throw new Error(`Project validation is ${validation.status} (complete: ${validation.complete}).`, { cause: validation });
const saved = data(await runtime.saveSession({ sessionId, expectedRevision: changed.revision }));
const project = await readProjectAsUam(new NodeIO(), projectPath);
keepOpen = false;
return { selector, revision: saved.revision, dirty: saved.dirty, project };
} catch (cause) {
if (!keepOpen) throw cause;
// The host must handle this live session before closing it; do not retry or discard edits here.
throw Object.assign(new Error('Edit/save failed; the session remains open for recovery.', { cause }), {
recovery: { runtime, sessionId, projectPath },
});
} finally {
if (!keepOpen) data(await runtime.closeSession({ sessionId }));
}
}
// #endregion example
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const projectPath = process.argv[2] ?? await createDemoProject();
console.log(JSON.stringify({ projectPath, ...await editAndSave(projectPath, process.argv[3]) }, null, 2));
}Publish, consume artifacts and perform limited recovery
The third example creates two packages containing text, components, two images and cross-package references. It publishes through publishNode, reads binaries from the actual returned manifest, restores those self-produced trusted artifacts into a separate directory, rereads and requires complete validation. It compares package/resource IDs, component geometry/text and references, not original project identity, editor-local state or XML spelling.
import assert from 'node:assert/strict';
import { stat } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { liftDocumentToUamProject, writeProjectFromUam } from '@openfairygui/core';
import { NodeIO } from '@openfairygui/core/node';
import { publishNode, restoreNode, validateProjectNode } from '@openfairygui/functions/node';
import { createDemoProject } from '../create-demo-project.mjs';
// Two opaque 2x2 PNGs make atlas references and recovered pixels independently checkable.
export const IMAGE_BYTES = {
red: 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=',
blue: 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEElEQVR4nGNgYPj/H4KhDAA/0gf5tBJPzQAAAABJRU5ErkJggg==',
};
export async function createPublishProject(parent) {
const projectPath = await createDemoProject(parent);
const document = await new NodeIO().readProject(projectPath);
document.getRoot().setProjectType(4);
const main = document.getRoot().listPackages()[0].listComponents()[0];
const shared = document.createPackage('Shared').setId('pkgshare');
const badge = document.createComponent('Badge').setId('badge').setPath('/').setSize(24, 24).setExported(true);
shared.addResource(badge);
for (const color of Object.keys(IMAGE_BYTES)) {
shared.addResource(document.createImageResource(color).setId(color).setPath('/').setFileName(`${color}.png`).setWidth(2).setHeight(2).setExported(true));
badge.addChild(document.createGImage(color).setId(color).setSrc(color).setXY(color === 'red' ? 0 : 4, 0).setSize(2, 2));
}
main.addChild(document.createGImage('icon').setId('icon').setSrc('red').setPackageId('pkgshare').setXY(8, 8).setSize(2, 2));
main.addChild(document.createGComponent('badge').setId('badge-instance').setSrc('badge').setPackageId('pkgshare').setXY(48, 72).setSize(24, 24));
const project = liftDocumentToUamProject(document);
for (const resource of project.packages.find((pkg) => pkg.id === 'pkgshare').resources) {
if (resource.kind === 'image') resource.sourceBytes = Uint8Array.from(Buffer.from(IMAGE_BYTES[resource.id], 'base64'));
}
await writeProjectFromUam(new NodeIO(), project, projectPath);
return projectPath;
}
// Deliberately compares supported runtime semantics, not editor-local state or original XML spelling.
export function supportedSemantics(document) {
return document.getRoot().listPackages().map((pkg) => ({
id: pkg.getId(), resources: pkg.listResources().map((resource) => ({
id: resource.getId(), kind: resource.propertyType,
size: [resource.getWidth(), resource.getHeight()],
...(resource.propertyType === 'Component' ? { children: resource.listChildren().map((node) => ({
id: node.getId(), kind: node.propertyType, name: node.getName(),
position: [node.getX(), node.getY()], size: [node.getWidth(), node.getHeight()],
...(node.getText ? { text: node.getText() } : {}),
...(node.getSrc?.() ? { reference: { packageId: node.getPackageId() || pkg.getId(), resourceId: node.getSrc() } } : {}),
})) } : {}),
})).sort((a, b) => a.id.localeCompare(b.id)),
})).sort((a, b) => a.id.localeCompare(b.id));
}
export function mergePublishedPackages(packages) {
// NodeIO reads one binary at a time and includes empty dependency placeholders.
const byId = new Map();
for (const pkg of packages) {
const previous = byId.get(pkg.id);
assert(!previous?.resources.length || !pkg.resources.length, `Duplicate populated package: ${pkg.id}`);
if (!previous || pkg.resources.length) byId.set(pkg.id, pkg);
}
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
}
// #region example
export async function publishAndRestore(projectPath) {
const io = new NodeIO();
const document = await io.readProject(projectPath);
const expected = supportedSemantics(document);
document.setLogger({ debug() {}, info() {}, warn: console.error, error: console.error });
const output = path.join(path.dirname(projectPath), 'release');
const published = await publishNode({ document, output, plugins: [], codeGeneration: false });
const packages = [];
for (const file of published.files) {
assert.equal((await stat(file.path)).size, file.size);
if (file.path.endsWith('.fui')) packages.push(...supportedSemantics(await io.readBinary(file.path)));
}
assert.deepEqual(mergePublishedPackages(packages), expected);
// Trusted artifacts produced above; never point this at unknown third-party downloads.
const restored = await restoreNode({ inputDir: output, output: path.join(path.dirname(projectPath), 'restored'), projectType: 4 });
assert.deepEqual(supportedSemantics(await io.readProject(restored.projectPath)), expected);
const validation = await validateProjectNode(restored.projectPath);
assert.equal(validation.status, 'valid');
assert.equal(validation.complete, true);
return { projectPath, published, restored: { projectPath: restored.projectPath, warnings: restored.warnings }, validation };
}
// #endregion example
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
console.log(JSON.stringify(await publishAndRestore(await createPublishProject()), null, 2));
}ofgui publish <project> -o <release-directory> --project-type layabox --json returns {schemaVersion:1,command:"publish",success:true,result:{files:[{path,size}]}}. The Node workflow records actual writes with final absolute paths and byte sizes, excluding untouched pre-existing files and arbitrary private plugin I/O. Explicit runtime output is staged atomically; separate codegen destinations and plugin side effects are outside that directory transaction.
ofgui restore <trusted-release-directory> -o <separate-project-directory> --json returns {schemaVersion:1,command:"restore",success:true,result:{projectPath,packages:[{id,name}],warnings}}. Both commands exit 0 on workflow success, 1 on workflow failure and 2 on syntax errors. Failure JSON contains success:false and error:{code,message}, with publish_failed, restore_failed or invalid_arguments. Human logs go to stderr; stdout contains one JSON result. Help remains text. Restoration success/warnings do not replace reread validation.
Restoration accepts trusted local artifacts only, requires a separate directory and refuses overwrite by default. Even --force replaces the old target only after staging succeeds. See recovery limits; the installed canonical boundary is available offline through ofgui docs cat restore-limits --json. Source information absent from published artifacts is not recoverable.
MCP stdio client
The fourth command uses the official MCP SDK and installed @openfairygui/mcp/stdio export, without global executables, shell interpolation or an assumed HTTP port. It discovers tools and version-bound documentation, explicitly restricts OPENFAIRYGUI_ALLOWED_PROJECT_ROOTS, obtains exact Main/MainView/title IDs from the outline, reads the current revision and previews a text edit. It does not apply/save, asserts unchanged query results and clean session state, and closes both session and stdio transport in finally.
It accepts an optional .fairy file with that structure, or creates a separate demo without arguments. Opening a file session still briefly holds a lock and never bypasses another owner. pack:check executes this file directly, verifies all project files stay unchanged, and proves lock release by opening a subsequent session. The SDK is an explicitly declared consumer dependency, not a new product abstraction.
export async function inspectThroughMcp(projectPath) {
const root = path.dirname(await realpath(projectPath));
// The installed public stdio export avoids global executables, shell quoting and assumed HTTP ports.
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--input-type=module', '--eval', 'const m = await import(process.argv[1]); await m.connectOpenFairyGuiMcpStdio();', import.meta.resolve('@openfairygui/mcp/stdio')],
env: { OPENFAIRYGUI_ALLOWED_PROJECT_ROOTS: root }, stderr: 'inherit',
});
const client = new Client({ name: 'openfairygui-example', version: '1.0.0' });
let sessionId;
async function call(method, input = {}) {
const result = await client.callTool({ name: `openfairygui_backend_${method}`, arguments: input });
const backend = result.structuredContent?.backendResult;
if (result.isError || !backend?.ok) throw new Error(JSON.stringify(backend?.error ?? result));
return backend.data;
}
try {
await client.connect(transport);
const { tools } = await client.listTools(); // The SDK also uses advertised output schemas to validate calls.
const docs = await client.readResource({ uri: 'openfairygui://docs/index' });
const documentation = JSON.parse(docs.contents[0].text);
const capabilities = await call('get_capabilities');
assert.equal(documentation.BACKEND_CAPABILITY_SCHEMA_VERSION, capabilities.capabilitySchemaVersion);
assert.equal(documentation.BACKEND_CONTRACT_VERSION, capabilities.contractVersion);
const opened = await call('open_session', { projectPath }); sessionId = opened.sessionId;
const outline = await call('get_project_outline', { sessionId });
const pkg = outline.packages.find((entry) => entry.name === 'Main');
const component = pkg?.resources.find((entry) => entry.name === 'MainView' && entry.kind === 'component');
const title = component?.component?.displayList.find((entry) => entry.name === 'title' && entry.kind === 'text');
assert(title, 'This example expects Main/MainView/title; it will not guess another target.');
const target = { kind: 'displayNode', selector: { packageId: pkg.id, componentResourceId: component.id, displayNodeId: title.id } };
const current = await call('query_entity', { sessionId, target });
const preview = await call('preflight_transaction', {
sessionId, expectedRevision: current.revision,
operations: [{ kind: 'setDisplayNodeProps', selector: target.selector, props: { text: `${current.entity.properties.text} (preview only)` } }],
});
assert.deepEqual(await call('query_entity', { sessionId, target }), current);
const session = await call('get_session', { sessionId });
assert.equal(session.revision, current.revision); assert.equal(session.dirty, false);
// No apply/save: a successful preview is not authorization, a reserved revision or a persisted edit.
return { projectPath, toolNames: tools.map((tool) => tool.name), documentation, current, preview, session };
} finally {
try { if (sessionId) await call('close_session', { sessionId }); }
finally { await client.close(); }
}
}Real browser storage
Run npm run browser in the copied examples/ directory and open the displayed localhost URL in Chromium. The example seeds only a missing openfairygui-example/ in this origin's OPFS, never requesting local-folder permission or overwriting an existing example. Clearing site data removes it. Use Open → Preview & apply → Save → refresh → Open to see persisted title, revision and dirty state. Close refuses to discard dirty edits, but refresh can still lose in-memory changes. Validate saved files explicitly hydrates source bytes and calls validateProjectWeb; unloaded images cannot count as complete validation.
export async function createBrowserExample() {
if (!navigator.storage?.getDirectory || !navigator.locks) throw new Error('This example requires OPFS and Web Locks on localhost or HTTPS.');
const root = await navigator.storage.getDirectory();
const fileSystem = createBackendStorageFileSystem(createFileSystemAccessFileSystem(root));
const io = new WebIO(fileSystem);
const projectPath = 'openfairygui-example/Example.fairy';
// Only seed our own missing demo. Serialize first-run initialization across tabs.
await navigator.locks.request('openfairygui-example:initialize', async () => {
if (await fileSystem.exists(projectPath)) return;
const document = new Document();
document.getRoot().setProjectId('browser-example').setProjectType(0).setVersion('3.0')
.setSettings({ publish: {}, common: {}, adaptation: {} });
const pkg = document.createPackage('Main').setId('pkgdemo1');
const component = document.createComponent('MainView').setId('cmpdemo1').setPath('/').setExported(true).setSize(320, 180);
component.addChild(document.createGTextField('title').setId('title').setText('Hello browser').setXY(16, 18).setSize(240, 32));
pkg.addResource(component);
pkg.addResource(document.createImageResource('pixel').setId('pixel').setPath('/').setFileName('pixel.png').setWidth(2).setHeight(1));
const project = liftDocumentToUamProject(document);
const canvas = new OffscreenCanvas(2, 1);
const context = canvas.getContext('2d');
context.fillStyle = '#ff0000'; context.fillRect(0, 0, 1, 1);
context.fillStyle = '#0000ff'; context.fillRect(1, 0, 1, 1);
project.packages[0].resources.find((resource) => resource.kind === 'image').sourceBytes = new Uint8Array(await (await canvas.convertToBlob({ type: 'image/png' })).arrayBuffer());
await writeProjectFromUam(io, project, projectPath);
});
const runtime = new BackendRuntime({ fileSystem, allowedProjectRoots: ['openfairygui-example'] });
let sessionId;
function read() {
const outline = data(runtime.getProjectOutline({ sessionId }));
const pkg = outline.packages.find((entry) => entry.name === 'Main');
const component = pkg?.resources.find((entry) => entry.kind === 'component' && entry.name === 'MainView');
const title = component?.component?.displayList.find((entry) => entry.kind === 'text' && entry.name === 'title');
if (!title) throw new Error('Expected Main/MainView/title in this demo; no guessed identifiers.');
const selector = { packageId: pkg.id, componentResourceId: component.id, displayNodeId: title.id };
return { selector, ...data(runtime.queryEntity({ sessionId, target: { kind: 'displayNode', selector } })) };
}
return {
runtime, fileSystem, projectPath,
get sessionId() { return sessionId; },
async open() {
if (sessionId) return data(runtime.getSession({ sessionId }));
const opened = data(await runtime.openSession({ projectPath }));
sessionId = opened.sessionId;
return opened;
},
async close() {
if (!sessionId) return;
const session = data(runtime.getSession({ sessionId }));
if (session.dirty) throw new Error('Save this demo before closing; unsaved edits will not be discarded.');
data(await runtime.closeSession({ sessionId })); sessionId = undefined;
},
read,
async edit(text) {
const current = read();
const transaction = { sessionId, expectedRevision: current.revision, operations: [{ kind: 'setDisplayNodeProps', selector: current.selector, props: { text } }] };
data(await runtime.preflightTransaction(transaction));
return data(await runtime.applyTransaction(transaction));
},
async save() {
return data(await runtime.saveSession({ sessionId, expectedRevision: read().revision }));
},
async validate() { return validateProjectWeb(await readProjectAsUam(io, projectPath, { hydrateResourceBytes: true })); },
};
}The example reuses Core's File System Access adapter, WebIO, Backend's storage bridge and native Web Locks. pack:check executes this page in real Chromium: preview/failure leaves files untouched, stale revisions fail, path denial preserves dirty state, save changes only target XML, PNG bytes and red/blue RGBA stay intact, reload reads persisted edits, and two tabs prove lock contention plus release on normal close and abrupt termination. Successful evidence includes browser-evidence.json and browser-consumer.png; failures preserve the consumer directory.
OPFS is origin-private storage, not a user directory selected by showDirectoryPicker. This does not validate local-folder permission, IndexedDB/ZIP adapters, a cross-browser matrix, image-replacement Workers or FairyGUI rendering. See MDN OPFS.
Verify the checkout or release artifacts
From the repository root:
pnpm pack:check
pnpm pack:check --artifacts .releaseThe first command builds and packs the five current publishable packages. The second reads the five tarballs matching current package names and versions from the supplied directory without repacking. Release runs this second form before either registry publish, checking the exact files to be published.
Checks run in a fresh directory outside the checkout:
- Install production dependencies from the five local tarballs, overriding internal package resolutions to those same files; disallow workspace links and clear ambient Node loader/source-resolution settings.
- Inspect actual
exports, packed files, ESM imports, CJS requires, and Node/Web entrypoints. The Worker is a separate browser entry, not imported in the Node main thread. - Verify installed CLI/bin mappings, versions, inspect/validate JSON, and MCP stdio initialization and tool discovery.
- Execute all seven Node examples, including the real stdio client, and assert read-only inspection/preview, the requested semantic edits and corresponding XML changes only, no unrelated new files, released session locks, and rejected stale revisions. Tasks A/B/C additionally run the SDK/MCP full-model, file, published-animation, and generated-reference comparisons above. Failed validation or staged writes in the single-field editing example must preserve the revision, dirty state, diagnostics, lock and original files; explicit recovery saves and rereads the same session after the fault is resolved.
- Additionally check the actual manifest/file sizes, binary components and cross-package references, red/blue atlas RGBA pixels, recovered assets and project validation. Failed forced recovery with a corrupt atlas must preserve the entire previous target. Real artifact tasks use a separate restricted host; see agent evaluations.
- Only after production execution passes, install pinned TypeScript, Node types, esbuild and Playwright. Compile strict
.mts/.ctsconsumers withoutskipLibCheckor source aliases, bundle browser/Worker exports without Node externals, then execute the real Chromium page checks above.
Successful checks remove only their own temporary directory. Failures preserve it and print the path; pnpm pack:check --keep preserves successful runs too. Registry and matching Chromium downloads require network access or caches; download/launch failures never count as passing. Playwright pins its browser version; see browser installation. System dependencies are not installed by default. Linux CI explicitly passes --browser-deps to install required system packages, potentially using sudo; Windows ignores that system-dependency option. The external browser cache survives consumer cleanup.
This proves package entrypoints, types, Node workflows and the real Chromium storage page, not local-folder permissions, a complete editor UI, every image format or all publish/restore formats. Project tests and user examples remain separate; all eight examples here run in consumer verification.
See the development guide for verification and CI scope, and Packages and Tools for product entrypoints.


