Skip to content

Latest commit

 

History

History
991 lines (800 loc) · 35.7 KB

File metadata and controls

991 lines (800 loc) · 35.7 KB

Plugin authoring

A plugin is one Rust crate. It bundles its Axum/Vespera API routes, its vinext frontend pages under app/, and optionally sea-orm models with vespertide JSON migrations. Registering the crate in an application is all it takes to get both halves.

This guide walks through plugins/example-memo-plugin, the reference full-stack plugin, end to end.

See also: Getting started, Deployment and security, Architecture overview, and the README.

Crate layout

Plugins use a flat layout with Cargo.toml at the crate root. There is no api/ subdirectory.

plugins/example-memo-plugin/
?��??� Cargo.toml                          # crate manifest, at the root
?��??� package.json                        # devDependencies for TypeScript DX
?��??� tsconfig.json                       # extends packages/app/tsconfig.json
?��??� vespertide.json                     # database model + migration config
?��??� models/
??  ?��??� memo.json                       # model definition
?��??� migrations/
??  ?��??� 0001_initial.vespertide.json    # generated migration
?��??� src/
??  ?��??� lib.rs                          # yeollin_plugin! macro
??  ?��??� models/
??  ??  ?��??� mod.rs
??  ??  ?��??� memo.rs                     # generated sea-orm entity
??  ?��??� routes/
??      ?��??� mod.rs
??      ?��??� memo.rs                     # Vespera route handlers
?��??� app/
    ?��??� (memo)/
        ?��??� page.tsx                    # frontend page
        ?��??� route.meta.json             # label, order, access, menu

Standalone applications under apps/ use the same layout, with src/main.rs instead of src/lib.rs.

Cargo.toml

[package]
name = "example-memo-plugin"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Example memo plugin with database CRUD for Yeollin CMS"

[dependencies]
yeollin-plugin = { path = "../../crates/plugin" }
vespera = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sea-orm = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
vespertide = { workspace = true }
anyhow = { workspace = true }

Shared dependencies come from the workspace root so every crate builds against one version. yeollin-plugin is the only Yeollin crate a plugin needs; it re-exports what the macro expands to.

A plugin without a database can drop sea-orm, chrono, and vespertide. See plugins/example-plugin/Cargo.toml for the minimal set.

src/lib.rs and the yeollin_plugin! macro

pub mod models;
pub mod routes;

yeollin_plugin::yeollin_plugin! {
    name: "example-memo-plugin",
    author: "DevFive",
    description: "Example memo plugin with database CRUD operations",
}

// Re-export entity for migrations
pub use models::memo;

Declaring the routes module is what pulls the route handlers into the crate. The macro expands to vespera::export_app! plus a metadata() function, so the handlers annotated with #[vespera::route(...)] anywhere under src/ are collected automatically.

Macro fields

Field Required Type Meaning
name yes string literal The plugin name. Also becomes its frontend URL prefix.
author no string literal Recorded in the exported plugin metadata.
description no string literal Recorded in the exported plugin metadata.
on_init no expression An async fn(DatabaseConnection) -> anyhow::Result<()> run once at startup.
frontend no bool literal true by default. Set false for an API-only plugin with no app/ directory.
api_base no string literal Override the API namespace derived from name; never include api.
settings no Rust type path Register a typed settings contract and its generated API and page.
collections no expression list Register compile-time typed content collections and their generated CRUD surfaces.
subscribers no expression list Register observe-only Inline or Deferred event subscribers.
public_api_routes no string-literal list Exact, fixed suffixes below this plugin's API namespace that do not require authentication.
runtime_storage no bool literal Require the host to configure a writable RuntimeStorage extension.
request_body_limit no byte-count expression Raise Axum's outer body ceiling; the handler must still enforce its tighter semantic limit.

Anything else is a compile error: the macro rejects unknown fields.

The version and licence come from the crate manifest via CARGO_PKG_VERSION and CARGO_PKG_LICENSE, so there is nothing to keep in sync by hand.

Export identifier

The macro derives a PascalCase export identifier from name: "example-memo-plugin" becomes ExampleMemoPlugin. The yeollin_app! macro derives the same identifier from the plugin's module path (example_memo_plugin). Hyphens and underscores collapse identically, so the two always agree.

on_init and vespertide

If you do not pass on_init and a vespertide.json file exists next to the crate manifest, the macro generates one for you that runs vespertide::vespertide_migration!(&db). That is why example-memo-plugin never writes an initialiser: its migrations are applied automatically at startup.

Pass on_init explicitly when you need more. auth does exactly that, to run migrations and seed the first administrator:

yeollin_plugin::yeollin_plugin! {
    name: "auth",
    author: "DevFive",
    description: "Database-backed users and sessions",
    on_init: initialize,
    frontend: false,
}

async fn initialize(db: DatabaseConnection) -> anyhow::Result<()> {
    vespertide::vespertide_migration!(&db).await?;
    seed_first_admin(&db).await
}

frontend: false there means the crate ships no app/ directory at all.

An on_init callback runs only when the application has a database configured. If it returns an error, startup fails with the plugin name attached.

Runtime files and multipart uploads

include_dir! embeds the static frontend at compile time and cannot hold files created after deployment. A plugin that writes runtime objects declares the capability and the host chooses a persistent root:

const REQUEST_BODY_BYTES: usize = 10 * 1024 * 1024 + 64 * 1024;

yeollin_plugin::yeollin_plugin! {
    name: "media",
    runtime_storage: true,
    request_body_limit: REQUEST_BODY_BYTES,
    public_api_routes: ["/file"],
}

let app = yeollin::app()
    .register_plugin(media::metadata())
    .with_storage_dir("./storage")
    .build();

Declaring runtime_storage: true makes startup fail clearly when the host omits with_storage_dir. Merely building or exporting metadata never creates the directory. At runtime, handlers extract RuntimeStorage; its store_file, open_file, and remove_file methods accept only URL-safe namespace and opaque-key segments. The settled layout is <root>/<namespace>/objects/<first-two-key-characters>/<key>. Original filenames are metadata only and never become paths.

Vespera provides typed multipart extraction and per-field byte limits:

use vespera::multipart::{FieldData, TypedMultipart};

#[derive(vespera::Multipart, vespera::Schema)]
#[try_from_multipart(strict)]
struct UploadRequest {
    #[form_data(limit = "10MiB")]
    file: FieldData<vespera::tempfile::NamedTempFile>,
}

The plugin-declared request budget only prevents Axum's 2 MiB default from rejecting a legitimate upload before extraction. It is not validation. Keep a hard #[form_data(limit = ...)], enforce any lower typed setting in the handler, and determine MIME from the bytes rather than trusting the multipart header. media accepts only JPEG, PNG, GIF, and WebP signatures.

public_api_routes contains suffixes, so "/file" above resolves to the exact path /api/media/file. The authentication model is path-based, not method-based: the macro rejects "/" because making the namespace root public would expose every method mounted there. Dynamic segments, wildcards, queries, fragments, traversal, and trailing slashes are rejected too. Put an opaque reference in the query string when a public lookup is needed. The media plugin uses media:<32-lowercase-hex> and content must persist that reference, never a storage path or serving URL.

Typed plugin settings

Declare a settings struct before yeollin_plugin! and pass its type to the settings field:

use serde::{Deserialize, Serialize};
use vespera::Schema;

#[derive(Debug, Default, Serialize, Deserialize, Schema)]
#[serde(rename_all = "camelCase")]
pub struct MemoSettings {
    pub compact_mode: bool,
    pub footer_note: String,
}

yeollin_plugin::yeollin_plugin! {
    name: "example-memo-plugin",
    settings: MemoSettings,
}

The macro adds administrator-only GET and PUT handlers at /api/example-memo-plugin/settings. Values are deserialized through MemoSettings before they are written, and the framework stores exactly one JSON row for the plugin. Existing values survive restarts; Default seeds a new installation.

Plugin handlers can read the same value without parsing untyped JSON:

use axum::Extension;
use yeollin_plugin::SettingsStore;

async fn render(Extension(settings): Extension<SettingsStore>) -> anyhow::Result<()> {
    let settings = settings
        .get::<MemoSettings>("example-memo-plugin")
        .await?;
    // use settings.compact_mode
    Ok(())
}

Prebuild serializes Vespera's schema and Default into plugins.json and generates /<plugin>/settings. To own the presentation, add app/settings/page.tsx; that exact file replaces the generated page while the typed API and persistence stay unchanged. Do not place the override under a route group.

Typed content collections

A collection declares its plugin-specific fields as an ordinary Rust type. The framework adds the shared content envelope: ID, title, slug, draft/published status, author, created/updated timestamps, and the optional published timestamp.

use serde::{Deserialize, Serialize};
use vespera::Schema;
use yeollin_plugin::ContentFields;

#[derive(Clone, Debug, Default, Serialize, Deserialize, Schema)]
#[serde(rename_all = "camelCase")]
pub struct PageFields {
    pub excerpt: String,
    pub body: String,
    pub hero_image: Option<String>,
}

impl ContentFields for PageFields {
    fn validate(&self) -> Result<(), String> {
        if self.body.trim().is_empty() {
            return Err("body must not be empty".to_string());
        }
        Ok(())
    }
}

yeollin_plugin::yeollin_content_collection! {
    module: pages,
    name: "pages",
    label: "Pages",
    fields: crate::PageFields,
    order: 30,
}

yeollin_plugin::yeollin_plugin! {
    name: "content",
    frontend: false,
    collections: [pages::registration()],
}

Collection names are stable lowercase kebab-case identifiers. The macro emits concrete request, response, query, and list types for PageFields; there is no untyped route payload. Default seeds the generated editor, Schema describes its controls at build time, and ContentFields::validate runs before every create or field update. Keep validation deterministic and free of I/O.

The example above owns these routes:

Method Path Access Purpose
GET, POST /api/content/pages administrator Paginated list and draft creation.
GET, PUT, DELETE /api/content/pages/{id} administrator Read, update, or delete one entry.
POST /api/content/pages/{id}/publish administrator Publish a draft.
POST /api/content/pages/{id}/unpublish administrator Return published content to draft.
GET /api/content/pages/published?slug=about public Fetch one published entry by its exact slug.

The public route is fixed and whole-path exact. The slug remains a query value, so no dynamic route has to be exempted from authentication. Drafts return 404 there. Management handlers always call require_role("admin"); authentication alone does not authorize them.

All collections share the framework-owned content_entries table. Slugs are unique within a collection, while collection-specific fields are serialized from and back into their concrete Rust type. Create, update, publish, unpublish, and delete emit audit-enabled content.* events in the same transaction as the write. The original author is retained across later edits.

Prebuild exports each field schema and writes a collection hub plus a reusable list/editor page under /<plugin>/<collection>. It never fetches during SSG. Primitive fields get native form controls; object and array fields get a JSON editor. A plugin can therefore set frontend: false and still ship the generated content UI. Because persistence is shared, registering any collection requires the host application to configure a database.

Content may store media references such as media:0123456789abcdef0123456789abcdef. Validate the canonical reference in the field type and store that reference, not /api/media/file?..., an original filename, or a runtime storage path.

Full-text content search

The search plugin provides ranked content search without an external service. It requires SQLite with FTS5 enabled, creates its ordinary projection table and FTS5 virtual table during plugin initialization, and backfills every existing content entry on startup. Content is the first indexed subject; each result therefore carries subject: "content", the collection and content ID, status, updated time, and the collection editor URL.

The searchable document contains the title with a higher ranking weight, plus the collection, slug, author, and every scalar value recursively found in the typed fields JSON. Object keys and null values are not indexed. Draft and published entries both belong to the administrator index, so the search handler calls require_role("admin") and the frontend handles a bare 403.

Create, update, publish, unpublish, and delete are applied by an Inline subscriber using the same DatabaseTransaction as the content write. If the projection or FTS trigger fails, the content write and its event roll back too. The startup synchronization repairs a missing initial projection when search is installed after content already exists.

GET /api/search accepts these query parameters:

Parameter Meaning
q Required search text, at most 200 characters and 12 alphanumeric terms.
page, pageSize Pagination; page size is clamped to 1–50.
collection Optional exact lowercase kebab-case collection name.
status Optional exact draft or published filter.

Punctuation in q becomes a separator, while word-like FTS operators are quoted as literal terms instead of syntax. Every term becomes a quoted prefix and terms are combined with AND, so launch check safely matches documents containing prefixes of both words. The API returns title-weighted relevance order and a plain-text FTS snippet; clients must still render all returned content as text rather than trusted HTML.

Typed events and subscribers

An event is a serializable Rust type with one stable name. The action and event must use the same EventTransaction; there is no non-transactional emit API:

use axum::{Extension, Json};
use sea_orm::{ActiveModelTrait, Set};
use serde::Serialize;
use yeollin_plugin::{Event, EventBus, PluginError};

#[derive(Serialize)]
struct MemoCreated {
    id: i32,
}

impl Event for MemoCreated {
    const NAME: &'static str = "memo.created";
    const AUDIT: bool = true;
}

async fn create(
    Extension(events): Extension<EventBus>,
) -> Result<Json<i32>, PluginError> {
    let mut transaction = events.begin().await?;
    let memo = memo::ActiveModel {
        title: Set("Typed event".to_string()),
        ..Default::default()
    }
    .insert(transaction.connection())
    .await?;

    transaction.emit(&MemoCreated { id: memo.id }).await?;
    transaction.commit().await?;
    Ok(Json(memo.id))
}

emit inserts the JSON envelope into the framework's events table, then runs matching Inline subscribers in that same transaction. commit commits the action and event together before it wakes Deferred delivery. The background drainer also polls, so a committed row is recovered after a process exits between commit and wake.

Event::AUDIT defaults to false. Set it to true only when the payload is useful as administrator-facing history. The audit-log plugin reads those rows directly from the outbox, newest first; it does not copy them into plugin-owned storage. Audit payloads therefore need the same deliberate data-minimisation as logs: do not emit passwords, tokens, secret values, or unnecessary personal data. Its retention job deletes only processed audit rows, never pending Deferred delivery state.

Register subscribers with an exact event-name filter. An empty list observes all events:

use sea_orm::{DatabaseConnection, DatabaseTransaction};
use yeollin_plugin::{
    EventEnvelope, InlineSubscriberFuture, SubscriberRegistration,
};

fn record_projection(
    event: EventEnvelope,
    transaction: &DatabaseTransaction,
) -> InlineSubscriberFuture<'_> {
    Box::pin(async move {
        // Write through `transaction`; return an error to abort the action.
        let _ = (event, transaction);
        Ok(())
    })
}

async fn notify_external_system(
    event: EventEnvelope,
    db: DatabaseConnection,
) -> anyhow::Result<()> {
    // Network and filesystem work belongs here, after commit.
    let _ = (event, db);
    Ok(())
}

yeollin_plugin::yeollin_plugin! {
    name: "reporting",
    subscribers: [
        SubscriberRegistration::inline(
            "projection",
            ["memo.created"],
            record_projection,
        ),
        SubscriberRegistration::deferred(
            "external-notification",
            ["memo.created", "memo.updated"],
            notify_external_system,
        ),
    ],
}

Inline subscribers are intentionally narrow: they may only write to the same database through the supplied transaction. They must not access the network or filesystem, and nested event emission is rejected. Any Inline error makes the event transaction uncommittable and rolls it back. Deferred delivery is at-least-once, so Deferred handlers must be idempotent; a crash after the handler succeeds but before the outbox row is marked can deliver it again.

Signed webhooks

The webhooks plugin is the standard external Deferred subscriber. Its administrator page at /webhooks configures endpoints and shows per-endpoint delivery history. The API lives at /api/webhooks; signing secrets are accepted on create or replacement but are never serialized back to a caller.

Each matching endpoint receives the serialized EventEnvelope as the exact request body with these headers:

Header Value
Content-Type application/json
X-Yeollin-Event Exact event name, such as content.published
X-Yeollin-Delivery Stable per-endpoint delivery ID
X-Yeollin-Signature sha256=<lowercase hex HMAC-SHA256>

Verify the signature over the raw request bytes before parsing JSON. The conceptual check is HMAC-SHA256(endpoint_secret, raw_body). Compare the supplied and calculated digests in constant time. Do not calculate the digest over re-serialized JSON, because whitespace and object-key ordering can change the byte sequence.

Endpoint event filters use whole-name exact matching. An empty filter receives every event; content does not match content.published. A successful endpoint is not sent again when another endpoint needs a retry. Failures retry through the core outbox with delays of 1, 2, 4, 8 seconds and so on, capped at five minutes. Each endpoint stops after five attempts and enters dead_letter. Administrators can explicitly retry a dead letter while its immutable source event still exists.

Every delivery has its own timeout (1–30 seconds), accepts only HTTP or HTTPS, and refuses redirects. Before connecting, the plugin resolves the hostname, blocks private, loopback, and link-local addresses by default, and pins the validated DNS answers into the HTTP client to prevent DNS rebinding between the check and connection. Unspecified, multicast, and IPv6 unique-local addresses are refused too. allowPrivateNetworks is an explicit per-endpoint opt-out for a trusted internal receiver; enabling it removes the address-range guard, not the signature, timeout, or redirect protections.

The management routes are administrator-only:

Method Path Purpose
GET, POST /api/webhooks List endpoints or create one.
PUT, DELETE /api/webhooks/{id} Replace or delete endpoint configuration.
GET /api/webhooks/deliveries Paginated history with endpoint/status filters.
POST /api/webhooks/deliveries/{id}/retry Reset a dead letter and requeue its source event.

Treat event payloads as a public contract with the receiving system. Never put passwords, tokens, signing secrets, or unnecessary personal data in an event.

API routes: how URLs are derived

Every plugin API lives under /api/<base>:

  1. <base> comes from the plugin declaration ??the name, or api_base when you set it. /api is always prepended and must not appear in api_base.
  2. The module path under src/routes/ is appended to that namespace.
  3. The macro's path argument is appended last.

The frontend already derives its pages from the same name, so one declaration gives /<name> for pages and /api/<name> for the API.

yeollin_plugin! { name: "media-library" }
// pages at /media-library, API under /api/media-library

Set api_base only when the URL namespace should differ from the name:

yeollin_plugin! { name: "reporting-suite", api_base: "reports" }  // -> /api/reports

Underscores become hyphens, because - is the URL convention: media_library and media-library both produce /api/media-library.

Placing handlers

Handlers in src/routes/mod.rs sit at the namespace root, which is usually what you want. A file adds its own segment.

For a plugin whose base resolves to /api/example-plugin:

Handler location path URL
src/routes/mod.rs omitted /api/example-plugin
src/routes/mod.rs /{id} /api/example-plugin/{id}
src/routes/items.rs / /api/example-plugin/items/
src/routes/items.rs /{id} /api/example-plugin/items/{id}

example-memo-plugin keeps all five CRUD handlers in src/routes/mod.rs, so they land on /api/example-memo-plugin and /api/example-memo-plugin/{id}.

Do not nest a src/routes/api/<name>/ directory. The namespace is already prepended, so that would produce /api/<base>/api/<name>/....

Because the URL comes from the declaration rather than the file tree, moving a handler between files does not change its published endpoint.

A handler

Request and response bodies derive vespera::Schema so they appear in the generated OpenAPI document, alongside serde:

#[derive(Serialize, Schema)]
#[serde(rename_all = "camelCase")]
pub struct MemoResponse {
    pub id: i32,
    pub title: String,
    pub content: String,
    pub created_at: String,
    pub updated_at: String,
}

/// List all memos
#[vespera::route(get, tags = ["memo"])]
pub async fn list_memos(
    Extension(db): Extension<DatabaseConnection>,
) -> Result<Json<ListMemosResponse>, (StatusCode, Json<ErrorResponse>)> {
    let memos = memo::Entity::find()
        .order_by(memo::Column::CreatedAt, Order::Desc)
        .all(&db)
        .await
        .map_err(/* ... */)?;
    // ...
}

The database connection arrives as an Axum Extension, installed by the runtime when the application is configured with a database. tags groups the operation in the OpenAPI document. The doc comment above the handler becomes its summary.

Do not block inside an async handler.

Guarding routes by role

The auth middleware establishes who is calling. It does not decide what they may do, so a protected route is reachable by every signed-in account until the handler says otherwise. Ask for the role you need:

use yeollin_plugin::{Authorize, CurrentUser, PluginError};

/// Remove a memo. Administrators only.
#[vespera::route(delete, path = "/{id}", tags = ["memo"])]
pub async fn delete_memo(
    Extension(db): Extension<DatabaseConnection>,
    Extension(current): Extension<CurrentUser>,
    Path(id): Path<i32>,
) -> Result<Json<DeleteResponse>, PluginError> {
    current.require_role("admin")?;
    // ...
}

require_any_role(&["admin", "editor"]) accepts several, and has_role returns a bool when you want to vary a response rather than refuse it.

Refusals are always a plain 403 FORBIDDEN, whichever role was missing, so probing endpoints cannot map out the role model. Matching is exact: Admin and admin are different roles.

Audit administrative and destructive endpoints for one of these calls. Forgetting one leaves the endpoint open to any authenticated user, which no test will catch unless you write it. GET /api/auth/users in the auth plugin is a worked example.

A role check is not a sandbox. Plugins are statically linked and run with full process privileges, so this enforces user authorization, not isolation of plugin code.

Frontend pages

Pages live under app/ in the plugin crate and follow the App Router conventions. A directory becomes a route when it contains page.tsx.

app/
?��??� (memo)/
    ?��??� page.tsx
    ?��??� route.meta.json

A plugin's frontend URL prefix comes from the name field in yeollin_plugin!, not from the directory tree. Directories wrapped in parentheses are route groups: they organise files and contribute no URL segment. So example-memo-plugin's app/(memo)/page.tsx serves /example-memo-plugin, and an app/(memo)/archive/page.tsx would serve /example-memo-plugin/archive.

The macro resolves the directory as concat!(env!("CARGO_MANIFEST_DIR"), "/app"). With the flat layout that is /app, never /../app.

Pages are plain React. example-memo-plugin uses @devup-ui/react primitives and calls its own API with fetch:

'use client'

import { Box, Flex, Text, VStack } from '@devup-ui/react'

async function fetchMemoList(): Promise<Memo[]> {
  const response = await fetch('/api/example-memo-plugin')
  if (!response.ok) {
    throw new Error('Failed to fetch memos')
  }
  const data = await response.json()
  return data.memos || []
}

Typography tokens are heading, subheading, body, and label.

Do not put a next.config.* file in a plugin's app/: that marks the directory as a complete application rather than a set of pages to merge.

route.meta.json

Menu placement and access rules are declared in a route.meta.json sidecar next to page.tsx. There are no route.ts config files.

{
  "label": "Memo",
  "order": 20
}
Field Type Default Meaning
label string the last URL segment Display name in the menu.
icon string none Icon name carried into the menu entry.
order integer 50 Sort key. Lower sorts first.
access "authenticated" | "public" | "guest" "authenticated" Who may reach the page.
menu boolean true, or false for a dynamic route Whether the page appears in navigation.

The file is optional; omitting it takes every default.

Rules worth internalising:

  • access is the only way to make a page reachable without a session. Putting a page inside (public) or (guest) grants nothing at all.
  • menu affects navigation only, never authorization.
  • Unknown fields are rejected. A misspelled acess fails the build instead of silently leaving the route on its default.
  • Duplicate route paths fail the build, and the diagnostic names the plugin that claimed the path first.
  • menu: true on a route with a dynamic segment fails the build.
  • Every diagnostic is reported in one pass, sorted deterministically, so a build surfaces all broken sidecars at once.

apps/example-app has a working example at app/(public)/test/route.meta.json:

{
  "access": "public",
  "menu": false
}

Database models

Three pieces work together: vespertide.json configures the generator, models/*.json declare the tables, and migrations/ holds the generated migration files that run at startup.

vespertide.json

{
  "modelsDir": "models",
  "migrationsDir": "migrations",
  "tableNamingCase": "snake",
  "columnNamingCase": "snake",
  "modelFormat": "json",
  "migrationFormat": "json",
  "migrationFilenamePattern": "%04v_%m",
  "modelExportDir": "src/models",
  "seaorm": {
    "extraEnumDerives": ["vespera::Schema"],
    "extraModelDerives": [],
    "enumNamingCase": "camel"
  },
  "prefix": "memp_"
}

modelExportDir is where the generated sea-orm entities land, which is why src/models/memo.rs exists but is not written by hand. prefix namespaces the plugin's tables so two plugins can both own a table called memos without colliding. auth uses the prefix auth_.

The presence of this file is also what makes yeollin_plugin! generate an on_init that applies migrations.

models/memo.json

{
  "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json",
  "name": "memos",
  "description": "Memo storage for example-memo-plugin",
  "columns": [
    { "name": "id", "type": "integer", "nullable": false,
      "primary_key": { "auto_increment": true } },
    { "name": "title", "type": "text", "nullable": false },
    { "name": "content", "type": "text", "nullable": false },
    { "name": "created_at", "type": "timestamptz", "nullable": false,
      "default": "NOW()", "index": true },
    { "name": "updated_at", "type": "timestamptz", "nullable": false,
      "default": "NOW()" }
  ]
}

Add a constraints array for uniqueness. auth uses it to keep usernames and refresh-token hashes unique:

"constraints": [
  { "type": "unique", "name": "uq_auth_users_username", "columns": ["username"] }
]

migrations/

Migration files are numbered by the migrationFilenamePattern, so the memo plugin's first one is 0001_initial.vespertide.json. Each carries a version, a comment, a created_at, and a list of actions:

{
  "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/migration.schema.json",
  "actions": [
    {
      "type": "create_table",
      "table": "memos",
      "columns": [ /* ... */ ],
      "constraints": []
    }
  ],
  "comment": "Initial",
  "created_at": "2026-01-23T18:53:44Z",
  "version": 1
}

Migrations are committed to the repository. vespertide_migration! applies them during plugin initialisation, so a fresh SQLite file gets its schema on first run.

Re-export the entity from lib.rs so migrations and downstream crates can reach it:

pub use models::memo;

TypeScript setup

Each plugin carries a package.json and a tsconfig.json purely for editor and typecheck support. The plugin is not published as a Node package.

{
  "extends": "../../packages/app/tsconfig.json",
  "compilerOptions": {
    "paths": { "@/*": ["../../packages/app/src/*"] },
    "noEmit": true
  },
  "include": ["app/**/*.ts", "app/**/*.tsx"],
  "exclude": ["node_modules"]
}

Run bun install once from the repository root, then typecheck the plugin with:

cd plugins/example-memo-plugin
bun x tsc --noEmit

Register the plugin in an application

cd apps/example-app
yeollin plugin add my-plugin
yeollin plugin doctor

Cargo resolves the dependency graph before proc macros run, so a plugin cannot be discovered at compile time. The host application must declare it in two places, and half a registration either fails to compile or silently omits the plugin's routes and migrations. plugin add makes both edits and is a no-op when re-run; plugin doctor reports a plugin declared on only one side and exits non-zero, so it can gate CI.

Both edits are shown below, since you will read them in existing applications.

First, the dependency in Cargo.toml:

[dependencies]
yeollin-app = { path = "../../crates/app" }
example-plugin = { path = "../../plugins/example-plugin" }
example-memo-plugin = { path = "../../plugins/example-memo-plugin" }
auth = { path = "../../plugins/auth" }

Then add its module path to the plugins list in yeollin_app!. Note the underscored form: the crate name example-memo-plugin is the module example_memo_plugin.

let app = yeollin::yeollin_app! {
    plugins: [auth, example_plugin, example_memo_plugin],
    openapi: "openapi.json",
    title: "Example CMS API",
    version: "1.0.0",
    docs_url: "/docs",
    redoc_url: "/redoc",
}
.host("0.0.0.0")
.port(port)
.with_auth(auth_config)
.with_database_url("sqlite://./db.sqlite?mode=rwc")
.build();

app.run().await

yeollin_app! does three things per plugin: calls register_plugin(metadata()), merges the plugin's Vespera routes into the application's OpenAPI document, and registers the host application's own app/ directory as a route source. Plugin order in the list is the order they are registered.

Prefer with_database_url over with_database. It connects lazily, which keeps metadata export free of side effects.

The metadata export contract

prebuild learns what a binary contains by running it once with YEOLLIN_EXPORT=1. There is a single export variable; the binary responds with one ExportEnvelope JSON document containing its schemaVersion, plugins, menus, and routes.

If you write your own main.rs, three rules apply:

  1. Emit exactly one ExportEnvelope on stdout and nothing else.
  2. Send all logs to stderr. apps/example-app does this with tracing_subscriber::fmt::layer().with_writer(std::io::stderr).
  3. Do no work before the export branch. YeollinApp::run handles the export first, before validating the JWT secret, before connecting to a database, and before running any on_init, so metadata export needs no deployment secrets.

Creating a new plugin

yeollin init my-plugin
cd apps/example-app
yeollin plugin add my-plugin
bun install                       # from the repository root

init scaffolds the crate and plugin add registers it. The workspace members list is globbed (crates/*, plugins/*, apps/*), so it needs no edit. Then fill in the crate:

  1. Edit src/lib.rs with your yeollin_plugin! metadata.
  2. Add handlers under src/routes/, choosing the module path that gives the URL base you want, and a role check on anything administrative or destructive.
  3. Add pages under app/(your-group)/, with a route.meta.json beside each page.tsx that needs a label, an order, or a non-default access rule.
  4. If you need tables, add vespertide.json, models/*.json, and the generated migrations/.
  5. Declare any JavaScript your pages import in the plugin's package.json dependencies. Prebuild merges those into the assembled app; devDependencies stay local to the crate.

plugins/example-plugin/ is a minimal reference and plugins/example-memo-plugin/ adds a database.

Anti-patterns

  • No api/ subdirectory. Cargo.toml goes at the crate root.
  • No concat!(env!("CARGO_MANIFEST_DIR"), "/../app"). Use /app.
  • No next.config.* inside a plugin's app/.
  • No route.ts config files. Use route.meta.json.
  • No relying on (public) or (guest) directory names for access control.
  • No hand-written menus.json. It is generated from page.tsx plus route.meta.json.
  • No blocking calls in async handlers.
  • No hardcoded filesystem paths. Use CARGO_MANIFEST_DIR.

Next

Before you deploy anything you built here, read Deployment and security.