Examples
Real-world patterns from production extensions and apps built with the Editor SDK.
Design System Editor
A MAIN_EDITOR placement that replaces the default content form with a custom design-token editor. Demonstrates updateField for granular field updates and useAutoResize for dynamic iframe height.
Reading Init Values and Setting Up State
The editor reads init.values once on mount, merges with defaults, and tracks form state locally:
import { useEffect, useState, useCallback } from 'react';
import { useEditor, useAutoResize } from '@eide/foir-editor-sdk';
function DesignSystemEditor() {
const { isReady, init, updateField, setDirty } = useEditor();
const [formValues, setFormValues] = useState<Record<string, unknown> | null>(null);
// Auto-resize the iframe as content grows
const containerRef = useAutoResize({ minHeight: 800 });
// Initialize form state from host values
useEffect(() => {
if (!isReady || !init) return;
const merged = mergeWithDefaults(init.values);
setFormValues(merged);
}, [isReady, init]);
// Sync dirty state with the host
const [isDirty, setIsDirtyLocal] = useState(false);
useEffect(() => {
setDirty(isDirty);
}, [isDirty, setDirty]);
if (!isReady || !formValues) return null;
return (
<div ref={containerRef} className="min-h-screen bg-background p-6">
{/* Editor UI */}
</div>
);
}Granular Field Updates with updateField
Instead of sending the entire form on every change, use updateField to send only the changed field:
const handleFieldChange = useCallback(
(field: string, value: unknown) => {
setFormValues((prev) => (prev ? { ...prev, [field]: value } : prev));
setIsDirtyLocal(true);
updateField(field, value);
},
[updateField]
);
// Multiple related fields can be updated individually
const handleCssUrlChange = useCallback(
(newCssUrl: string, cssFileKey: string) => {
setFormValues((prev) =>
prev ? { ...prev, cssUrl: newCssUrl, cssFileKey } : prev
);
setIsDirtyLocal(true);
updateField('cssUrl', newCssUrl);
updateField('cssFileKey', cssFileKey);
},
[updateField]
);Loading State
Return null while waiting for init. The Foir admin shows its own loading overlay until the editor sends its first resize or update-field message:
if (!isReady || !formValues) {
return null;
}Redirect Editor
A MAIN_EDITOR that uses requestOperation to trigger server-side operations from the UI. Demonstrates async operation calls and handling results.
Triggering Operations from the Editor
Use requestOperation to ask the platform to execute a registered operation:
import { useState, useCallback } from 'react';
import { useEditor, useAutoResize } from '@eide/foir-editor-sdk';
function RedirectEditor() {
const { isReady, init, updateField, setDirty, requestOperation } = useEditor();
const containerRef = useAutoResize({ minHeight: 500 });
const [syncLoading, setSyncLoading] = useState(false);
const handleTriggerSync = useCallback(async () => {
setSyncLoading(true);
try {
const result = await requestOperation('redirect-sync', {
source: 'manual',
});
if (result.success) {
const data = result.result as Record<string, unknown> | undefined;
console.log('Synced', data?.redirectCount, 'redirects');
} else {
console.error('Sync failed:', result.error?.message);
}
} catch (err) {
console.error('Operation error:', err);
} finally {
setSyncLoading(false);
}
}, [requestOperation]);
// ...
}Async dispatch with execution-id polling
For operations registered as mode: 'async', pass { mode: 'async' } to receive an execution id immediately and subscribe to terminal events instead of holding the connection:
const handleStartLongRun = useCallback(async () => {
const result = await requestOperation(
'full-export',
{ format: 'csv' },
{ mode: 'async' }
);
if (result.success) {
const { executionId } = result.result as { executionId: string };
// Subscribe to jobs:{executionId} via SSE for progress + terminal events,
// or query publicOperationExecution(id: ...) on a polling interval.
subscribeToExecution(executionId);
}
}, [requestOperation]);Fetching Status via Operations
Operations can also be used for read-only tasks. Fire them on mount for existing records:
useEffect(() => {
if (!isReady || !init || !init.recordId) return;
requestOperation('redirect-sync-status', {}).then(
(result) => {
if (result.success && result.result) {
const data = result.result as Record<string, unknown>;
setSyncStatus({
lastSyncedAt: (data.lastSyncAt as string) ?? null,
redirectCount: (data.redirectCount as number) ?? 0,
});
}
},
() => {
// Status check not available — show default state
}
);
}, [isReady, init, requestOperation]);Nested Field Paths
When your record has nested content (e.g., fields under a content key), use dot notation in updateField:
const handleChange = useCallback(
(field: string, value: unknown) => {
setFields((prev) => ({ ...prev, [field]: value }));
updateField(`content.${field}`, value);
setDirty(true);
},
[updateField, setDirty]
);Translate Editor
A SIDEBAR editor that reads selectedFieldKeys and localeContext to provide AI-powered translation for selected fields.
Sidebar Mode and Locale Context
Sidebar editors receive additional context about which fields the user has selected and what locale they are working in:
import { useEditor, useAutoResize } from '@eide/foir-editor-sdk';
function TranslateEditor() {
const {
isReady,
init,
configMode,
selectedFieldKeys,
selectableFields,
localeContext,
updateField,
} = useEditor();
const containerRef = useAutoResize({ minHeight: 200 });
if (!isReady || !init) {
return (
<div ref={containerRef} className="p-6 text-sm text-muted-foreground">
Loading...
</div>
);
}
if (configMode !== 'sidebar') {
return (
<div ref={containerRef} className="p-4 text-sm text-muted-foreground">
This extension only works as a sidebar panel.
</div>
);
}
if (!localeContext) {
return (
<div ref={containerRef} className="p-4 text-sm text-muted-foreground">
No locale context available. Enable locale support to use this extension.
</div>
);
}
return (
<div ref={containerRef}>
<TranslatePanel
sourceLocale={localeContext.sourceLocale}
currentLocale={localeContext.currentLocale}
selectedFieldKeys={selectedFieldKeys}
selectableFields={selectableFields}
values={init.values}
onUpdateField={(path, value) => updateField(path, value)}
/>
</div>
);
}Server-Side: Hono API with Scoped-Token Verification
App backends use scoped tokens to verify that an incoming request came from the Foir platform. The middleware below verifies the token, parses the subject, and exposes both on the request context for downstream handlers.
Middleware setup
import { Hono } from 'hono';
import {
verifyScopedToken,
parseSubject,
parseContextHeader,
type ScopedTokenClaims,
type ScopedTokenSubject,
} from '@eide/foir-editor-sdk/server';
const JWKS_URL = 'https://api.foir.io/.well-known/jwks.json';
type Env = {
Variables: {
claims: ScopedTokenClaims;
subject: ScopedTokenSubject;
ctx: Record<string, string>;
};
};
const app = new Hono<Env>();
async function verifyDispatch(c, next) {
const token = c.req.header('x-foir-token');
if (!token) return c.json({ error: 'missing token' }, 401);
try {
const claims = await verifyScopedToken(token, JWKS_URL);
c.set('claims', claims);
c.set('subject', parseSubject(claims.sub));
c.set('ctx', parseContextHeader(c.req.header('x-foir-context')));
await next();
} catch (err) {
return c.json({ error: 'invalid token', detail: String(err) }, 401);
}
}
app.post('/operations/*', verifyDispatch);
app.post('/operations/process', async (c) => {
const claims = c.get('claims');
const { projectId, appName } = c.get('subject');
const ctx = c.get('ctx');
const payload = await c.req.json();
console.log('operation:', payload.operationKey);
console.log('triggered by:', ctx.triggered_by, 'execution:', ctx.execution_id);
console.log('caps:', claims.cap);
// Process the operation...
return c.json({
success: true,
result: { processed: true },
metadata: { durationMs: 150 },
});
});Server-Side: Async Operation with Callback Client
This pattern handles a long-running operation where the platform dispatches with mode: 'async', the endpoint returns 202 immediately, and a background runner reports progress and the terminal result via the callback client.
import { Hono } from 'hono';
import { Queue } from 'bullmq';
import {
verifyScopedToken,
createCallbackClient,
type OperationPayload,
} from '@eide/foir-editor-sdk/server';
const JWKS_URL = 'https://api.foir.io/.well-known/jwks.json';
const queue = new Queue('jobs', { connection: { host: 'redis', port: 6379 } });
const app = new Hono();
app.post('/operations/full-export', async (c) => {
// 1. Verify the dispatch token
const token = c.req.header('x-foir-token');
if (!token) return c.json({ error: 'missing token' }, 401);
await verifyScopedToken(token, JWKS_URL);
const payload = (await c.req.json()) as OperationPayload;
if (!payload.callback || !payload.executionId) {
return c.json({ error: 'expected async dispatch' }, 400);
}
// 2. Queue the work in our own durable runtime
await queue.add('full-export', {
executionId: payload.executionId,
callback: payload.callback,
input: payload.input,
});
// 3. Ack immediately
return c.json({ accepted: true }, 202);
});
// Worker side (separate process / queue consumer)
async function processFullExport(job) {
const { executionId, callback, input } = job.data;
const cb = createCallbackClient(callback, {
executionId,
signingSecret: process.env.FOIR_SIGNING_SECRET!,
});
const items = await fetchAllItems();
for (let i = 0; i < items.length; i++) {
if (i % 100 === 0) {
const result = await cb.progress({
pct: Math.floor((i / items.length) * 100),
message: `Exported ${i} of ${items.length}`,
});
if (result.cancelled) {
return; // admin cancelled — stop work
}
}
await exportItem(items[i]);
}
await cb.complete({ exportedCount: items.length });
}The callback client signs each request with FOIR_SIGNING_SECRET (HMAC-SHA256 of the body) and bears the scoped token from payload.callback.token in Authorization. The platform verifies both before accepting the callback.
Server-Side: Inbound Webhook from a Third Party
When an app receives a webhook from an external service like Shopify, the verification path is different — the source is signing, not the platform. Use verifyWebhookSignature with the source’s webhook secret.
import { Hono } from 'hono';
import { verifyWebhookSignature } from '@eide/foir-editor-sdk/server';
const app = new Hono();
app.post('/webhooks/shopify', async (c) => {
// Shopify signs with its own HMAC; verify against the per-shop secret
const body = await c.req.text();
const signature = c.req.header('x-shopify-hmac-sha256') ?? '';
const valid = await verifyWebhookSignature(
body,
signature,
process.env.SHOPIFY_WEBHOOK_SECRET!,
);
if (!valid) {
return c.text('Invalid signature', 401);
}
// The platform's gateway also dispatches inbound webhooks with a scoped
// token in X-Foir-Token, since gateway-to-middleware is a platform call.
// For Shopify-style apps, you typically verify both: the source HMAC
// (Shopify) AND the gateway's scoped token (Foir).
const payload = JSON.parse(body);
// Process webhook...
});Server-Side: Data Ingestion
Push data into the platform from external sources using client.ingest():
import { createServerClient } from '@eide/foir-editor-sdk/server';
async function syncProducts(apiKey: string, products: any[]) {
const client = createServerClient({
baseUrl: process.env.PLATFORM_API_URL!,
apiKey,
configKey: 'shopify',
});
const items = products.map((product) => ({
resource: 'record' as const,
event: 'upsert' as const,
modelKey: 'product',
externalId: String(product.id),
naturalKey: product.handle,
data: {
title: product.title,
description: product.body_html,
vendor: product.vendor,
price: product.variants?.[0]?.price,
},
}));
// Ingest in batches
const BATCH_SIZE = 100;
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const batch = items.slice(i, i + BATCH_SIZE);
const result = await client.ingest(batch);
console.log(`Batch ${i / BATCH_SIZE + 1}: ${result.processed} processed, ${result.errors} errors`);
}
}