fyflow-plugin-sdk

中文文档
0.7.0发布于 2026/11/04MITTypeScriptES Module零依赖

Types and definePlugin for fyflow-app Plugins: Node Types, Workers, gate types and Resource Groups

原始使用文档
文档目录12 节
  1. The contract
  2. The Plugin directory
  3. Peer range
  4. Icons
  5. Workers
  6. A Worker's lifecycle
  7. A Plugin's data directory
  8. A Plugin's status
  9. The disk , cpu and gpu groups
  10. Holding a group while a Worker lives
  11. Profiles and secrets
  12. Versioning

fyflow-plugin-sdk

Types and definePlugin for writing Plugins for fyflow-app, the visual builder and Host for fyflow-orchestration flows. A Plugin contributes Node Types (with the Workers that run their Tasks), gate types, Resource Group types and Resource Groups.

npm install --save-peer fyflow-plugin-sdk
npm install --save-dev fyflow-orchestration   # its types, for type checking; the Host brings its own at run time

The contract

A Plugin is a module whose default export is a plain object. definePlugin returns its argument unchanged; it only gives you type checking. Depend on the SDK as a peer and do not bundle it: the Host provides it at run time (see below), and a copy of your own would only report its own SDK_VERSION, not the Host's.

import { definePlugin } from "fyflow-plugin-sdk";

export default definePlugin({
  id: "acme.signer", // stable and unique across Plugins
  version: "1.0.0",
  base: import.meta.url, // what Worker `url`s resolve against
  // Set on the Host's Profiles, once for every Node Type that takes them (see Profiles and secrets).
  profile: {
    schema: {
      type: "object",
      properties: { key: { type: "string", writeOnly: true } }, // a secret
    },
    startup: ["key"], // read once, when a Worker starts
  },
  workers: {
    sign: {
      url: "./sign.worker.js",
      settingsSchema: { type: "object", properties: { field: { type: "string" } } },
    },
  },
  nodeTypes: {
    Sign: {
      worker: "sign",
      inline: true,
      profile: ["key"], // the fields of `profile` its Workers get
      groups: ["acme.signing"],
      icon: "signature", // from the app's icon set, below
      description: "Signs a field with HMAC.",
    },
  },
  groups: { "acme.signing": { type: "ConcurrentLimitGroup", config: { limit: 2 } } },
  gateTypes: {
    threshold: {
      routes: ["below", "at-or-above"],
      defaultRoute: "below",
      fn: ({ key, settings }) => (Number(key) >= Number(settings.at) ? "at-or-above" : "below"),
    },
  },
});
FieldWhat it is
profileThe Plugin's Profile fields, since 0.5.0: one object schema, defaults for fields no Profile sets, the startup fields its Workers read when they start, and the per-Task fields only a Profile may set (profileOnly). See Profiles and secrets.
workersWorker modules by key. A Worker module's default export is a class: its constructor gets the start-up fields its Node Type takes, and run({ settings, input }, context) does one Task (see Workers). A Worker declares no configSchema since 0.5.0: its config is the Plugin's profile. Schemas are JSON Schema in fyflow's subset. A Worker's outputSchema, what run returns (accepted by every version of the SDK, as fyflow's own Worker entry), is the form a person fills in to mark a failed Task done, and the paths the editor suggests for conditions on its Node's Edges and gates before any Run. Give inputSchema and outputSchema a description on each field and an examples list: a Node's panel shows what its Worker takes and returns from them, and checks what reaches the Node against what it takes. Before any Run, the first of an outputSchema's examples is the sample the next Node's Transform is tried and checked on; without one, the editor makes a coarser value from the schema.
nodeTypesNode Types by name, each naming one of this Plugin's workers, with fyflow's declaration fields (inline, pool, settings defaults, retryPolicy, groups; no config since 0.5.0), since 0.2.0 how the app shows it (an optional icon and a one-line description), and since 0.5.0 the profile fields it takes.
dataDirtrue asks the Host for a data directory of the Plugin's own, since 0.6.0. See A Plugin's data directory.
statusA function saying how the Plugin is, shown on its card on the Plugins page, since 0.6.0. See A Plugin's status.
gateTypesGate types by name: their routes, default route, settings schema, and the decision fn.
groupTypesResource Group types by name: a create(config, key) factory for the scheduler's limit, and a configSchema.
groupsResource Groups by key, of fyflow's built-in types (ConcurrentLimitGroup, RateLimitGroup, KeyedRateLimitGroup) or this Plugin's own. A Node Type may use its own Plugin's groups, and the built-in Plugin's disk, cpu and gpu groups (below).

Do not name a setting like a member of every JavaScript object (constructor, toString, valueOf, hasOwnProperty, __proto__). The Host hands Agents a Flow Definition's shape as a JSON Schema that includes your settingsSchema, and some JSON Schema validators misread such a property: they refuse, or throw, on a Flow that is valid.

Every name a Plugin contributes (Node Type, gate type, Resource Group type, Resource Group) must be unique across the Plugins a Host loads: the Plugin the Host saw first keeps a name, and a later one that repeats it is skipped with the reason.

The Plugin directory

A Host loads Plugins from its Plugin directory (<dataDir>/plugins unless pluginDir, FYFLOW_PLUGIN_DIR or --plugin-dir says otherwise):

  • a folder holding plugin.ts or plugin.js, or a package (package.json and its entry), as the Host lays out a Plugin installed from the app;
  • a package named fyflow-plugin-* or @scope/fyflow-plugin-* under the directory's node_modules, installed with npm. A package must ship JavaScript: Node does not strip TypeScript under node_modules.

A Plugin's own imports resolve from the Plugin directory's node_modules, so npm install your other dependencies there (or bundle them). The SDK is the Host's to provide: on a server, npm install fyflow-plugin-sdk there (npm also installs it as your package's peer); the desktop app puts its own there by itself. Changes take effect when the Host is told to Reload.

Peer range

Declare the SDK versions your Plugin works with as a peer dependency:

{ "peerDependencies": { "fyflow-plugin-sdk": "^0.5.0" } }

A Host implements its SDK version (SDK_VERSION, exported here) and the earlier ones it is compatible with (SDK_COMPATIBLE). It skips, with the reason, a Plugin whose range excludes them all. 0.5.0 changed what Profiles are, so it is compatible with no earlier version: a Plugin written for 0.4 or before is skipped until it is upgraded, as UPGRADING.md describes. 0.6.0 and 0.7.0 only add to 0.5.0, so a Host on 0.7 loads a Plugin declaring ^0.5.0 or ^0.6.0. From 0.5 on, raise the range only for what a Host must do at run time: a Plugin that asks for a data directory says ^0.6.0, since a Host on 0.5 would load it and give it none.

Icons

A Node Type's icon names one of the app's icons, from Lucide: activity, archive, bell, bot, box, braces, brain, calculator, calendar, camera, chart-bar, check, clock, cloud, code, cpu, credit-card, database, download, file, file-text, filter, flag, folder, git-branch, globe, hash, image, key, link, list, lock, mail, map-pin, message-square, package, pen-tool, play, plug, repeat, search, send, server, settings, shield, shopping-cart, signature, sparkles, table, tag, terminal, timer, trash, upload, user, users, wand-sparkles, webhook, zap. A name outside the set is not an error: the Node Type shows its Kind's icon, and the Plugins page shows a warning.

Workers

A Worker module's default export is a class. The Host builds each instance (one per worker of the pool: a thread, or on an inline pool an object on the Host's own thread) with the start-up fields its Node Type takes, from the Profile the pool is for, and reuses it across Tasks, so keep per-Task state out of fields. run gets the Task's envelope (the Node's settings and the Task input) and a context; what it returns is the Task's result, and throwing fails the Task with the error's message. Since 0.3.0 the SDK types all of it:

import type { TaskEnvelope, Worker, WorkerContext } from "fyflow-plugin-sdk";

type Config = { key?: string };
type Settings = { field?: string };

export default class Sign implements Worker<Settings> {
  readonly #config: Config;

  constructor(config: Config) {
    this.#config = config;
  }

  async run({ settings, input }: TaskEnvelope<Settings>, context: WorkerContext) {
    if (this.#config.key === undefined) throw new Error("No key: set `key` on this Node's Profile");
    context.sendProgress(0, "signing"); // a fraction from 0 to 1, not a percentage
    context.signal.throwIfAborted(); // aborted when the Task is Cancelled
    const value = settings.field === undefined ? input : (input as Record<string, unknown>)[settings.field];
    return { signed: await hmac(this.#config.key, value) }; // hmac: your own signing function
  }
}

WorkerClass<Config, Settings, Input, Output> types the class itself, constructor included. Check context.signal between steps, or pass it to anything that takes one (fetch, streams): a Worker that stops promptly frees its slot sooner, and what it returns after a Cancel is discarded. Use import type, so a Worker module needs nothing from the SDK at run time. The context offers what the Host shows (progress) and Cancel; fyflow's scheduler passes more, but the SDK promises only these.

A Worker's lifecycle

Since 0.5.0 the SDK also promises what happens to an instance between Tasks, for a Worker that owns something long-lived (a child process, a connection, a loaded model):

  1. Built: new Worker(config, pool), where config is its start-up fields and pool a PoolContext.
  2. setup(), if it has one: once, awaited before its first Task. Start what it owns here, not in the constructor.
  3. run, for any number of Tasks.
  4. teardown(), if it has one: once, when the pool retires the instance. That is when it has been idle past its pool's idleTimeout (the pool checks every 5 seconds, so up to 5 seconds later), when it asked to be replaced, and when its Runtime shuts down: the Host stopping, or after a Reload once the last Run on the replaced Runtime ends.
import type { PoolContext, Worker } from "fyflow-plugin-sdk";

export default class Thumbnails implements Worker {
  readonly #pool: PoolContext | undefined;
  #service: Service | undefined; // Service, start: your own

  constructor(_config: object, pool?: PoolContext) {
    this.#pool = pool;
  }

  async setup() {
    // Port 0: another copy of this Worker may be running (see below).
    this.#service = await start({ port: 0, onExit: (error) => this.#pool?.terminateWithError(error, { canRestart: true }) });
  }

  async teardown() {
    await this.#service?.stop(); // undefined when setup() did not get that far
  }

  async run() {
    return this.#service?.thumbnail();
  }
}

What to rely on, and what not to:

  • A setup() that throws fails every Task the instance is given, with its message, and its teardown() runs at once. It is not tried again on that instance: the pool builds a new one only once it retires this one, at a Reload, or once no Task has reached it for its idleTimeout, which a steady stream of Tasks never allows. So a setup() that may fail for a passing reason (a port taken, a service still starting) should retry within itself before it throws, and teardown() must cope with a setup() that never finished.
  • pool.terminateWithError(error, { canRestart: true }) asks the pool to replace the instance, say after the process it owns dies. It returns at once and never throws, so call it from a callback or a timer. The instance's teardown() runs, its Tasks still running go back in the queue to run again on a new instance, and that one is built and set up. With canRestart: false those Tasks fail instead. The pool replaces instances only so many times (3, fyflow-scheduler's maxWorkerRestarts); after that it builds no more, and its Tasks wait until a Reload. So ask when the instance is broken, not because a Task failed: throw from run for that.
  • Never assume one copy. Each Profile has its own pool, a pool may hold several instances (up to its maxThreads), and after a Reload the replaced Runtime's instances keep running its Runs while the new Runtime sets up its own. Bind port 0, not a fixed port, and keep anything shared outside the Worker.
  • idleTimeout defaults to 5 seconds. For a Worker costly to set up, give its Node Type's pool a longer one (pool: { idleTimeout: 60_000 }); a Profile may override it for that Node Type. 0 keeps an instance until its Runtime shuts down.

A Plugin's data directory

A Plugin that keeps state beyond one Host process (a cache its service requires, a record of a ban to wait out) sets dataDir: true (since 0.6.0). The Host then gives it a folder of its own, plugin-data/<plugin id>/ under the Host's data directory, and creates it before it builds the Plugin's Workers. Each Worker of the Plugin finds its absolute path in its config, as host.dataDir, on an inline pool or a threaded one alike:

import { join } from "node:path";
import type { HostConfig, Worker } from "fyflow-plugin-sdk";

type Config = { host: HostConfig; ttlHours?: number };

export default class Lookup implements Worker {
  readonly #cache: string;

  constructor(config: Config) {
    this.#cache = join(config.host.dataDir, "answers.sqlite");
  }
  // …
}
  • The folder is kept across Reloads, updates, and uninstalling then installing again. The app never deletes it: to start afresh, delete it by hand while the Host is stopped.
  • So that the path has a place in the config, a Plugin that asks for one may not have a start-up field named host; the Host skips it with the reason. A Plugin that does not ask gets no folder and no host in its config, and its own host field is its own.
  • Several instances, and the Runtime a Reload replaced, may run at once: write to the folder as a shared place (a SQLite database, a file replaced atomically), not as one Worker's own.

A Plugin's status

A Plugin that keeps something running between Tasks (a connection, a session, a queue behind a rate limit) can say how it is with status() (since 0.6.0). The Host shows the answer on the Plugin's card on its Plugins page, asking again every 5 seconds while the page is open, and gives it to Agents. A Plugin without status() shows none.

import { definePlugin } from "fyflow-plugin-sdk";
import { session } from "./session.ts"; // your own module, shared with the inline Workers

export default definePlugin({
  // …
  status: () =>
    session.blockedUntil === null
      ? { level: "ok", summary: `Logged in · ${session.queued} queued` }
      : {
          level: "warning",
          summary: "Blocked after a ban",
          details: [{ label: "Blocked until", value: session.blockedUntil.toLocaleTimeString() }],
        },
});
  • level is ok, info, warning or error; summary is one line; details are optional label and value pairs. It is data: the Host draws it, so a Plugin still ships no UI code.
  • It runs on the Host's own thread, where the Host loaded the Plugin's module. It sees what that module shares with inline Workers, not what threaded Workers hold.
  • Answer within a second. A status() that throws, rejects, answers with something else, or takes longer shows as an error status with the reason. While one answer is still pending, the Host does not ask again.
  • It is handed { dataDir } when the Plugin asks for a data directory, and {} otherwise.
  • The Host redacts its secrets from the answer, as it does from errors, but do not put one there.

The disk, cpu and gpu groups

The Host's built-in Plugin declares three Resource Groups any Plugin's Node Types may name, each a concurrency limit a Host can override on its Resource Groups page:

  • disk (exported as DISK_GROUP since 0.3.0), on Tasks that read or write the Host's disks. It is 4. Name it when your Tasks do heavy disk work (copying, transcoding, indexing), so they share one budget with the Host's own file system Node Types instead of thrashing the disk beside them.
  • cpu (exported as CPU_GROUP since 0.4.0), on Tasks that keep a processor busy (hashing, encoding, image work). It is the Host's logical processor count, so such Tasks from every Plugin together take no more processors than there are.
  • gpu (exported as GPU_GROUP since 0.7.0), on Tasks that use a GPU (inference, rendering, GPU encoding). It is 1, since a Host cannot count its GPUs; a person raises it on a machine with more. It only counts: a Task is not told which GPU it holds, so when the limit is above 1 your Plugin chooses each Task's device (from its settings, or by keeping track itself).

A Task that does both names both:

import { CPU_GROUP, DISK_GROUP, definePlugin } from "fyflow-plugin-sdk";

export default definePlugin({
  // …
  nodeTypes: { Transcode: { worker: "transcode", groups: [DISK_GROUP, CPU_GROUP] } },
});

Other Plugins' groups stay out of reach, so Disabling one Plugin never breaks another. The keys are values, so a Plugin that imports them needs the SDK at run time; writing "disk", "cpu" or "gpu" instead works on any Host that has the group. Hosts before SDK 0.4.0 have no cpu and skip a Plugin naming it, so a Plugin that names it says ^0.4.0 in its peer range; the same goes for gpu and ^0.7.0.

Holding a group while a Worker lives

groups holds a unit of each group for one Task, from its start to its end. A Worker that holds something for as long as it lives, such as a model it keeps loaded in a GPU's memory between Tasks, names the group in its Node Type's pool.residentGroups instead (since 0.7.0): the pool takes the units when it builds the instance and gives them back when the instance is retired (idle past its idleTimeout, a Reload, or the Host stopping). Another pool builds an instance only while what it needs still fits, and its Tasks wait meanwhile: up to the holder's idleTimeout and 5 seconds more after the holder's last Task. So a Node Type holding a group resident needs an idleTimeout above 0; the Host refuses one of 0, from the Plugin or a Profile.

import { definePlugin, GPU_GROUP } from "fyflow-plugin-sdk";

export default definePlugin({
  // …
  groups: { "imagegen.vram": { type: "ConcurrentLimitGroup", config: { limit: 24 } } }, // GB
  workers: { model: { url: "./model.worker.ts" } },
  nodeTypes: {
    // Keeps its model loaded for 10 minutes after its last Task, holding the GPU and 16 GB of its memory meanwhile.
    Generate: {
      worker: "model",
      pool: { maxThreads: 1, idleTimeout: 600_000, residentGroups: { [GPU_GROUP]: 1, "imagegen.vram": 16 } },
    },
  },
});

residentGroups is a list of keys (a unit of each) or keys with how many units each holds, and may name the same groups groups may: the Plugin's own and disk, cpu and gpu. A Host refuses a Plugin naming any other. Units are held per instance, so a pool with maxThreads: 2 takes two; and each Profile has its own pool of a Node Type, so a Node Type that Tasks run under two Profiles holds units for each. Keep maxThreads at 1 for a Worker that holds a whole device, and a Host that runs it under several Profiles needs as many units as it has pools alive at once.

Profiles and secrets

A Plugin's configuration lives on the Host, not in Flows: a Profile is a named configuration with a section for each Plugin, edited in the Host's UI with a form built from the Plugin's profile.schema. Every Host has a Default Profile. Declare your fields once, in profile, and list on each Node Type the ones it takes: a field several Node Types take is set once, for all of them. A field no Profile sets is its defaults value.

A start-up field (listed in profile.startup) is read when a Worker starts: the start-up fields a Node Type takes are its Workers' config, the constructor's argument, and a change takes effect at the Host's next Reload. Use one for what a Worker sets up once: a connection, a loaded model.

Every other field is a per-Task field: the Host reads it as each Task is dispatched and hands the ones the Node Type takes to run as settings.profile ({} when it takes none), so a change reaches the next Task with no Reload. Type them with TaskEnvelope<Settings, Input, ProfileFields>. Your settingsSchema may not name profile. List in profile.profileOnly the per-Task fields only a Profile may set: a grant, such as the folders Tasks may reach, beside a field a Flow may set to narrow it, which your Worker checks against the grant. Per-Task fields never reach a Run's records, but what run returns is recorded, so do not return them.

Mark a property of profile.schema writeOnly: true to make it a secret: the Host never shows it again, only whether it is set, and redacts its value from errors and logs. Only properties of the schema can be secrets.

Versioning

This package follows semver. SDK_VERSION always equals the package version.

版本与发布记录

fyflow-plugin-sdk 共发布 6 个版本,首次发布于 2026/10/24;过去 12 个月发布了 6 个版本。

版本发布日期距今
0.7.0latest2026/11/04今天
0.6.02026/11/012 天前
0.5.02026/10/286 天前
0.3.02026/10/269 天前
0.2.02026/10/2410 天前
0.0.0-stage2026/10/2410 天前

关于 fyflow-plugin-sdk 的常见问题

fyflow-plugin-sdk 是什么?

fyflow-app插件的类型和definePlugin:节点类型,工人,门类型和资源组

如何安装 fyflow-plugin-sdk?

在项目目录运行 npm install fyflow-plugin-sdk,或使用 pnpm add / yarn add / bun add。

fyflow-plugin-sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

fyflow-plugin-sdk 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

fyflow-plugin-sdk 可以商用吗?

fyflow-plugin-sdk 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

fyflow-plugin-sdk 还在维护吗?

最新版本 0.7.0 发布于 2026/11/04(今天);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/10/24。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。