Jiffoo Docs
Developer

Plugin Development

Practical guide for building new plugins on the current production plugin runtime

Plugin Development (Current Runtime)

This guide is for building new plugins on top of the current, already-implemented plugin runtime.

The platform runtime contract is stable in these areas:

  • ZIP install and manifest validation
  • runtime routing through /api/v1/shop/plugins/{slug}/api/*
  • multi-instance routing via installationId or instanceKey
  • lifecycle hook callbacks
  • shared manifest contract between platform and SDK

Use this guide as the practical implementation reference.

1. Minimal Manifest Templates

All plugins use manifest.json with schemaVersion: 1.

{
  "schemaVersion": 1,
  "slug": "my-payment-plugin",
  "name": "My Payment Plugin",
  "version": "1.0.0",
  "description": "A custom payment integration",
  "runtimeType": "external-http",
  "externalBaseUrl": "https://my-payment-plugin.example.com",
  "permissions": ["orders:read", "orders:write"],
  "category": "payment",
  "capabilities": ["payment.process", "payment.refund"],
  "lifecycle": {
    "onInstall": true,
    "onEnable": true,
    "onDisable": true,
    "onUninstall": true,
    "onUpgrade": true
  },
  "configSchema": {
    "apiKey": {
      "type": "string",
      "label": "API Key",
      "required": true,
      "secret": true
    }
  }
}

Internal Fastify plugin (platform-maintained)

{
  "schemaVersion": 1,
  "slug": "my-internal-plugin",
  "name": "My Internal Plugin",
  "version": "1.0.0",
  "description": "Internal plugin running in isolated Fastify runtime",
  "runtimeType": "internal-fastify",
  "entryModule": "dist/index.js",
  "permissions": [],
  "category": "integration",
  "capabilities": ["api.read"]
}

Required validation rules

  • slug must match ^[a-z][a-z0-9-]{0,30}[a-z0-9]$
  • version and sdkVersion must be strict semver (MAJOR.MINOR.PATCH)
  • runtimeType="external-http" requires externalBaseUrl
  • runtimeType="internal-fastify" requires entryModule
  • themeExtensions.embeds[].targetPosition must be head-end or body-end

2. Gateway Contract (Must Follow)

Your plugin is called through platform gateway, not directly by frontend clients.

  • Gateway path: /api/v1/shop/plugins/{slug}/api/*
  • Instance selection:
    • ?installation=default (by instanceKey)
    • ?installationId=<id> (by instance id)
    • default fallback: installation=default
  • Health probe path from platform: /api/v1/shop/plugins/{slug}/health

Platform injects context headers before forwarding:

  • x-plugin-slug
  • x-installation-id
  • x-installation-key
  • x-platform-id
  • x-user-id (empty string for anonymous, NOT "anonymous")
  • x-user-role (guest / customer / admin)
  • x-request-id
  • x-caller (shop / admin / theme-app / api-internal / unknown)
  • x-platform-signature
  • x-platform-timestamp
  • x-platform-version
  • x-platform-api-base-url
  • x-locale
  • x-plugin-config (Base64URL-encoded JSON of instance config)

Important:

  • Do not trust incoming spoofed context headers from clients
  • For external plugins, verify x-platform-signature + x-platform-timestamp
  • Treat installationId as the primary tenant boundary in plugin logic

3. Lifecycle Implementation

Lifecycle declaration lives in manifest.json under lifecycle.

Supported hooks:

  • onInstall
  • onEnable
  • onDisable
  • onUninstall
  • onUpgrade

For external-http, expose lifecycle endpoint:

  • POST /__lifecycle/:hookName

Request body shape:

{
  "installationId": "...",
  "pluginSlug": "my-plugin",
  "instanceKey": "default",
  "config": {}
}

Suggested behavior:

  • Keep hooks idempotent
  • Keep hooks short and retry-safe
  • Never assume default instance only; always use installationId

4. Config and Multi-Instance Development Rules

The platform creates a default instance (instanceKey=default) when plugin ZIP install succeeds.

  • Default instance may start disabled if required config is missing
  • Additional instances can be created with different instanceKey
  • Every runtime request is resolved to one concrete installationId

Your plugin code should:

  • read config per request/instance (not global singleton config)
  • isolate data by installationId
  • include installationId in logs and error reports
  • treat instanceKey as human-readable label, not security boundary

5. Local Development and Packaging

Install SDK:

npm install jiffoo-plugin-sdk

Scaffold a new project:

npx jiffoo-plugin init my-plugin

Validate/build/package:

npx jiffoo-plugin validate
npx jiffoo-plugin build --output dist
npx jiffoo-plugin pack --output my-plugin.zip

Useful pack flags:

  • --no-build
  • --no-validate
  • --include-source

6. Minimal Implementation Example (external-http)

import express from 'express';
import {
  createContextMiddleware,
  createSignatureMiddleware,
  defineLifecycleHooks,
  definePlugin,
} from 'jiffoo-plugin-sdk';

const app = express();
app.use(express.json());

const plugin = definePlugin({
  schemaVersion: 1,
  slug: 'my-payment-plugin',
  name: 'My Payment Plugin',
  version: '1.0.0',
  description: 'A custom payment integration',
  runtimeType: 'external-http',
  externalBaseUrl: process.env.PLUGIN_BASE_URL || 'http://127.0.0.1:4100',
  permissions: [],
  category: 'payment',
  capabilities: ['payment.process'],
  lifecycle: { onInstall: true, onEnable: true, onDisable: true },
});

app.get('/health', (_req, res) => {
  res.json({ status: 'ok', slug: plugin.getManifest().slug, version: plugin.getManifest().version });
});

app.use('/api', createContextMiddleware());
app.use('/api', createSignatureMiddleware(process.env.PLUGIN_SECRET || 'dev-secret'));

app.post('/api/process-payment', async (req, res) => {
  const ctx = (req as any).pluginContext;
  res.json({
    success: true,
    installationId: ctx?.installationId,
    requestId: ctx?.requestId,
  });
});

const lifecycle = defineLifecycleHooks({
  onInstall: async (ctx) => {
    console.log('onInstall', ctx.installationId);
  },
  onEnable: async (ctx) => {
    console.log('onEnable', ctx.installationId);
  },
  onDisable: async (ctx) => {
    console.log('onDisable', ctx.installationId);
  },
});

app.post('/__lifecycle/:hookName', lifecycle.httpHandler());

const port = Number(process.env.PORT || 4100);
app.listen(port, () => {
  console.log(`Plugin listening on :${port}`);
});

7. Debug and Acceptance Checklist

Before uploading ZIP to Admin:

  • manifest.json validates successfully
  • LICENSE exists
  • health endpoint returns 200
  • lifecycle endpoint is reachable for declared hooks
  • signature verification is enabled for gateway endpoints
  • logs include installationId and requestId

After installation in Admin:

  • plugin package appears in installed list
  • default instance is created
  • plugin gateway call succeeds:
    • GET /api/v1/shop/plugins/{slug}/api/...?...
  • disable instance returns gateway soft block (404)
  • re-enable instance resumes successful routing

8. Internal Fastify Appendix (Platform-Maintained)

Use internal-fastify only when plugin code is trusted and packaged with a runtime entry that can be loaded by the API runtime.

Runtime expectations

  • manifest.json.runtimeType must be internal-fastify
  • manifest.json.entryModule must point to a built JS file inside the plugin package
  • the entry module must export a Fastify plugin function (default export or module export)
  • the plugin receives instance config as registration options

Minimal internal plugin entry

// dist/index.js
async function myInternalPlugin(fastify, options) {
  fastify.get('/api/ping', async () => {
    return {
      ok: true,
      source: 'internal-fastify',
      installationId: options.installationId || null,
    };
  });

  fastify.get('/health', async () => {
    return { status: 'ok' };
  });
}

export default myInternalPlugin;

Internal plugin packaging rules

  • always upload built artifacts, not TypeScript source only
  • keep entryModule stable across versions when possible
  • keep startup fast; slow register/ready can disable instance on warm failure
  • keep implementation stateless per request; do not cache mutable global instance config

9. Concept Map: Business Plugins vs Themes vs Runtime Types

This section resolves the most common confusion.

A. Extension kinds (installer-level category)

The installer supports these extension kinds:

  • plugin
  • theme-shop
  • theme-admin
  • theme-app-shop
  • theme-app-admin
  • bundle

Think of this as the package family selected at install time (POST /api/v1/admin/extensions/:kind/install).

B. Business plugin runtime types (plugin-internal mode)

Inside the plugin extension kind, there are two runtime modes:

  • external-http
  • internal-fastify

So:

  • plugin is an extension kind
  • external-http / internal-fastify are runtime types inside that kind

C. Themes are separate from business plugins

  • Theme Pack (theme-shop, theme-admin) customizes storefront/admin presentation using static resources
  • Theme App (theme-app-shop, theme-app-admin) is an executable storefront/admin runtime
  • Neither Theme Pack nor Theme App is the same concept as business plugin runtime types

D. What is themeExtensions in plugin manifest?

themeExtensions does not turn a business plugin into a theme.

It means a business plugin can publish controlled UI extension points (app blocks / app embeds) that Theme runtime can render.

In short:

  • Theme owns page rendering model
  • Business plugin owns business capability/API
  • themeExtensions is the bridge from business plugin into theme-rendered UI slots

10. Should We Split Docs by Plugin Type?

No. Keep one primary guide and split by sections.

  • Most teams should follow external-http sections only
  • Platform maintainers can use the internal-fastify appendix
  • Both paths share the same manifest contract, gateway path, lifecycle model, and multi-instance rules

On this page