Документация пока доступна только на английском языке. Остальная часть сайта соответствует вашему языку.
Plugins
Write a plugin in plugins/<name>/, then install, activate, and manage .jfpkg packages. Manifest, permissions, and the SDK context.
8 минута чтения
Write a plugin
If you are developing against the CE source, create a folder under `plugins/` and start there. One folder per plugin. Copy plugins/hello-world (it listens to content.published). The pnpm workspace includes plugins/*, and the server scans that directory when you run from source. Import types from @justflows/sdk only. Build to dist/index.js — the runtime does not load TypeScript.
- 1
Copy the example
cp -R plugins/hello-world plugins/acme-seo - 2
Set the id
Use an id of the form
justflows.<name>injustflows.jsonandsrc/index.ts(justflows.acme-seo); the installer rejects any other id. Declare a GPL-compatiblelicense. - 3
Write and build
Edit
src/. Thenpnpm --filter justflows.acme-seo buildsodist/index.jsexists.
Note
Do not put plugin source under packages/. That tree is platform code. Site owners never copy folders into plugins/ — they install a .jfpkg in Admin.
Install a packaged plugin
Admin → Plugins. Drop a .jfpkg on the page or use justflows plugin install <path>. Then activate. Core is MIT; plugins keep the license in their manifest (example: GPL-2.0-or-later).
Updates and auto-update
Plugins, Themes, and Marketplace show Update available when the Marketplace has a newer build that works with this version of Justflows, with an Update to vX button and Check for updates. An active plugin is switched to the new build without a restart. If the new build fails to activate, the plugin is marked as an error and the reason is shown.
Each plugin and theme has an Auto-update switch. A background job checks shortly after boot and then twice a day, and installs updates within the same major version. Major updates wait for an administrator, beta builds are only offered while beta installs are allowed, and JUSTFLOWS_DISABLE_AUTO_UPDATE=1 turns automatic updates off. Updates go through the same signature, digest, and commercial-listing checks as installs, and are written to the audit log. API: GET /api/marketplace/updates, POST /api/marketplace/update, PUT /api/marketplace/auto-update (administrator).
Lifecycle APIs
| Action | HTTP | CLI |
|---|---|---|
| List | GET /api/plugins | justflows plugin list |
| Install | POST /api/plugins | justflows plugin install <path> |
| Activate | POST /api/plugins/:id/activate | justflows plugin activate <id> |
| Deactivate | POST /api/plugins/:id/deactivate | justflows plugin deactivate <id> |
| Delete | DELETE /api/plugins/:id | — |
Actions fired: plugin.installed, plugin.activated, plugin.deactivated, plugin.uninstalled. Runtime loads active plugins in plugin-runtime before deferred routes register.
A plugin can declare a setupPath, an admin page relative to /admin/plugins/<id>, and activating it opens that page for first-run setup. Admin sidebar pages are contributed through the admin.menu filter (needs admin:extend) and re-validated by GET /api/plugins/admin-menu. Plugin settings, secrets, and schema live in plugin_data, not site_settings, and every plugin implements a deleteData hook the host calls on uninstall.
Installed is not active
Uploading a package only installs it. Its module, admin navigation, blocks, and public routes become available after activation and are removed immediately on deactivation. Activation, deactivation, and deletion revalidate cached public pages.
Bundled content extensions
- Forms — a fully standalone plugin. It ships its own block, its routes under
/api/forms/(such as/api/forms/submit), a framed admin app, and the publicjf-forms.jsenhancement script, and sends submission notifications through the permission-gatedctx.mail.send()API. Forms 0.4.0 can start Elsa Workflows when a form is sent. Keep it active if your site uses forms; the default Contact pattern cannot be imported until its block is active. - Gallery — registers its own block with Grid, Masonry, Carousel, Slideshow, and List layouts and a lightbox. Carousel uses scroll snap; Slideshow cross-fades; the public output needs no client-side gallery library.
- Analytics — adds its public behavior only while the extension is active.
- Cookie Consent — a categorised consent banner and preference center, per-category script and embed gating, and versioned consent records. Its admin screen ships in the plugin, in all eight admin languages. See Cookie consent.
- SEO Toolkit — meta descriptions, Open Graph and Twitter Card tags, JSON-LD, title templates, the per-page SEO fields, and RSS, Atom, and JSON feeds. See SEO.
- Shop — catalog, the
/shoppage, cart, checkout, orders, customers, payments, shipping, tax, discounts, and order emails. See the Shop guide. - Demo mode — a Demo role and demo sign-in that resets on a schedule. Plugins restore their own data through the
justflows.demo.targets,justflows.demo.snapshot, andjustflows.demo.restorehooks.
Minimal plugin
import type { PluginModule } from "@justflows/sdk";
const plugin: PluginModule = {
manifest: {
id: "justflows.acme-welcome",
name: "Acme Welcome",
version: "1.0.0",
license: "GPL-2.0-or-later",
permissions: [],
main: "index.js",
},
activate(ctx) {
ctx.hooks.action("content.published", (event) => {
ctx.logger.info("Published", { contentId: event.contentId });
});
},
};
export default plugin;Ship a front-end asset bundle
For public-site JavaScript — or a standalone stylesheet — declare an assets block ({ dir?, scripts?, styles? }) in justflows.json and drop the files in the package. The host serves <dir>/** at /ext/<pluginId>/** and concatenates every active plugin's scripts and styles into one content-hashed `/jf-plugins.<hash>.js` / `.css` bundle added to every public page. No ctx.http route, no html.head filter. Each script is wrapped in its own IIFE; write them as progressive enhancement (no import/export), .js/.mjs/.css only, at most 20 of each. PLUGIN_ASSETS_BUNDLE=0 emits one tag per file. The static-site exporter downloads the bundle automatically, so a plugin front-end works on a CDN deployment with no extra wiring.
Ship your own admin app
A plugin's admin screens can be its own app rather than React pages compiled into the host bundle. Declare adminApp ({ dir?, routes: [{ path, entry, title }] }) alongside an adminMenu item and ship an HTML build; the host serves it at /ext/<pluginId>/admin/** and mounts each declared route in a same-origin <iframe> inside the admin shell. The two sides talk only over postMessage via the @justflows/admin-bridge package (ready, resize, navigate, and setLayout from the frame; context and route from the host) — no shared React runtime, no core page or route. The frame is same-origin, so it reads the CSRF cookie itself and calls the plugin's own ctx.http routes for data. bridge.setLayout("fill") gives the frame the whole content area while the sidebar and section tabs stay. Rules: dir/entry relative with no .., entry must be .html, each path is relative to /admin/plugins/<id> (omit it for the root), at most 40 routes. adminApp.locales lists a catalog per admin language; the host passes them to the frame, and adminMenu labels use them too.
An adminApp route nested under one of the plugin's own pages (for example products/bulk-edit under products) does not need its own adminMenu entry: it stays out of the sidebar and uses the parent page's section. Bulk edit: when a plugin lists a content type in the host's content list and ships a {path}/bulk-edit route, selecting rows shows a Bulk edit button next to Delete. The selected ids are handed to the plugin page through sessionStorage (jf.admin.bulkSelection).
Sidebar sections, editor panels, and workspace cards
- Menu labels. An
adminMenuitem'slabelKeyis looked up in the plugin's ownadminApp.localescatalogs first, andlabels({ "nl": "Beoordelingen" }) translates it without an admin app.rolesnames the roles that may open the page, such as one the plugin registers; without it, administrators and editors may. An unknowndomainfalls back toextensions. - Own sidebar section (Justflows 0.4.1). Items with
domain: "plugin"are listed in a section for that plugin, above Extensions.adminSectionnames it (label,labelKey, orlabels) and sets itsicon: an emoji, or a.png,.svg, or.webpfile in the admin app folder. - Content editor panels.
editorPanelsadds up to 5 sections next to Content, Discussion, and Advanced, each with up to 20 fields (text,textarea,url,image, orcheckbox) saved in the entry'sfields. Labels can be one string or one per locale,placeholdercan use{title},{excerpt}, and{url}, andindexableOnlylimits the panel to content types included in search engines. From Justflows 0.4.1 a panel can embed an admin-app screen instead (app), which receives the entry'scontentId, drafts included. - Workspace cards (Justflows 0.4.1).
workspacePanelsadds up to 5 cards to Platform → Workspace on the main site, each embedding an admin-app screen that receives theworkspaceId. - A switch per website. A plugin that registers the website flag
<plugin id>.enabledgets a switch in the website's Features list; off hides the plugin's admin pages on that website, and the plugin refuses its own routes withctx.quotas.check. Register it withwhenUnset: "off"to keep it off until a plan or an operator turns it on. - All of these need
admin:extend.
Publish a health check
Declare diagnostics:publish and register a read-only check during activation with ctx.diagnostics.register({ id, label, run() }). It appears in Admin → System → Diagnostics. Check IDs are namespaced by plugin, execution is time-bounded by the host, and returned details are recursively redacted before display or export — checks must not return credentials, personal data, or raw provider responses. Registrations are removed automatically on deactivation.
Ship your own stylesheet
Public components rendered by a plugin should ship their CSS with that plugin. Keep source CSS under src/styles/, minify or copy it to dist/styles/ during pnpm build, read the built file once, and append it with the async theme.css filter during activation. Do not put plugin-specific rules in the active theme and do not inject a second stylesheet link through html.head.
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const marker = "/* acme.catalog */";
const css = (await readFile(
fileURLToPath(new URL("./styles/catalog.css", import.meta.url)),
"utf8",
)).trim();
ctx.hooks.filter("theme.css", (current) =>
current.includes(marker) ? current : `${current}\n${marker}\n${css}\n`,
);- Package the ready-to-serve file below
dist/; installed plugins do not run TypeScript or a CSS toolchain. - Use plugin-namespaced classes and theme tokens such as
--color-*,--space-*, and--radius-*. - Plugin CSS is added after theme styles and Customizer tokens, but before the site owner's Additional CSS.
- Deactivation removes the filter and cache revalidation rebuilds
/theme.csswithout the plugin CSS. - The combined plugin contribution is limited to 512 KiB. It is trusted extension output, so never concatenate request or site data into CSS.
Note
The theme.css filter runs once per cached stylesheet build, not per page request, and may be async. Cache file contents in the plugin process. See plugins/hello-world for a minimal example.
Block fields
A plugin block's schema field can set label and help for the inspector, optionLabels for the text shown per fixed choice, optionsUrl for a same-origin route the editor GETs for choices (answering { options: [{ value, label }] }), multiple for a checklist saved as a string array, and showWhen: { field, equals } to hide the field unless another prop matches. A field's type can also be media (one image URL) or images (a list of { src, alt, caption } added by upload, from the media library, or by URL). For those, the host adds a responsive prop before render; pass it through the SDK's applyResponsiveProp and renderResponsiveImage to get the same <picture>, srcset, and lazy loading as core.image, and use escapeHtml for captions. The page builder previews a plugin block with its own render, sanitized like an HTML block. See PluginBlockField.
source: { type: "select", label: "Show", options: ["all", "picked"], optionLabels: { all: "Everything", picked: "Pick items" }, default: "all" },
items: {
type: "string",
label: "Items",
multiple: true,
optionsUrl: "/ext/acme.featured/blocks/options/items",
showWhen: { field: "source", equals: "picked" },
},Route policy
A plugin declares CSRF exemption, a host rate limit, and raw-body capture on the ctx.http route itself, as a third argument (PluginHttpRouteOptions). The host applies that policy; core never special-cases plugin URLs, and deactivating the plugin drops the policy with the route.
ctx.http.post("payments/hooks/:gateway/:token", handler, {
csrf: false, // only when the call is authenticated another way, e.g. a signed webhook
rawBody: true, // exact request bytes on req.rawBody for signature checks
rateLimit: { limit: 60, windowMs: 60_000, key: "payment-hook" },
});Add mcp to a route's options to offer it to AI agents as one MCP tool, with the route's own handler and checks (see Bring your own AI). Management API keys (Authorization: Bearer jfk_…) authenticate plugin routes too: such requests skip CSRF, arrive with role: "api-key", and follow the key's site, IP, Origin, expiry, and rate-limit rules.
A route's response can also return redirect: "/path" to send the browser to a same-site path (303 unless status is already a redirect; absolute and protocol-relative URLs are ignored), or renderPage: "slug" to answer with one of the site's published pages in the active theme at the URL the visitor asked for, uncached. revalidate: true drops this site's caches after a config write; revalidate: "network" drops every site's, and needs platform:network and a request on the main site. req.trustedOrigin is true when the Origin header is this site or an origin the CORS allow-list vouches for, false for any other origin, and absent when there was none — drop a beacon another site fires at yours when it is false. Signed-in sessions now carry displayName.
Background jobs
With jobs:register, ctx.jobs.register() runs a handler on a five-part cron schedule. With perSite: true it runs once for every site where the plugin is active, inside that site's context and database, and gets siteId; one site's failure does not stop the others.
ctx.jobs.enqueue() stores each run in the job_queue table, so it survives a restart, and resolves once it is stored. Every call is its own run with its own payload. A process claims a due run with a lease and a heartbeat, so one run executes on one process at a time, and a run interrupted by a crash is picked up again. Failed runs are retried after 1, 5, 25, and then every 60 minutes until maxAttempts (default 3). Administrators see waiting, running, and failed runs under Diagnostics → Job queue and can retry them; payloads are never shown. Make handlers safe to repeat.
ctx.jobs.register({
name: "reconcile",
schedule: "*/5 * * * *",
perSite: true,
handler: async () => {
await repairOpenWork(ctx);
return { success: true };
},
});
await ctx.jobs.enqueue("send-receipt", { orderId }, { delayMs: 60_000 });Plugin tables
ctx.databases.ensureSchema() creates your tables and, on every later activation, adds the declared columns and indexes existing tables lack (columnsAdded, indexesAdded). Added columns are nullable and not unique; types are never changed and nothing is dropped. For work that must not race or half-finish, use the row primitives inside transaction(): insert() returns false when a unique key is already taken, update() returns the matched rows for compare-and-set, increment() adds deltas with a floor, and find() / findOne() take orderBy and lock. find() also takes range and an after cursor to read any number of rows, 500 at a time. All of it works on PostgreSQL, MySQL, and MariaDB.
await ctx.databases.transaction(async (tx) => {
await tx.findOne("inventory", { id }, { lock: true });
const moved = await tx.increment("inventory", { id }, { available: -qty }, { min: { available: 0 } });
if (moved !== 1) throw new Error("Not enough stock");
await tx.insert("stock_movements", { id: newId(), inventory_id: id, delta: -qty });
});Private files and uploads
With files:private, ctx.files stores files for the current site that are never public, such as products sold as downloads: put, get, delete, and list. A route answers { file: { key } } to have the host stream one, with Range support and without revealing where it is stored. A route registered with binaryBody: { maxBytes } receives the raw request body as a Buffer (up to 1 GiB) to accept uploads. Files go to the site's storage from Settings → Storage, the installation's S3 bucket under .private/, or PRIVATE_STORAGE_PATH, and count toward the files.count and files.bytes limits.
ctx.http.get("downloads/:token", async (req) => {
const grant = await findGrant(req.params.token);
if (!grant) return { status: 404, body: { error: "Not found" } };
return { file: { key: grant.fileKey, filename: grant.name } };
});Public API URLs
Declare apiNamespace (for example "shop") in both manifests and your routes are also served at /api/<namespace>/…, with the same site, authorization, CSRF, rate-limit, and body policies. Build URLs with ctx.http.url() on the server or pluginApiUrl() from the SDK in the browser. /ext/<id>/… keeps working as an alias. Routes can opt into structuredErrors: true so host guard failures answer { error: { code, message } }.
More context APIs
ctx.mail.send({ attachments })attaches up to 5 files (PDF, PNG, JPEG, CSV, plain text, calendar; 10 MB each, 15 MB together). See Outgoing mail.ctx.content.getPublished({ type, slug, locale })returns one published entry with its blocks, in the visitor's language when a translation is published (needscontent:read).ctx.hooks.emit(name, payload, { durable: true })stores core webhook deliveries before listeners run, for plugins with their own event outbox.ctx.quotasregisters, checks, and (withplatform:tenancy) sets limits.ctx.users.removeRole(target, role, actor)takes back an additional role the plugin gave, never the main role (needsusers:manage).ctx.i18n.timeZone()andctx.i18n.countries()return the site's time zone and the core country list.- A
selectsetting lists its ownoptionsor usesoptionsSource: "timezones"or"countries"; saving refuses a value that is not listed. adminApp.routesallows up to 40 admin pages. A route undersettings/that the plugin lists inadmin.menuis linked from its Settings page.ctx.plugins.call(pluginId, { method, path, query?, body?, session? })(plugin ids may contain a hyphen, such asjustflows.shared-files) runs another active plugin's route in-process as the given session, so that plugin's own checks apply. Withplatform:tenancyit accepts thesiteIdof another site in the same workspace.ctx.captcha.config()andctx.captcha.verify(token, action?)use the site's Settings → Discussion CAPTCHA without ever seeing its secret. Without anaction,verifyexpects the plugin's own name (helpdeskforjustflows.helpdesk).ctx.ai.complete({ instruction, text, system? })returns one short completion from the site's assistant, with no tools.systemsets the plugin's own role for the assistant; the host always adds thattextis data written by someone else, not instructions.ctx.tenancy.siblingSites()lists the active sites of the current workspace (needsplatform:tenancy; it cannot see other workspaces).ctx.databases.forSite(siteId)reads this plugin's own tables on another site, from the installation's main site.ctx.network(needsplatform:network) —mainSiteSettings.get(key)reads this plugin's main-site settings from any site, andmainSiteSecrets.get(key)/has(key)read its main-site secrets, read-only, so a hosted site can use a connection the main site saved.sites()lists every live site, main site first, from the main site only, andsite(siteId?)returns one live site with its publicurlfrom any site (the main site when no id is given). Pair it with hooks registered as{ network: true }. See Hooks.ctx.content.ensurePage({ …, blocks, cacheControl, retireTypes })stores a starting block document and Cache-Control only when the page is first inserted, so an edited canvas is kept;retireTypestrashes an older entry with the same slug (for a page that moves frompagetoaccount).ctx.content.deletePages({ type, slug })permanently deletes a page in every locale, trash included (needscontent:delete).hooks.apply("operatorKey.ensure", { name: "JUSTFLOWS_ACME_KEY" })asks the host to create a missing 32-byte key in the installation.env. The name must use the plugin's own prefix, an existing key is kept, and the value is never returned. Helpdesk uses it forJUSTFLOWS_HELPDESK_KEK.- The
security.cspSourcesfilter adds thehttps://hosts your public pages load to the Content-Security-Policy, so a third-party script or frame is not blocked by the default policy. The Stripe Buy Button uses it. - A plugin block can declare
inspectorto switch the builder's generic sections (spacing,size,appearance,colors,animation,advanced,themeVariables) on or off, andinspector.hideFieldsto drop single layout fields such astextAlign. - The
plugin.settings.schemafilter adjusts a plugin's settings form for the current site: drop fields, or fill aselect'soptions. A dropped key is neither shown nor saved.
Manifest rules
- id —
justflows.<name>, lowercase with hyphens, e.g.justflows.acme-seo. The installer rejects any other id, and the id is the prefix of the admin namespace (/admin/plugins/<id>), the asset mount (/ext/<id>/), and every hook and block the plugin owns. - version — semver.
- license — required; the plugin's own license. Must be a GPL-compatible SPDX identifier (GPL, MIT, BSD, ISC, …). Not inherited from MIT core.
- permissions — declared up front; sensitive ones are
network:outbound,users:manage,settings:manage,auth:hook,mail:send,mail:transport,mail:templates,mail:hook,platform:tenancy, andplatform:network. - main — entry file, default
index.js. - Optional
minJustflowsVersion/maxJustflowsVersion, description, author, homepage. - Plugins, themes, and CSS providers share an
engines.justflowsrange the installer enforces before a package leaves staging; the public SDK export surface is snapshotted in CI so an export cannot vanish without a deprecation cycle. The legacy top-leveljustflowsrange is still accepted as a deprecated alias. - Optional
requires: ["justflows.shop"]— plugins that must already be installed on this site. Install is refused while one is missing, and activation is refused until each one is active. A plugin cannot require itself. Shop quotes uses it.
Plugin permissions
content:read|create|update|delete|publish, media:read|upload|delete, users:read|manage, settings:read|manage, network:outbound, admin:extend, jobs:register, auth:hook, mail:transport|templates|hook|send, diagnostics:publish, platform:tenancy (sensitive: manage workspaces, set limits, and listen to domain and quota hooks; see Multisite), platform:network (sensitive: register { network: true } hooks that run on every site and use ctx.network), files:private (store private files with ctx.files).
Context (`activate(ctx)`)
hooks— typed action / gate / filter registration (see Hooks). Core actions includecomments.created, fired when a comment is stored as pending or approved, with ids and the status but no email, IP address, or comment text.cache— namespaced jf-cache (plugin:{id}:…).remember,get,set,delete,invalidate.settings— plugin settings store.logger— attributed to your plugin id.content—ensureType,ensurePage,deleteTypefor plugins that own CMS types;listPublished(query?)reads published entries by type / locale / author / date (needscontent:read).deleteCreatedBy(userId)permanently deletes a user's content, uploads, and comments and drops their working revisions elsewhere; it never deletes the user and refuses an administrator (needscontent:delete).users—create()a user in a role your plugin registered,addRole({ userId } | { email }, role, actor)to give an existing user one of your roles as an additional role, andget(userId)to read a user with all their roles (all needusers:manage). Plugin HTTP sessions includeroles, main role first.media—placeholder(kind)andplaceholderHtml(kind)return the site's placeholder from a block'srender();registerPlaceholder()ships a default for your own kind.databases/secrets— prefixed plugin tables (ensureSchema,upsert,find,delete) and encrypted secret storage.tenancy—current()returns the workspace and site for this request (workspace id, site id, hostname, user and database mode, never a password).listWorkspaces,listSites,createWorkspace,addSite,suspend,reactivate, anddeleteWorkspaceneedplatform:tenancy.cookies—declare()a non-essential cookie andlist()the resolved site registry.capabilities—register()a runtime capability so it appears in the role editor and access policy.patterns—register()a validated, plugin-scoped block pattern for the editor library.diagnostics—register()a read-only health check for Admin → System → Diagnostics (needsdiagnostics:publish).mail—send()through the host transport without seeing credentials (needsmail:send);registerTemplate()ships versioned system-email templates.i18n—defaultLocale()andlocales()return the site's configured locales, read-only.runtime— the running Justflows, SDK package, and SDK API versions.pluginId,version,permissionsset.
Good to know
You do not unregister hooks on deactivate — the loader disposes registrations when the plugin stops. The html.head, html.title, and html.canonical filters receive a PublicPageContext: the page's locale, title, content type and fields, description, canonical URL, share image, origin, and whether it is indexable. Import types from @justflows/sdk only. Author guides in the CE repo: docs/PLUGINS.md, docs/MANIFEST.md, docs/PERMISSIONS.md, docs/PACKAGING.md, docs/THEMES.md, docs/BLOCKS.md, docs/TESTING-EXTENSIONS.md.
Your errors show in Diagnostics
Errors your plugin logs with ctx.logger.error(), exceptions from its hook handlers, and uncaught route failures all appear in Admin → System → Diagnostics → Recent errors as plugin:<id>. There is no opt-in or opt-out.