Quick Start
用一个真实的 Canvas Runtime 验证 NodeBraid:安装 Kernel Plugin,通过公共 @nodebraid/core facade 提交一个 Node,然后完整结束 Plugin Host 生命周期。
Packages 尚未公开发布
本仓库声明的 @nodebraid/* package 尚未发布到 npm。首次公开发布前请从源码运行,不要复制尚未生效的 npm 安装命令。
1. 获取源码
bash
git clone https://github.com/ZWkang/nodebraid.git
cd nodebraid
bun installNodeBraid 要求 Bun 1.2.19 或更高版本。
2. 运行示例
bash
bun run docs:quick-start命令会先构建公共 facade 及其 workspace 依赖,再执行下面这份与文档共用的示例源码:
ts
import { createPluginHost, definePlugin, kernelPlugin, kernelService, nodeId } from '@nodebraid/core';
const appPlugin = definePlugin({
name: 'docs.quick-start',
requires: { kernel: kernelService },
setup(context) {
const commit = context.services.kernel.transact((transaction) => {
transaction.nodes.add({
id: nodeId('welcome'),
type: 'task',
position: { x: 0, y: 0 },
data: null,
});
});
if (!commit) throw new Error('Expected the Quick Start transaction to commit.');
console.log(`revision=${commit.after.snapshot.revision} nodes=${commit.after.snapshot.nodes.length}`);
},
});
const host = createPluginHost();
try {
host.install(kernelPlugin);
const app = host.install(appPlugin);
await app.whenActive();
} finally {
await host.dispose();
}成功输出:
text
revision=1 nodes=13. 理解这条链路
createPluginHost()创建一份隔离的 Plugin Host。kernelPlugin为自己的 Activation 创建 revision-zero Kernel,并提供KernelService。- 应用 Plugin 通过 Required Service 声明 Kernel 依赖,而不是从全局容器动态查找。
KernelService.transact()同步、原子地提交 Node,产生 revision 1。whenActive()证明应用 Plugin 已获得全部 Required Services。host.dispose()结束 Installation、Activation 与 Owned Resource 生命周期。
这个示例刻意保持 headless:它验证 Document 与 Runtime 组合,不创建可见画布。当前已经交付的 @nodebraid/renderer-svg 可在下一层把同一 Canvas 语义投影到 SVG,但不属于这条最小 Kernel 路径。
4. 下一层:Basic Canvas Composition 与真实 SVG
如果需要 Kernel、Command、Session、Renderer、Interaction 与 History 的完整基础组合,可以显式选择 SVG Provider 并安装 @nodebraid/preset-basic。下面的 Runtime creator 由私有 Interactive Example 与真实 Chromium 验收共同使用:
ts
import {
commandService,
createBasicCanvasPlugin,
createClipboardPlugin,
createClipboardShortcutPlugin,
createDuplicateSelectionPlugin,
createDuplicateSelectionShortcutPlugin,
createLayoutPlugin,
createPluginHost,
createPortContributionPlugin,
defineCommand,
definePlugin,
edgeId,
historyService,
kernelService,
nodeId,
nodePlacementService,
rendererService,
sessionService,
type ClipboardErrorCode,
type CommandService,
type CanvasNode,
type NodePlacementService,
type Point,
type HistoryService,
type KernelService,
type LayoutCommandInput,
type LayoutCommandResult,
type SessionService,
} from '@nodebraid/core';
import { dagreLayoutEngine, type DagreLayoutConfig } from '@nodebraid/layout-dagre';
import { createSvgRenderer } from '@nodebraid/renderer-svg';
import { createNavigatorClipboardTransport, exampleCanvasFragmentTextCodec } from './clipboard-adapters';
import { createExampleNodeContent } from './node-content';
import { exampleZoomRange } from './viewport';
export type ExampleNodeType = 'step' | 'note';
export const exampleLayoutCommand = defineCommand<LayoutCommandInput<DagreLayoutConfig>, LayoutCommandResult>(
'nodebraid.examples.layout.dagre',
);
export interface ClipboardFailureSnapshot {
readonly code: ClipboardErrorCode;
readonly causeName: string;
}
export interface ExampleRuntime {
readonly commands: CommandService;
readonly history: HistoryService;
readonly kernel: KernelService;
readonly session: SessionService;
readonly placement: NodePlacementService;
materializeNode(type: ExampleNodeType, position: Point, label: string): CanvasNode;
getClipboardFailure(): ClipboardFailureSnapshot | undefined;
subscribeClipboardFailure(listener: () => void): () => void;
disposeClipboardShortcuts(): Promise<void>;
disposeDuplicateShortcut(): Promise<void>;
dispose(): Promise<void>;
}
export async function createExampleRuntime(target: SVGSVGElement): Promise<ExampleRuntime> {
let clipboardFailure: ClipboardFailureSnapshot | undefined;
const clipboardFailureListeners = new Set<() => void>();
const host = createPluginHost({
diagnostics: {
faultReporter(fault) {
const clipboardError = findClipboardError(fault.error);
if (!clipboardError) {
queueMicrotask(() => {
throw fault.error;
});
return;
}
clipboardFailure = Object.freeze({
code: clipboardError.code,
causeName: describeCauseName(clipboardError.cause),
});
const listeners = Array.from(clipboardFailureListeners);
for (const listener of listeners) listener();
},
},
});
let nextEdgeSequence = 1;
let nextNodeSequence = 1;
let nextDuplicateNodeSequence = 1;
let nextDuplicateEdgeSequence = 1;
let nextClipboardNodeSequence = 1;
let nextClipboardEdgeSequence = 1;
let commands: CommandService | undefined;
let history: HistoryService | undefined;
let kernel: KernelService | undefined;
let session: SessionService | undefined;
let placement: NodePlacementService | undefined;
const basicCanvasPlugin = createBasicCanvasPlugin(createSvgRenderer, {
interaction: {
...exampleZoomRange,
wheelZoomSensitivity: Math.log(2) / 100,
connection: {
materializeEdge({ source, target: edgeTarget }) {
const id = freshEdgeId('example-edge', () => nextEdgeSequence++);
return { id, type: 'flow', source, target: edgeTarget, data: null };
},
},
},
});
const duplicateSelectionPlugin = createDuplicateSelectionPlugin({
materializeIds({ nodeIds, edgeIds }) {
return {
nodes: nodeIds.map((sourceId) => {
const duplicateId = freshNodeId('example-duplicate-node', () => nextDuplicateNodeSequence++);
return { sourceId, duplicateId };
}),
edges: edgeIds.map((sourceId) => {
const duplicateId = freshEdgeId('example-duplicate-edge', () => nextDuplicateEdgeSequence++);
return { sourceId, duplicateId };
}),
};
},
});
const stepPortsPlugin = createPortContributionPlugin({
id: 'nodebraid.examples.step-ports',
definePorts(node) {
return node.type === 'step'
? [
{ id: 'input', role: 'target' },
{ id: 'output', role: 'source' },
]
: [];
},
});
const duplicateSelectionShortcutPlugin = createDuplicateSelectionShortcutPlugin();
const clipboardPlugin = createClipboardPlugin({
transport: createNavigatorClipboardTransport(),
codec: exampleCanvasFragmentTextCodec,
materializeIds({ nodeIds, edgeIds }) {
return {
nodes: nodeIds.map((sourceId) => {
const materializedId = freshNodeId('example-clipboard-node', () => nextClipboardNodeSequence++);
return { sourceId, materializedId };
}),
edges: edgeIds.map((sourceId) => {
const materializedId = freshEdgeId('example-clipboard-edge', () => nextClipboardEdgeSequence++);
return { sourceId, materializedId };
}),
};
},
});
const clipboardShortcutPlugin = createClipboardShortcutPlugin();
const applicationPlugin = definePlugin({
name: 'nodebraid.examples.basic-svg',
requires: {
commands: commandService,
history: historyService,
kernel: kernelService,
renderer: rendererService,
session: sessionService,
placement: nodePlacementService,
},
setup(context) {
commands = context.services.commands;
history = context.services.history;
kernel = context.services.kernel;
session = context.services.session;
placement = context.services.placement;
},
});
const stepPorts = host.install(stepPortsPlugin);
const composition = host.install(basicCanvasPlugin, {
target,
connectionAnchorHitTolerance: 12,
createNodeContent: createExampleNodeContent,
});
const layout = host.install(createLayoutPlugin({ engine: dagreLayoutEngine, command: exampleLayoutCommand }));
const duplicateSelection = host.install(duplicateSelectionPlugin);
const duplicateSelectionShortcut = host.install(duplicateSelectionShortcutPlugin);
const clipboard = host.install(clipboardPlugin);
const clipboardShortcuts = host.install(clipboardShortcutPlugin);
const application = host.install(applicationPlugin);
try {
await Promise.all([
composition.whenActive(),
layout.whenActive(),
stepPorts.whenActive(),
duplicateSelection.whenActive(),
duplicateSelectionShortcut.whenActive(),
clipboard.whenActive(),
clipboardShortcuts.whenActive(),
application.whenActive(),
]);
if (!commands || !history || !kernel || !session || !placement) {
throw new Error('Expected all Basic SVG Example Runtime Services.');
}
const first = nodeId('example-discover');
const second = nodeId('example-compose');
const third = nodeId('example-ship');
const initialEdge = edgeId('example-initial-edge');
const commit = kernel.transact((transaction) => {
transaction.nodes.add({
id: first,
type: 'step',
position: { x: 80, y: 100 },
size: { width: 160, height: 88 },
data: null,
});
transaction.nodes.add({
id: second,
type: 'step',
position: { x: 360, y: 260 },
size: { width: 160, height: 88 },
data: null,
});
transaction.nodes.add({
id: third,
type: 'step',
position: { x: 660, y: 120 },
size: { width: 160, height: 88 },
data: null,
});
transaction.edges.add({
id: initialEdge,
type: 'flow',
source: { nodeId: first, portId: 'output' },
target: { nodeId: second, portId: 'input' },
data: null,
});
});
if (!commit) throw new Error('Expected the Basic SVG Example initial graph Commit.');
return Object.freeze({
commands,
history,
kernel,
session,
placement,
materializeNode(type: ExampleNodeType, position: Point, label: string): CanvasNode {
return {
id: freshNodeId('example-node', () => nextNodeSequence++),
type,
position,
size: { width: 160, height: 88 },
data: Object.freeze({ label, tags: Object.freeze([]) }),
};
},
getClipboardFailure: () => clipboardFailure,
subscribeClipboardFailure(listener: () => void) {
clipboardFailureListeners.add(listener);
return () => clipboardFailureListeners.delete(listener);
},
disposeClipboardShortcuts: () => clipboardShortcuts.dispose(),
disposeDuplicateShortcut: () => duplicateSelectionShortcut.dispose(),
async dispose() {
try {
await host.dispose();
} finally {
clipboardFailureListeners.clear();
}
},
});
} catch (error) {
await host.dispose();
throw error;
}
function freshNodeId(prefix: string, next: () => number) {
if (!kernel) throw new Error('Example ID allocation requires an active Kernel Service.');
const query = kernel.read().query;
let id = nodeId(`${prefix}-${next()}`);
while (query.getNode(id)) id = nodeId(`${prefix}-${next()}`);
return id;
}
function freshEdgeId(prefix: string, next: () => number) {
if (!kernel) throw new Error('Example ID allocation requires an active Kernel Service.');
const query = kernel.read().query;
let id = edgeId(`${prefix}-${next()}`);
while (query.getEdge(id)) id = edgeId(`${prefix}-${next()}`);
return id;
}
}
function describeCauseName(cause: unknown): string {
if (cause instanceof Error) return cause.name;
return cause === undefined ? 'None' : typeof cause;
}
function findClipboardError(
error: unknown,
seen = new Set<object>(),
): (Error & Readonly<{ domain: 'plugin.clipboard'; code: ClipboardErrorCode; cause?: unknown }>) | undefined {
if (typeof error !== 'object' || error === null || seen.has(error)) return undefined;
seen.add(error);
if (error instanceof Error && Reflect.get(error, 'domain') === 'plugin.clipboard') {
return error as Error &
Readonly<{
domain: 'plugin.clipboard';
code: ClipboardErrorCode;
cause?: unknown;
}>;
}
if (error instanceof AggregateError) {
for (const nested of error.errors) {
const found = findClipboardError(nested, seen);
if (found) return found;
}
}
return error instanceof Error ? findClipboardError(error.cause, seen) : undefined;
}这里仍由应用创建 Host、传入现有 SVG Target 并通过静态 Required Service Consumer 取得能力。Preset 不创建默认 Renderer,也不把 SVG 或 DOM 变成通用依赖。