@struxa/extension-sdk
v1.0.1
Published
SDK for building Struxa extensions: manifest types, server entry helpers, the iframe host bridge, a preconfigured oRPC client, and React hooks.
Readme
@struxa/extension-sdk
SDK for building Struxa extensions. Provides manifest types and validation, the server-side defineExtension() helper, a preconfigured oRPC client, the iframe host bridge, and React hooks — everything an extension needs to integrate with the host panel.
npm install @struxa/extension-sdkExports
| Entry point | Contents |
|---|---|
| @struxa/extension-sdk | Manifest schema/types, permission constants, HOST_API_VERSION |
| @struxa/extension-sdk/server | defineExtension(), ExtensionContext, hook typings |
| @struxa/extension-sdk/client | createHostClient(), createBridge(), host bridge types |
| @struxa/extension-sdk/react | createExtension(), useHostBridge() |
Server entry (server/index.js)
The host loads server/index.js, calls mod.default.register(ctx), and mounts the returned router at ext.<id>.*. Use defineExtension() as the default export:
import { defineExtension } from "@struxa/extension-sdk/server";
export default defineExtension({
async register(ctx) {
ctx.hooks.on("server.created", async ({ serverId }) => {
ctx.logger.info("server created", { serverId });
});
const router = {
ping: ctx.procedures.protectedProcedure.handler(() => ({
ok: true,
timestamp: new Date().toISOString(),
})),
};
return { router };
},
});Context (ctx)
All host capabilities arrive through ctx — there is no other sanctioned import path from server/index.js:
| Field | Type | Requires permission |
|---|---|---|
| ctx.logger | { info, warn, error } | — |
| ctx.db | Drizzle handle (fenced to ext_<id>_* tables) | db:own |
| ctx.coreMeta.<entity> | { get, set } on the entity's metadata column | core.metadata:<entity> |
| ctx.settings | { get, set, all } scoped to approved key prefixes | settings:<prefix> |
| ctx.hooks | { on(event, handler) } | hook:<event> |
| ctx.procedures | { publicProcedure?, protectedProcedure?, adminProcedure? } | api:<level> |
| ctx.fieldOutputs | { set, clear } — publish computed values for named UI fields | output:<entity>:<field> |
Hook events
Subscribe via ctx.hooks.on(event, handler) with the matching hook:<event> permission:
| Event | Payload | Description |
|---|---|---|
| server.created | { serverId, userId } | A server was created |
| server.updated | { serverId, userId } | A server's config was updated |
| server.deleted | { serverId, userId } | A server was deleted |
| server.power | { serverId, action } | A power action was sent to a server |
| server.settings.saved | { serverId, key, value } | One of this extension's server settings was saved by the user |
| admin.settings.saved | { tabId, changes } | One of this extension's admin settings tabs was saved |
| node.created | { nodeId } | A node was created |
| node.updated | { nodeId } | A node was updated |
| node.deleted | { nodeId } | A node was deleted |
| user.created | { userId } | A user registered |
| user.updated | { userId } | A user's profile was updated |
| user.deleted | { userId } | A user was deleted |
server.settings.saved and admin.settings.saved are fired only to the extension whose settings were changed — other extensions do not receive these events.
ctx.fieldOutputs — requires output:<entity>:<field>
Publish a computed value for a named UI field. The host panel reads these values and displays them instead of the default (e.g. showing a Cloudflare-generated subdomain instead of the raw server IP).
// In register(), populate on startup from persisted data:
ctx.hooks.on("server.created", async ({ serverId }) => {
const subdomain = await provisionSubdomain(serverId);
ctx.fieldOutputs.set("server", "address", serverId, `${subdomain}:25565`);
});
// Clear when the server is deleted:
ctx.hooks.on("server.deleted", async ({ serverId }) => {
ctx.fieldOutputs.clear("server", "address", serverId);
});Values are in-memory — repopulate them in register() by reading from ctx.settings or ctx.coreMeta if you need them to survive restarts.
Currently overridable fields:
| Entity | Field | Where it appears |
|---|---|---|
| server | address | Server console page — address stat |
| server | sftp.host | Server settings page — SFTP section and sidebar |
React UI
createExtension(element)
Bootstraps a React app into #root. Wraps in an error boundary and removes the boot-status indicator.
// src/main.tsx
import { createExtension } from "@struxa/extension-sdk/react";
import { App } from "./App";
createExtension(<App />);useHostBridge()
Mounts the iframe bridge once, exposes the host context (theme, session, route params, translated messages), and returns navigation/toast/resize helpers. Auto-resize is on by default.
function App() {
const { context, navigate, toast } = useHostBridge();
return (
<button onClick={() => toast("success", "done!")}>
Hello, {context?.session.userId}
</button>
);
}bridge.t(key, params?) — translations
The bridge exposes a t() helper that resolves dot-notation keys from the extension's translated messages (pushed by the host on init from the messages manifest field):
const bridge = createBridge({ onInit: (ctx) => render(ctx) });
// After onInit fires:
bridge.t("nav.title") // → "My Extension"
bridge.t("msg.count", { n: 3 }) // → "3 items" (if messages has "msg.count": "{n} items")Falls back to the key itself if no translation is found. Supports {param} placeholder substitution.
oRPC client (createHostClient)
Same-origin, cookie-authenticated. Call your extension's procedures under client.ext["<your-id>"].*:
import { createHostClient } from "@struxa/extension-sdk/client";
const client = createHostClient();
const res = await client.ext["hello-world"].ping();Manifest (manifest.json)
Validated by manifestSchema (Zod). Full example:
{
"id": "hello-world",
"name": "Hello World",
"version": "1.0.0",
"struxaApi": "^1.0.0",
"permissions": [
"hook:server.created",
"api:protected",
"core.metadata:server",
"output:server:address",
"output:server:sftp.host"
],
"messages": {
"en": "messages/en.json",
"pl": "messages/pl.json"
},
"ui": {
"pages": [
{ "route": "/hello", "section": "panel", "label": "nav.hello", "icon": "Blocks" }
],
"slots": [
{ "slot": "server.stats.after", "widget": "/hello/stats-widget" }
],
"serverSettings": [
{
"key": "subdomain_prefix",
"label": "Subdomain prefix",
"description": "Prefix used when generating Cloudflare subdomains.",
"type": "text",
"placeholder": "mc"
},
{
"key": "enabled",
"label": "Enable automatic subdomains",
"type": "toggle"
}
],
"adminSettingsTabs": [
{
"id": "cloudflare",
"label": "Cloudflare",
"sections": [
{
"title": "API Credentials",
"description": "Connect your Cloudflare account.",
"fields": [
{ "key": "api_token", "label": "API Token", "type": "password" },
{ "key": "zone_id", "label": "Zone ID", "type": "text", "placeholder": "abc123…" },
{
"key": "plan",
"label": "Plan",
"type": "select",
"options": [
{ "value": "free", "label": "Free" },
{ "value": "pro", "label": "Pro" }
]
}
]
}
]
}
]
}
}Permission grammar
| Permission | Grants |
|---|---|
| db:own | Create and use ext_<id>_* tables |
| core.metadata:<entity> | Read/write the metadata JSON on server, node, or user |
| settings:<prefix> | Get/set settings keys under <prefix> |
| hook:<event> | Subscribe to a lifecycle event (e.g. hook:server.created) |
| api:public / api:protected / api:admin | Expose oRPC procedures at that auth level |
| output:<entity>:<field> | Publish a computed value for a named UI field (e.g. output:server:address) |
