This guide is for developers and companies who want to add their own node to PLGRND, either upstream (a PR to this repository — the node ships on plgrnd.io) or in your own fork and deployment. The technical steps are identical; only the last section differs.
Assumes TypeScript, React and Solana basics; none of them are explained here.
| Topic | Value |
|---|---|
| App | apps/web — React 19, Vite 7, @xyflow/react 12 (React Flow), Zustand, Tailwind 4 |
| Install / run | yarn install · yarn web:dev |
| Verify | yarn web:build (tsc -b, the Vitest suite, then vite build, which also enforces the bundle budgets in apps/web/bundle-budget.json) · yarn workspace web lint · npx prettier --check <files you touched> (yarn format:check scans the whole repo and may flag files you did not touch) |
| Tests | Vitest: yarn web:test (it also runs inside yarn web:build, so a red test blocks the deploy). Tests sit next to the code as *.test.ts; offline fixtures live in apps/web/src/__fixtures__/ (see its README). UI still goes through the manual checklist in §7. |
| Where nodes live | apps/web/src/{types/nodes,utils/node/data,components/nodes}/<category>/ |
| How nodes are wired | Hand-edited registries: types/node.ts, utils/node/node-config-registry.ts, utils/node/node-map.ts, utils/node/node-style.utils.ts, constants/menu-config.ts, constants/node-tooltips.tsx |
Caught by tsc |
Missing entries in nodeConfigRegistry, nodeMap, nodeStyles (all Record<NodeType, …>) |
| Not caught | Missing entries in menuConfig (node invisible), NODE_TOOLTIPS (no tooltip), README table |
| Execution model | No executor. Each node is a React component: it reads its inputs, computes, and writes its outputs into its own data; downstream nodes re-render. |
| Plugin / runtime API | None. Nodes are compiled into the bundle. |
All paths below are relative to apps/web/src/ unless stated otherwise.
If your goal is "let people call my Solana program from PLGRND", you probably do not. Drop an IDL node (Programs category) and type your program id: the IDL loads from chain (the canonical Program Metadata account or the Anchor IDL account). You can also paste it. Legacy Anchor, Anchor 0.30+, Codama and Shank IDLs all work. The program's instructions and accounts become nodes (PROGRAM_INSTRUCTIONS, PROGRAM_ACCOUNT). No code, no PR.
Write a custom node when you need logic that is not an on-chain instruction: a derivation, an encoding, a lookup, a UI for a specific data shape.
NodeTypeEnum.X ─┬─ types/nodes/<cat>/x-node.ts XNodeData, XNodeType
├─ utils/node/data/<cat>/x-node-data.ts xNodeConfig: label, handles, actions
├─ components/nodes/<cat>/x-node.tsx <XNode/> wraps <CustomNode/>
├─ utils/node/node-config-registry.ts [X]: xNodeConfig (tsc)
├─ utils/node/node-map.ts [X]: XNode (tsc)
├─ utils/node/node-style.utils.ts [X]: { color, width } (tsc)
├─ constants/menu-config.ts category.nodes.push(X) (silent)
└─ constants/node-tooltips.tsx NODE_TOOLTIPS[X] (silent)
| Piece | Type | Notes |
|---|---|---|
NodeConfig |
{ label: string; handles: HandleConfig[]; actions: NodeActionConfigBase[] } |
Must be declared as const satisfies NodeConfig — input field names are inferred from it |
HandleConfig |
{ position: Position; type: 'source' | 'target'; dataField: string; dataType?; maxConnections?; label? } |
dataField is the key in data (source) or the input name (target) |
NodeActionConfigBase |
{ position: Position; label: string } |
Rendered as a small button; wired with useNodeActions |
CustomNode |
components/ui/custom-node.tsx |
Renders the colour strip, label, handles, actions and tooltip. Always wrap your JSX in it. |
useTypedNodesData<TargetFieldsForEnum<NodeTypeEnum.X>>(id) |
hooks/flow/use-typed-nodes-data.ts |
{ [targetField]: { value, sourceId, sourceHandleId, dataField } } for connected inputs |
useTypedReactFlow().updateNodeData<XNodeData>(id, patch) |
hooks/flow/use-typed-react-flow.ts |
Publishes outputs; keys must match source dataFields |
A pure node: one text in, one text out. Category string, id STRING_REVERSE, label REVERSE. It exists only to teach the pattern — delete it after you have followed the steps, or replace it with your real node.
1. Enum — types/node.ts, append at the end of NodeTypeEnum (order is cosmetic: NodeType is keyof typeof NodeTypeEnum):
STRING_REVERSE = 'STRING_REVERSE',2. Data type — new file types/nodes/string/string-reverse-node.ts:
import type { Node } from '@xyflow/react'
import type { NodeTypeEnum } from '../../node'
export type StringReverseNodeData = {
reversed: string
}
export type StringReverseNodeType = Node<StringReverseNodeData, NodeTypeEnum.STRING_REVERSE>3. Config — new file utils/node/data/string/string-reverse-node-data.ts:
import { Position } from '@xyflow/react'
import type { NodeConfig } from '@/types/node-config'
export const stringReverseNodeConfig = {
label: 'REVERSE',
handles: [
{ position: Position.Left, type: 'target', dataField: 'text', label: 'Text', dataType: 'text' },
{ position: Position.Right, type: 'source', dataField: 'reversed', label: 'Reversed', dataType: 'text' },
],
actions: [],
} as const satisfies NodeConfig4. Config registry — utils/node/node-config-registry.ts (do this before step 5: the component's input types are derived from the registry):
import { stringReverseNodeConfig } from './data/string/string-reverse-node-data'
// …
export const nodeConfigRegistry = {
// …
[NodeTypeEnum.STRING_REVERSE]: stringReverseNodeConfig,
} as const satisfies Record<NodeType, NodeConfig>5. Component — new file components/nodes/string/string-reverse-node.tsx:
import { useEffect, useMemo } from 'react'
import type { NodeProps } from '@xyflow/react'
import { CustomNode } from '../../ui/custom-node'
import { useTypedNodesData } from '@/hooks/flow/use-typed-nodes-data'
import { useTypedReactFlow } from '@/hooks/flow/use-typed-react-flow'
import type { NodeTypeEnum, TargetFieldsForEnum } from '@/types/node'
import type { StringReverseNodeData, StringReverseNodeType } from '@/types/nodes/string/string-reverse-node'
import { toText } from '@/utils/string/string-node.utils'
import { StringNodeContent, StringNodePreview } from './string-node-content'
export const StringReverseNode = (props: NodeProps<StringReverseNodeType>) => {
const { updateNodeData } = useTypedReactFlow()
const resolved = useTypedNodesData<TargetFieldsForEnum<NodeTypeEnum.STRING_REVERSE>>(props.id)
const text = useMemo(() => toText(resolved.text?.value), [resolved])
const reversed = useMemo(() => Array.from(text).reverse().join(''), [text])
useEffect(() => {
updateNodeData<StringReverseNodeData>(props.id, { reversed })
}, [props.id, reversed, updateNodeData])
return (
<CustomNode {...props}>
<StringNodeContent>
<StringNodePreview value={reversed} />
</StringNodeContent>
</CustomNode>
)
}6. Component map — utils/node/node-map.ts:
import { StringReverseNode } from '@/components/nodes/string/string-reverse-node'
// …
export const nodeMap: Record<NodeType, React.ComponentType<any>> = {
// …
[NodeTypeEnum.STRING_REVERSE]: StringReverseNode,
}7. Style — utils/node/node-style.utils.ts (colour = the category's colour, see the table in §5.4):
[NodeTypeEnum.STRING_REVERSE]: { color: '#7a3f24', width: 180 },8. Menu — constants/menu-config.ts, in the object with id: 'string', append to nodes:
NodeTypeEnum.STRING_REVERSE,9. Tooltip and README — constants/node-tooltips.tsx, inside NODE_TOOLTIPS (optionally a docs URL in NODE_TOOLTIP_LINKS):
STRING_REVERSE: (
<div>
<p>Reverses the input text by Unicode code points.</p>
</div>
),…and add the node to the row of the Node categories table in the root README.md that covers your category (rows merge several categories, e.g. "Math / String / Logic").
Run yarn web:dev, open the menu → String → drag REVERSE onto the canvas, connect a TEXT node to its left handle, type something. Then run §7.
| # | File | What | Forgot it? |
|---|---|---|---|
| 1 | types/node.ts |
enum member | everything else fails to type |
| 2 | types/nodes/<cat>/<name>-node.ts |
<Name>NodeData, <Name>NodeType |
tsc error |
| 3 | utils/node/data/<cat>/<name>-node-data.ts |
<name>NodeConfig |
tsc error |
| 4 | utils/node/node-config-registry.ts |
registry entry | tsc error |
| 5 | components/nodes/<cat>/<name>-node.tsx |
<Name>Node component |
tsc error |
| 6 | utils/node/node-map.ts |
map entry | tsc error |
| 7 | utils/node/node-style.utils.ts |
{ color, width } |
tsc error |
| 8 | constants/menu-config.ts |
push into a category's nodes |
silent — node not in menu |
| 9 | constants/node-tooltips.tsx + root README.md |
tooltip entry; table row | silent — no tooltip / doc |
A missing entry in 4, 6 or 7 does not fail at that line. TargetFieldsForEnum is derived from the registry's key set, so one missing key produces dozens of TS2344/TS2339 errors in unrelated node files. If the build explodes right after you added a node, check the three registries first.
- Handle DOM id is
${nodeId}-${dataField}-${position}and is parsed by splitting on-.dataFieldmust not contain-. Use camelCase. Nothing enforces this (dataFieldis a plainstring): a dash fails silently at runtime — the input resolves toundefined. - Every handle you add declares
dataType. Compatibility is decided inutils/flow/connection.utils.ts(areHandleTypesCompatible): exact match,anyon either side, or one of the compatibility sets — e.g. atextsource may feedtext | publicKey | signature | privateKey | mint, anumbersource may feednumber | uiAmount | decimals. Existing types:text,publicKey,signature,privateKey,mint,number,uiAmount,decimals,wallet,any. An untyped target accepts anything; an untyped source connects only toany. - Need a new type? Add it to the sets in
connection.utils.tsand say so in the PR. maxConnectionson a target limits fan-in; omit for unlimited.- Handles are stacked per side, 12 px apart; keep
labelto one or two words.
data is persisted to localStorage and encoded into share URLs. On persist, sanitizeForPersist (stores/flow-snapshot.ts) keeps primitives, arrays and plain objects and drops class instances (PublicKey, Keypair, Transaction, Buffer, BN, …). Store strings/numbers/booleans/plain objects; recompute derived values in the component. A new node starts with data: {} — tolerate missing fields.
Flows are embedded on third-party sites (/embed). useFlowMode() from components/flow/flow-mode-context.ts returns { mode: 'readonly' | 'interactive' | 'editor', embedded: boolean }.
CustomNodealready hides actions inreadonlyand disables selection and double-click-to-Display outsideeditor.- Your node must disable its own inputs/buttons when
mode === 'readonly'. - When
embedded, never connect a wallet. Send only with a key signer in the flow; otherwise deep-link to the editor (seecomponents/nodes/wallet/wallet-node.tsx,components/nodes/transactions/transaction-node.tsx). Read-only RPC is fine.
Self-hosted embedding. packages/embed builds the same nodes into <plgrnd-flow>. That widget runs inside a shadow root on someone else's page, next to their scripts and styles, so every node also follows these rules:
- DOM access.
- No
documentorwindowqueries for your own DOM (document.querySelector,getElementById,document.activeElement). Use refs, React events or the React Flow instance; if you need the root, useelement.getRootNode(). - No
document-wide orwindow-wide listeners.
- No
- Navigation. No
location,historyor router access. - UI seams.
- Radix dialogs go only through
components/ui/dialog.tsx, which provides the portal container and the dialog mode. - Selects go through
components/ui/select.tsx; the self-hosted build swaps it for a native select. - Toasts go through
sonner'stoast; the self-hosted build routes them into the widget.
- Radix dialogs go only through
- Checks after a node or dependency change.
- Run
yarn workspace @plgrnd/embed build && yarn workspace @plgrnd/embed verify. - If the global-access register reports drift, run
node packages/embed/scripts/global-access-register.mjs --write. - Review every new or changed entry in
packages/embed/build/global-access-register.json. Classify each one assafe,mitigated:Mx,unreachable-in-S-Noraccepted:<reason>(Mxis a mitigation id from the comments inpackages/embed/buildandsrc/shims;S-Nis the self-hosted build). Anaccepted:entry is a host-visible effect and needs a maintainer decision.
- Run
- Bumps of
@radix-ui/*,@xyflow/*,@tanstack/react-queryorreact-dom.- Re-run the module-singleton inventory. List the module-level state (Sets, Maps, counters, registries) in the bundled files of the bumped packages (
packages/embed/dist/module-list.json). Then confirm that each item is per-widget, safe to share between widgets, or mitigated. @radix-ui/react-dismissable-layerand@radix-ui/react-dialogare patched at exact versions (packages/embed/build/patch-layer-scope.ts,patch-dialog-warnings.ts). The build fails until the patches are re-ported.
- Re-run the module-singleton inventory. List the module-level state (Sets, Maps, counters, registries) in the bundled files of the bumped packages (
| Thing | Convention | Example |
|---|---|---|
| Enum id | SCREAMING_SNAKE |
STRING_REVERSE |
Node label |
short UPPERCASE, ≤ ~14 chars (fits the strip) | 'REVERSE', 'PRIVATE KEY' |
| Handle / action label | Title Case | 'Public key', 'Generate' |
dataField |
camelCase, no - |
balanceRaw, isValid |
| Files | <name>-node.ts, <name>-node-data.ts, <name>-node.tsx in the category folder |
string-reverse-node-data.ts |
| Category | one of the existing ten — input #531d2b, math #3d4f91, logic #8a641f, utils #2f6f6a, transactions #2a5f2b, network #75511e, programs #5a1d5f, wallet #1f3d6b, crypto #265c75, string #7a3f24 |
node colour = category colour |
Category means the menu group in menu-config.ts, not the folder or the name: STRING_ENCODE/STRING_DECODE live in utils. Pick by where a user would look in the menu. New categories are created by maintainers only; pick the closest existing one and explain the choice in the PR.
nodes[].type (your enum id), nodes[].data keys and the dataFields inside edge handle ids are stored in visitors' localStorage (sol-learn:flow) and in every share link and embed. Links are written as version 3 when they only use what PLGRND had before the transaction importer, and as version 4 otherwise (utils/flow/requires-v4.ts), so a pinned older widget says "made with a newer PLGRND" instead of misbuilding. Renaming any of them breaks those flows silently. There is no migration layer and no compatibility promise — flows are educational and ephemeral — so prefer additive changes.
Reference nodes to copy from; no new abstractions needed.
| Pattern | Where to look | Gist |
|---|---|---|
| Action buttons | utils/node/data/crypto/keypair-node-data.ts, components/nodes/network/balance-node.tsx |
actions: [{ position, label }] in config; in the component const actions = useNodeActions(type, { Generate: fn }); pass actions to CustomNode |
| Async / RPC | components/nodes/network/balance-node.tsx, hooks/solana/query/use-solana-balance.ts |
TanStack Query hook keyed by inputs + a refresh counter; write results with updateNodeData when the query resolves |
| Dynamic handles | hooks/nodes/use-program-instructions-node.ts, extraHandles prop of CustomNode |
Compute HandleConfig[] from inputs, pass as extraHandles, call useUpdateNodeInternals()(id) after they change |
New dataType |
utils/flow/connection.utils.ts |
Extend the compatibility sets; mention it in the PR |
| Heavy dependency | constants/solana/instructions-config.ts (await import('@solana/spl-token')) |
Lazy-import inside the handler so the cold bundle (and embeds) stay small |
| Derived data | transient in your NodeConfig |
Data keys recomputed at runtime (parsed objects, fetched values) are stripped from saved and shared flows; a trailing * matches a prefix |
| Pure compute | utils/node/compute/*, hooks/flow/use-node-compute.ts |
Nodes that build transactions compute their outputs in a pure function registered by type; the hook runs it and writes only changed outputs. The headless evaluateTxChain runs the same functions |
| Inline values | data.inline, renderInline prop of CustomNode |
A value typed into the node; an edge into the same handle wins (resolveInputs) |
| Transaction chain | utils/flow/evaluate-tx-chain.ts |
Builder → instruction nodes → Transaction must be registered computes to be replayed for imported and v0 transactions; any other node is an opaque value source |
| Shared string UI | components/nodes/string/string-node-content.tsx, utils/string/string-node.utils.ts |
StringNodeContent, StringNodePreview, StringNodeRows, toText, toNumber |
All four groups are required — for upstream PRs and equally for your own fork.
Windows: the repository is LF-only. With
core.autocrlf=trueevery file is checked out as CRLF andyarn format:checkfails before you have changed anything. Rungit config core.autocrlf false(orinput) before cloning, or renormalize withgit rm --cached -r . && git reset --hard.
- Build & pattern —
yarn web:buildandyarn workspace web lintexit 0;npx prettier --checkpasses on every file you touched (yarn formatfixes it); all nine registration points from §4 filled (the build proves 1–7; check 8–9 by hand). - Runtime — node appears in the menu and renders; every new handle has
dataTypeand incompatible targets dim while dragging an edge; the node survives a page reload and a Share → copy link → open round-trip (datais JSON-plain). - Embed — open the flow via Share → Embed and check
mode=readonly,interactive,editor; no wallet connection or transaction sending when embedded. - Docs & discoverability — README "Node categories" row updated; tooltip entry in
node-tooltips.tsx; a share link to a demo flow ready for the PR.
- Fork
RedDuckTeam/PLGRND, branch frommain(feat/<name>-node). - Run §7. Do not commit the throwaway
Reverse Stringnode. - Open a PR — the template is pre-filled. It must contain:
- what the node does, which existing category it goes into and why;
- a share link to a demo flow (and/or a screenshot);
- the ticked checklist from §7;
- a justification for every new dependency or external (non-RPC) HTTP call — size, purpose, failure handling. There is no fixed rule; maintainers decide case by case. Prefer lazy imports;
- a note if you added a
dataType.
- Review is done by the RedDuck team, without an SLA. A green checklist is necessary, not sufficient: a node can be declined for product reasons (overlap, scope, maintenance cost).
Same steps 1–9 and §7, no PR. To ship:
yarn install
yarn web:build # apps/web/dist
yarn workspace web deploy # Cloudflare Workers via wrangler (apps/web/wrangler.jsonc)
# …or host apps/web/dist on any static host with SPA fallbackSet VITE_PUBLIC_SOLANA_RPC to your RPC endpoint (see apps/web/src/env.ts). Keeping up with upstream is ordinary git fetch/merge — the registries are the usual conflict points, and there is no promise of API stability.
- License. By opening a PR you agree your contribution is licensed under the project's MIT license. No CLA, no DCO.
- Branding. Protocol or company names are fine in
label, tooltips andNODE_TOOLTIP_LINKS('JUPITER QUOTE'). Logos, custom colours and marketing copy are not. - Categories. Existing ten only; new ones are a maintainer decision.
- Dependencies / external APIs. Justified in the PR, decided case by case.
- Compatibility. None promised; see §5.5.
- Review. RedDuck team, no SLA, product-level rejection possible.
A runtime plugin API, a node generator (yarn node:new) and single-file co-located node definitions are future work.
For maintainers. The site (apps/web) and the package ship from this repository. Routine site deploys never need an embed build: release-check only verifies the version folders that are already committed.
- Release gate.
- Ownership of the
@plgrndnpm org must be confirmed. This blocks only step 7. - Steps 1-6 and the site deploy can go ahead before that.
- Until step 7 is done, the GitHub Release and the Share dialog show the npm channel as "pending".
- Ownership of the
- Bump
versioninpackages/embed/package.json. Use semver, and add new attributes only in minor versions. - Run
yarn workspace @plgrnd/embed buildand thenyarn workspace @plgrnd/embed verify. - Run
yarn workspace @plgrnd/embed pack:check. It checks thatnpm pack --dry-runlists exactly the planned files. - Copy
packages/embed/dist/sdk/<v>/toapps/web/public/sdk/<v>/. Never overwrite an existing version folder. Add a/sdk/<v>/*rule with the one-yearimmutableCache-Controltoapps/web/public/_headers; never a broader/sdk/*rule, because Cloudflare would also apply it to the SPA fallback at paths that do not exist yet. Then runnode packages/embed/scripts/release-check.mjs --release, which checks both. - Update the README section "Self-hosted embedding": the version in file names and snippets, and the sizes from
packages/embed/dist/size-report.md. - Commit, and tag the commit
embed-v<v>. - Run
npm publish --access publicinpackages/embed. Then add<v>tonpmPublishedinpackages/embed/release-state.jsonand commit that change. - Run
gh release create embed-v<v>. Attach the IIFE, the two ESM files,SHA256SUMS.txt,SRI.txt,THIRD_PARTY_LICENSES.txtandsize-report.md. - Push to
main. Cloudflare builds and deploys the site; the build bakespublic/sdk/<v>/and both release flags intodist, and runsrelease-checkfirst, so a mismatched/sdkfolder or a bad/sdkcache rule fails the deploy. - Smoke-check the release:
https://plgrnd.io/sdk/<v>/plgrnd-flow-<v>.jsreturns 200, astext/javascript, and its SHA-256 matchesSHA256SUMS.txt. The SPA fallback answers mistyped paths with HTML and 200, so check all three.curl -sI https://plgrnd.io/sdk/<next version>/x.jsreturns the SPA's HTML without the one-yearimmutableheader, so a version requested before its release is not cached.- The Share dialog shows the download links and the npm line.