code-docs/architecture/plugin-system.md
How Lowdefy's plugin architecture works.
The plugin system enables:
| Type | Purpose | Registry Location |
|---|---|---|
| Blocks | UI components | lowdefy._internal.blockComponents |
| Connections | Data sources | lowdefy._internal.connections |
| Operators | Expression evaluators | lowdefy._internal.operators |
| Actions | Event handlers | lowdefy._internal.actions |
| Auth | Authentication providers | context.authOptions |
Schema: packages/build/src/lowdefySchema.js
plugins:
- name: '@lowdefy/blocks-antd'
version: '4.0.0'
- name: '@my-org/custom-blocks'
version: '1.0.0'
typePrefix: 'custom' # Optional namespace
| Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Package name |
version | string | Yes | Version constraint |
typePrefix | string | No | Namespace prefix |
build()
↓
buildRefs() # Load config with plugin declarations
↓
buildTypes() # Count which types are used
↓
buildImports() # Generate import statements
↓
writePluginImports() # Write import files
File: packages/build/src/utils/createPluginTypesMap.js
Creates mapping from type names to package info:
{
'Button': {
package: '@lowdefy/blocks-antd',
originalTypeName: 'Button',
version: '4.0.0'
},
'customTable': {
package: '@my-org/custom-blocks',
originalTypeName: 'Table',
version: '1.0.0'
}
}
File: packages/build/src/defaultTypesMap.js
Pre-built map of all built-in Lowdefy plugins:
{
// Actions
'@lowdefy/actions-core': { ... },
'@lowdefy/actions-pdf-make': { ... },
// Blocks
'@lowdefy/blocks-basic': { ... },
'@lowdefy/blocks-antd': { ... },
'@lowdefy/blocks-echarts': { ... },
// Connections
'@lowdefy/connection-mongodb': { ... },
'@lowdefy/connection-axios-http': { ... },
// Operators
'@lowdefy/operators-js': { ... },
'@lowdefy/operators-nunjucks': { ... },
// Auth
'@lowdefy/plugin-next-auth': { ... },
'@lowdefy/plugin-auth0': { ... }
}
File: packages/build/src/build/buildTypes.js
Tracks which types are actually used:
context.typeCounters = {
actions: { SetState: 5, Request: 12 },
blocks: { Button: 8, TextInput: 15 },
connections: { MongoDBCollection: 2 },
requests: { MongoDBFind: 5, MongoDBInsertOne: 3 },
operators: {
client: { _state: 45, _if: 12 },
server: { _secret: 3, _payload: 8 },
},
auth: {
providers: { GoogleProvider: 1 },
adapters: {},
callbacks: {},
events: {},
},
};
@lowdefy/blocks-basic/
├── package.json
├── src/
│ ├── blocks.js # Named exports for all block components
│ ├── metas.js # Named exports for all block meta.js files
│ ├── types.js # Type declarations (via extractBlockTypes)
│ └── blocks/
│ ├── Anchor/
│ │ ├── Anchor.js
│ │ └── meta.js # Block metadata + property schema
│ ├── Box/
│ │ ├── Box.js
│ │ └── meta.js
│ └── Icon/
│ ├── Icon.js
│ └── meta.js
└── dist/
blocks.js:
export { default as Anchor } from './blocks/Anchor/Anchor.js';
export { default as Box } from './blocks/Box/Box.js';
export { default as Icon } from './blocks/Icon/Icon.js';
metas.js:
export { default as Anchor } from './blocks/Anchor/meta.js';
export { default as Box } from './blocks/Box/meta.js';
export { default as Icon } from './blocks/Icon/meta.js';
types.js:
import { extractBlockTypes } from '@lowdefy/block-utils';
import * as metas from './metas.js';
export default extractBlockTypes(metas);
// Returns: { blocks: ['Anchor', 'Box', 'Icon'], icons: {...}, blockMetas: {...} }
@lowdefy/connection-mongodb/
├── src/
│ ├── connections.js # Named exports
│ ├── types.js # Type declarations
│ └── connections/
│ └── MongoDBCollection/
│ ├── MongoDBCollection.js
│ ├── MongoDBFind/
│ │ └── MongoDBFind.js
│ └── MongoDBInsertOne/
│ └── MongoDBInsertOne.js
connections.js:
export { default as MongoDBCollection } from './connections/MongoDBCollection/MongoDBCollection.js';
Connection Structure:
export default {
schema: {
/* JSON Schema for connection properties */
},
requests: {
MongoDBFind,
MongoDBFindOne,
MongoDBInsertOne,
MongoDBUpdateOne,
// ...
},
};
@lowdefy/operators-js/
├── src/
│ ├── types.js
│ └── operators/
│ ├── build/ # Build-time operators
│ ├── client/ # Browser operators
│ ├── server/ # Backend operators
│ └── shared/ # Both client & server
types.js:
export default {
operators: {
client: Object.keys(client),
server: Object.keys(server),
},
};
@lowdefy/actions-core/
├── src/
│ ├── actions.js # Named exports for all actions
│ ├── schemas.js # Named exports for all action schemas
│ ├── types.js # Type declarations
│ └── actions/
│ ├── CallAPI/
│ │ ├── CallAPI.js
│ │ └── schema.js # JSON schema for CallAPI params
│ ├── Request/
│ │ ├── Request.js
│ │ └── schema.js
│ └── SetState/
│ ├── SetState.js
│ └── schema.js
actions.js:
export { default as CallAPI } from './actions/CallAPI/CallAPI.js';
export { default as Request } from './actions/Request/Request.js';
export { default as SetState } from './actions/SetState/SetState.js';
File: packages/build/src/build/writePluginImports/
| Output File | Generator | Purpose |
|---|---|---|
plugins/blocks.js | writeBlockImports.js | Block components |
plugins/connections.js | writeConnectionImports.js | Connection handlers |
plugins/actions.js | writeActionImports.js | Action handlers |
plugins/operators/client.js | writeOperatorImports.js | Client operators |
plugins/operators/server.js | writeOperatorImports.js | Server operators |
plugins/auth/*.js | writeAuthImports.js | Auth components |
plugins/blockMetas.json | writeBlockSchemaMap.js | Block runtime metadata |
plugins/icons.js | writeIconImports.js | Icon components |
plugins/blockSchemas.json | writeBlockSchemaMap.js | Block property schemas |
plugins/actionSchemas.json | writeActionSchemaMap.js | Action param schemas |
plugins/operatorSchemas.json | writeOperatorSchemaMap.js | Operator param schemas |
File: packages/build/src/build/writePluginImports/generateImportFile.js
const template = `
{%- for import in imports -%}
import { {{ import.originalTypeName }} as {{ import.typeName }} } from '{{ import.package }}/{{ importPath }}';
{% endfor -%}
export default {
{% for import in imports -%}
{{ import.typeName }},
{% endfor -%}
};
`;
Dev: (buildImportsDev.js)
package.json to determine which packages are installed, then includes all types from those packagesProd: (buildImportsProd.js)
File: packages/client/src/initLowdefyContext.js
function initLowdefyContext({ auth, Components, config, lowdefy, router, stage, types, window }) {
lowdefy._internal = {
actions: types.actions,
blockComponents: types.blocks,
operators: types.operators,
// ...
};
}
File: packages/client/src/block/CategorySwitch.js
const Component = lowdefy._internal.blockComponents[block.type];
if (!Component) {
throw new Error(`Block type "${block.type}" not found`);
}
return <Component {...props} />;
File: packages/api/src/routes/request/getConnection.js
function getConnection({ connections }, { connectionConfig }) {
const connection = connections[connectionConfig.type];
if (!connection) {
throw new ConfigurationError(`Connection type "${connectionConfig.type}" not found.`);
}
return connection;
}
During parsing, operators are resolved from the registry:
// In parser
const operator = this.operators[operatorName];
if (operator) {
return operator({ params, location, context, ... });
}
Prevents naming conflicts when using custom plugins:
plugins:
- name: '@my-org/blocks'
typePrefix: 'my'
| Original Type | With Prefix | Usage in Config |
|---|---|---|
Button | myButton | type: myButton |
Table | myTable | type: myTable |
1. lowdefy.yaml declares plugins
↓
2. Build loads plugin packages
↓
3. createPluginTypesMap() creates type → package mapping
↓
4. buildTypes() counts used types
↓
5. buildImports() generates import statements
↓
6. writePluginImports() writes import files
↓
7. Next.js bundles imports
↓
8. Runtime: types passed to initLowdefyContext()
↓
9. Runtime: types available in lowdefy._internal
Connections and requests include inline JSON schemas validated at request time via validateSchemas in @lowdefy/api:
// Connection schema — inline on the connection export
export default {
schema: {
type: 'object',
properties: {
databaseUri: { type: 'string' },
collection: { type: 'string' },
read: { type: 'boolean', default: true },
write: { type: 'boolean', default: false }
},
required: ['databaseUri', 'collection']
},
requests: { ... }
}
// Request schema — attached to the resolver function
MongoDBFind.schema = {
type: 'object',
properties: {
query: { type: 'object' },
options: { type: 'object' },
},
};
Block schemas are generated at build time from meta.js files via buildBlockSchema(meta). Actions and operators export schemas via a separate schema.js file. These are collected at build time and used for reactive validation — when an error occurs, the received data is validated against the schema to produce a more helpful diagnostic message.
Schema definition pattern:
// Block meta (e.g., blocks/Button/meta.js) — schema generated by buildBlockSchema()
export default {
category: 'display',
icons: [],
cssKeys: { element: 'The button element.' },
events: { onClick: 'Called when button is clicked.' },
properties: {
type: 'object',
additionalProperties: false,
properties: {
title: { type: 'string' },
type: { type: 'string', enum: ['default', 'primary', 'dashed', 'link'] },
},
},
};
// Action schema (e.g., actions/SetState/schema.js)
export default {
type: 'object',
params: {
type: 'object',
description: 'Key-value pairs to set in state.',
},
};
// Operator schema (e.g., operators/shared/get.schema.js)
export default {
type: 'object',
params: {
type: 'object',
required: ['from'],
properties: {
from: { description: 'Object or array to get value from.' },
key: { type: 'string' },
default: { description: 'Default value if key does not exist.' },
},
additionalProperties: false,
},
};
Build-time collection: writePluginImports generates schema map JSON files:
| Plugin Type | Build Artifact | Schema Key |
|---|---|---|
| Blocks | plugins/blockSchemas.json | properties (generated from meta.js via buildBlockSchema) |
| Actions | plugins/actionSchemas.json | params |
| Operators | plugins/operatorSchemas.json | params |
Runtime validation flow: When a BlockError, ActionError, or OperatorError reaches the server via /api/client-error, logClientError reads the schema map, validates the received data, and produces a ConfigError with a human-readable message if validation fails. See api.md.
Package export convention: Block packages export metadata via a /metas entry point. Actions and operators export schemas via a /schemas entry point:
{
"exports": {
"./metas": "./dist/metas.js",
"./schemas": "./dist/schemas.js"
}
}
Custom plugin schemas: Custom plugins can provide schemas via typesMap.schemas in the build context, which takes priority over package-exported schemas. Custom block metadata can be provided via typesMap.blockMetas.
| File | Purpose |
|---|---|
packages/build/src/lowdefySchema.js | Plugin schema validation |
packages/build/src/utils/createPluginTypesMap.js | Type mapping |
packages/build/src/defaultTypesMap.js | Built-in plugins |
packages/build/src/build/buildTypes.js | Type counting |
packages/build/src/build/buildImports/ | Import routing |
packages/build/src/build/writePluginImports/ | Import generation |
packages/client/src/initLowdefyContext.js | Runtime initialization |
packages/client/src/block/CategorySwitch.js | Block resolution |
// my-plugin/src/blocks/MyButton/MyButton.js
const MyButton = ({ blockId, properties, methods }) => {
return (
<button onClick={() => methods.triggerEvent({ name: 'onClick' })}>
{properties.label}
</button>
);
};
export default MyButton;
// my-plugin/src/blocks/MyButton/meta.js
export default {
category: 'display',
icons: [],
cssKeys: { element: 'The button element.' },
events: { onClick: 'Called when MyButton is clicked.' },
properties: {
type: 'object',
properties: {
label: { type: 'string' },
},
},
};
// my-plugin/src/blocks.js
export { default as MyButton } from './blocks/MyButton/MyButton.js';
// my-plugin/src/metas.js
export { default as MyButton } from './blocks/MyButton/meta.js';
// my-plugin/src/types.js
import { extractBlockTypes } from '@lowdefy/block-utils';
import * as metas from './metas.js';
export default extractBlockTypes(metas);
// my-plugin/src/connections.js
export { default as MyAPI } from './connections/MyAPI.js';
// my-plugin/src/connections/MyAPI.js
export default {
schema: {
type: 'object',
properties: {
apiKey: { type: 'string' },
},
},
requests: {
MyAPIGet: async ({ connection, request }) => {
const response = await fetch(request.url, {
headers: { 'X-API-Key': connection.apiKey },
});
return response.json();
},
},
};
// my-plugin/src/operators/client/myOperator.js
function _myOperator({ params, location }) {
return params.toUpperCase();
}
export default _myOperator;
types.js declares available types