@struxa/extension-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@struxa/extension-sdk",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "SDK for building Struxa extensions: manifest types, server entry helpers, the iframe host bridge, a preconfigured oRPC client, and React hooks.",
6
+ "license": "MIT",
7
+ "files": ["src", "README.md"],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/index.ts",
14
+ "default": "./src/index.ts"
15
+ },
16
+ "./server": {
17
+ "types": "./src/server.ts",
18
+ "default": "./src/server.ts"
19
+ },
20
+ "./client": {
21
+ "types": "./src/client.ts",
22
+ "default": "./src/client.ts"
23
+ },
24
+ "./react": {
25
+ "types": "./src/react.tsx",
26
+ "default": "./src/react.tsx"
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "@orpc/client": "^1.13.14",
31
+ "@orpc/server": "^1.13.14",
32
+ "drizzle-orm": "^0.45.1",
33
+ "zod": "^4.1.13"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=18",
37
+ "react-dom": ">=18"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "react": {
41
+ "optional": true
42
+ },
43
+ "react-dom": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@struxa/config": "workspace:*",
49
+ "@types/react": "^19.2.10",
50
+ "@types/react-dom": "^19.2.3",
51
+ "react": "^19.2.3",
52
+ "react-dom": "^19.2.3",
53
+ "typescript": "^6"
54
+ }
55
+ }
package/src/client.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Browser-side iframe bridge. Runs inside an extension's web bundle (the iframe)
3
+ * and talks to the Struxa host shell over `postMessage`. Keeps extension UIs
4
+ * visually and behaviorally consistent with the host despite iframe isolation.
5
+ *
6
+ * The host injects the initial context (theme, locale, session summary) in the
7
+ * handshake; the extension can request height changes, navigate the host, and
8
+ * raise host toasts. For data, use {@link createHostClient} (or call `/api/rpc`
9
+ * directly with `credentials: "include"`) — the host session cookie authenticates
10
+ * you, no token plumbing needed.
11
+ */
12
+ import { createORPCClient } from "@orpc/client";
13
+ import { RPCLink } from "@orpc/client/fetch";
14
+
15
+ /**
16
+ * Preconfigured oRPC client for the host API. Same-origin, cookie-authed. Call
17
+ * your extension's procedures under `client.ext["<your-id>"].*` (and core
18
+ * procedures too, if you need them).
19
+ *
20
+ * @example
21
+ * const client = createHostClient();
22
+ * const res = await client.ext["react-demo"].ping();
23
+ */
24
+ export function createHostClient<TClient = Record<string, unknown>>(): TClient {
25
+ const origin = typeof location !== "undefined" ? location.origin : "";
26
+ const link = new RPCLink({
27
+ url: `${origin}/api/rpc`,
28
+ fetch: (url, options) => fetch(url, { ...options, credentials: "include" }),
29
+ });
30
+ return createORPCClient(link) as TClient;
31
+ }
32
+
33
+ export interface HostTheme {
34
+ /** "light" | "dark". */
35
+ mode: string;
36
+ /** CSS custom properties to mirror (e.g. --primary, --background). */
37
+ tokens: Record<string, string>;
38
+ }
39
+
40
+ export interface HostSessionSummary {
41
+ userId: string | null;
42
+ role: string | null;
43
+ locale: string;
44
+ }
45
+
46
+ export interface HostContext {
47
+ extId: string;
48
+ theme: HostTheme;
49
+ session: HostSessionSummary;
50
+ /** Route context the iframe was opened with (e.g. { serverId }). */
51
+ params: Record<string, string>;
52
+ }
53
+
54
+ type HostInbound =
55
+ | { type: "struxa:host:init"; context: HostContext }
56
+ | { type: "struxa:host:theme"; theme: HostTheme };
57
+
58
+ type IframeOutbound =
59
+ | { type: "struxa:iframe:ready" }
60
+ | { type: "struxa:iframe:height"; height: number }
61
+ | { type: "struxa:iframe:navigate"; route: string }
62
+ | {
63
+ type: "struxa:iframe:toast";
64
+ level: "success" | "error" | "info";
65
+ message: string;
66
+ };
67
+
68
+ const HOST_ORIGIN =
69
+ typeof location !== "undefined" ? location.origin : "*";
70
+
71
+ function post(msg: IframeOutbound): void {
72
+ if (typeof window !== "undefined" && window.parent) {
73
+ window.parent.postMessage(msg, HOST_ORIGIN);
74
+ }
75
+ }
76
+
77
+ export interface BridgeOptions {
78
+ /** Called once the host sends its init context. */
79
+ onInit?: (ctx: HostContext) => void;
80
+ /** Called whenever the host theme changes (e.g. user toggles dark mode). */
81
+ onThemeChange?: (theme: HostTheme) => void;
82
+ /** Auto-report document height to the host so the iframe resizes. */
83
+ autoResize?: boolean;
84
+ }
85
+
86
+ export interface Bridge {
87
+ context: HostContext | null;
88
+ navigate(route: string): void;
89
+ toast(level: "success" | "error" | "info", message: string): void;
90
+ reportHeight(height?: number): void;
91
+ destroy(): void;
92
+ }
93
+
94
+ function applyTheme(theme: HostTheme): void {
95
+ if (typeof document === "undefined") return;
96
+ const root = document.documentElement;
97
+ root.dataset.theme = theme.mode;
98
+ for (const [name, value] of Object.entries(theme.tokens)) {
99
+ root.style.setProperty(name, value);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Initialize the bridge. Call once on iframe boot.
105
+ */
106
+ export function createBridge(options: BridgeOptions = {}): Bridge {
107
+ const bridge: Bridge = {
108
+ context: null,
109
+ navigate: (route) => post({ type: "struxa:iframe:navigate", route }),
110
+ toast: (level, message) =>
111
+ post({ type: "struxa:iframe:toast", level, message }),
112
+ reportHeight: (height) =>
113
+ post({
114
+ type: "struxa:iframe:height",
115
+ height:
116
+ height ??
117
+ (typeof document !== "undefined"
118
+ ? document.documentElement.scrollHeight
119
+ : 0),
120
+ }),
121
+ destroy: () => {
122
+ if (typeof window !== "undefined") {
123
+ window.removeEventListener("message", onMessage);
124
+ }
125
+ resizeObserver?.disconnect();
126
+ },
127
+ };
128
+
129
+ function onMessage(event: MessageEvent<HostInbound>) {
130
+ if (event.origin !== HOST_ORIGIN) return;
131
+ const data = event.data;
132
+ if (!data || typeof data !== "object") return;
133
+ if (data.type === "struxa:host:init") {
134
+ bridge.context = data.context;
135
+ applyTheme(data.context.theme);
136
+ options.onInit?.(data.context);
137
+ } else if (data.type === "struxa:host:theme") {
138
+ if (bridge.context) bridge.context.theme = data.theme;
139
+ applyTheme(data.theme);
140
+ options.onThemeChange?.(data.theme);
141
+ }
142
+ }
143
+
144
+ let resizeObserver: ResizeObserver | undefined;
145
+
146
+ if (typeof window !== "undefined") {
147
+ window.addEventListener("message", onMessage);
148
+ post({ type: "struxa:iframe:ready" });
149
+
150
+ if (options.autoResize && typeof ResizeObserver !== "undefined") {
151
+ resizeObserver = new ResizeObserver(() => bridge.reportHeight());
152
+ resizeObserver.observe(document.documentElement);
153
+ }
154
+ }
155
+
156
+ return bridge;
157
+ }
package/src/index.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * @struxa/extension-sdk
5
+ *
6
+ * The contract that extension authors build against. Pure types + zod schemas,
7
+ * no runtime dependency on the host. The host (`@struxa/extension-host`) imports
8
+ * these to validate manifests and to type the scoped context it hands to
9
+ * extensions.
10
+ */
11
+
12
+ /** Core entities that expose a `metadata` JSON column extensions may write. */
13
+ export const CORE_META_ENTITIES = ["server", "node", "user"] as const;
14
+ export type CoreMetaEntity = (typeof CORE_META_ENTITIES)[number];
15
+
16
+ /** Canonical lifecycle events extensions can subscribe to via `hook:<event>`. */
17
+ export const HOOK_EVENTS = [
18
+ "server.created",
19
+ "server.updated",
20
+ "server.deleted",
21
+ "server.power",
22
+ "node.created",
23
+ "node.updated",
24
+ "node.deleted",
25
+ "user.created",
26
+ "user.updated",
27
+ "user.deleted",
28
+ ] as const;
29
+ export type HookEvent = (typeof HOOK_EVENTS)[number];
30
+
31
+ /** Max oRPC procedure level an extension may expose. */
32
+ export type ApiLevel = "public" | "protected" | "admin";
33
+
34
+ /**
35
+ * Permission grammar. Validated loosely as strings (so the registry can carry
36
+ * forward-compatible perms) but documented here:
37
+ * db:own create/use ext_<id>_* tables
38
+ * core.metadata:<entity> read/write the metadata JSON on a core entity
39
+ * settings:<prefix> get/set settings keys under <prefix>
40
+ * hook:<event> subscribe to a lifecycle event
41
+ * api:<level> expose procedures up to <level>
42
+ */
43
+ const permissionSchema = z
44
+ .string()
45
+ .regex(
46
+ /^(db:own|core\.metadata:(server|node|user)|settings:[a-zA-Z0-9_.*-]+|hook:[a-zA-Z0-9_.*-]+|api:(public|protected|admin))$/,
47
+ "invalid permission",
48
+ );
49
+
50
+ const navSection = z.enum(["panel", "admin"]);
51
+
52
+ const uiPageSchema = z.object({
53
+ /**
54
+ * Host route the page is mounted at, e.g. "/foo". Panel pages appear at this
55
+ * path (`/foo`); admin pages appear under /admin (`/admin/foo`). It is matched
56
+ * against the live route — a path the core site already defines (e.g.
57
+ * /servers, /account) always wins, so an extension cannot shadow core routes.
58
+ */
59
+ route: z.string().regex(/^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/),
60
+ /** Which sidebar/route-group the page lives under (controls auth gating). */
61
+ section: navSection,
62
+ /** i18n key (resolved under the ext.<id>.* namespace) for the nav label. */
63
+ label: z.string().min(1),
64
+ /** Allowlisted Lucide icon name. */
65
+ icon: z.string().min(1).optional(),
66
+ /** Path inside the extension web bundle to load; defaults to route tail. */
67
+ entry: z.string().optional(),
68
+ });
69
+
70
+ const uiSlotSchema = z.object({
71
+ /** Named anchor in a host page, e.g. "server.overview.after". */
72
+ slot: z.string().min(1),
73
+ /** Path inside the extension web bundle rendered into the slot iframe. */
74
+ widget: z.string().min(1),
75
+ });
76
+
77
+ export const manifestSchema = z.object({
78
+ id: z
79
+ .string()
80
+ .regex(/^[a-z][a-z0-9-]{1,62}$/, "id must be a lowercase slug"),
81
+ name: z.string().min(1).max(120),
82
+ version: z.string().min(1).max(32),
83
+ description: z.string().max(500).optional(),
84
+ author: z.string().max(120).optional(),
85
+ /** Host API semver range this extension was built against. */
86
+ struxaApi: z.string().min(1),
87
+ permissions: z.array(permissionSchema).default([]),
88
+ ui: z
89
+ .object({
90
+ pages: z.array(uiPageSchema).default([]),
91
+ slots: z.array(uiSlotSchema).default([]),
92
+ })
93
+ .optional(),
94
+ /** Map of locale -> relative path of a namespaced messages JSON file. */
95
+ messages: z.record(z.string(), z.string()).optional(),
96
+ });
97
+
98
+ export type Manifest = z.infer<typeof manifestSchema>;
99
+ export type ManifestUiPage = z.infer<typeof uiPageSchema>;
100
+ export type ManifestUiSlot = z.infer<typeof uiSlotSchema>;
101
+
102
+ /** The current host API version. Extensions declare a range against this. */
103
+ export const HOST_API_VERSION = "1.0.0";
package/src/react.tsx ADDED
@@ -0,0 +1,87 @@
1
+ import { Component, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ import { createBridge, type Bridge, type HostContext } from "./client";
5
+
6
+ class ExtensionErrorBoundary extends Component<
7
+ { children: ReactNode },
8
+ { error: Error | null }
9
+ > {
10
+ state: { error: Error | null } = { error: null };
11
+ static getDerivedStateFromError(error: Error) {
12
+ return { error };
13
+ }
14
+ componentDidCatch(error: Error, info: ErrorInfo) {
15
+ console.error("[struxa extension] render error", error, info);
16
+ }
17
+ render(): ReactNode {
18
+ if (this.state.error) {
19
+ return (
20
+ <div style={{ padding: 20, fontFamily: "monospace", fontSize: 13, color: "#f87171" }}>
21
+ <strong>Extension error</strong>
22
+ <pre style={{ marginTop: 8, whiteSpace: "pre-wrap" }}>
23
+ {this.state.error.message}
24
+ </pre>
25
+ </div>
26
+ );
27
+ }
28
+ return this.props.children;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Bootstraps a React extension into #root. Wraps the element in an error
34
+ * boundary that displays errors visibly instead of leaving a blank page.
35
+ * Removes the #boot-status loading indicator on mount.
36
+ *
37
+ * @example
38
+ * // src/main.tsx
39
+ * import { createExtension } from "@struxa/extension-sdk/react";
40
+ * import { App } from "./App";
41
+ * createExtension(<App />);
42
+ */
43
+ export function createExtension(element: ReactNode): void {
44
+ const root = document.getElementById("root");
45
+ if (!root) throw new Error("[struxa extension] #root element not found");
46
+ document.getElementById("boot-status")?.remove();
47
+ createRoot(root).render(<ExtensionErrorBoundary>{element}</ExtensionErrorBoundary>);
48
+ }
49
+
50
+ /**
51
+ * React adapter for the host iframe bridge. Mounts the bridge once, exposes the
52
+ * host context (theme/session/params) as state, and returns navigate/toast/
53
+ * resize helpers. Auto-resize is on by default.
54
+ *
55
+ * @example
56
+ * function App() {
57
+ * const { context, toast } = useHostBridge();
58
+ * return <button onClick={() => toast("success", "hi")}>{context?.session.userId}</button>;
59
+ * }
60
+ */
61
+ export function useHostBridge(options: { autoResize?: boolean } = {}) {
62
+ const { autoResize = true } = options;
63
+ const [context, setContext] = useState<HostContext | null>(null);
64
+ const ref = useRef<Bridge | null>(null);
65
+
66
+ useEffect(() => {
67
+ const bridge = createBridge({
68
+ autoResize,
69
+ onInit: (ctx) => setContext(ctx),
70
+ onThemeChange: (theme) =>
71
+ setContext((c) => (c ? { ...c, theme } : c)),
72
+ });
73
+ ref.current = bridge;
74
+ return () => bridge.destroy();
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, []);
77
+
78
+ return {
79
+ context,
80
+ navigate: (route: string) => ref.current?.navigate(route),
81
+ toast: (level: "success" | "error" | "info", message: string) =>
82
+ ref.current?.toast(level, message),
83
+ reportHeight: (height?: number) => ref.current?.reportHeight(height),
84
+ };
85
+ }
86
+
87
+ export type { HostContext } from "./client";
package/src/server.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { AnyRouter } from "@orpc/server";
2
+ import type { MySql2Database } from "drizzle-orm/mysql2";
3
+
4
+ import type { CoreMetaEntity, HookEvent } from "./index";
5
+
6
+ export type { HookEvent } from "./index";
7
+
8
+ /**
9
+ * Server-side contract for an extension's `server/index.js`. The host loads the
10
+ * module, builds a capability-scoped {@link ExtensionContext} from the manifest's
11
+ * approved permissions, and calls `register(ctx)`. Everything the extension may
12
+ * touch in the host arrives through `ctx` — there is no other sanctioned import.
13
+ */
14
+
15
+ /**
16
+ * oRPC builder type. Loosely typed: the concrete builder is bound to the host's
17
+ * private request Context, which the SDK can't reference without depending on
18
+ * the host. The host injects a real, fully-functional builder at register time;
19
+ * call `.handler(...)`, `.input(...)`, etc. as you would with `@orpc/server`.
20
+ */
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ export type ProcedureBuilder = any;
23
+
24
+ export interface ExtensionLogger {
25
+ info(msg: string, meta?: Record<string, unknown>): void;
26
+ warn(msg: string, meta?: Record<string, unknown>): void;
27
+ error(msg: string, meta?: Record<string, unknown>): void;
28
+ }
29
+
30
+ /**
31
+ * Read/write the namespaced slice of the `metadata` JSON column on a core
32
+ * entity. Reads/writes are confined to this extension's own key, so two
33
+ * extensions never clobber each other's fields. Granted by
34
+ * `core.metadata:<entity>`.
35
+ */
36
+ export interface CoreMetaClient {
37
+ get(id: string): Promise<Record<string, unknown> | null>;
38
+ set(id: string, patch: Record<string, unknown>): Promise<void>;
39
+ }
40
+
41
+ /** Settings get/set scoped to the extension's approved key prefix(es). */
42
+ export interface ScopedSettings {
43
+ get(key: string): Promise<string | null>;
44
+ set(key: string, value: string): Promise<void>;
45
+ all(): Promise<Record<string, string>>;
46
+ }
47
+
48
+ /** Payload shape per event. Kept permissive; refine as the surface stabilizes. */
49
+ export interface HookPayloads {
50
+ "server.created": { serverId: string; userId: string | null };
51
+ "server.updated": { serverId: string; userId: string | null };
52
+ "server.deleted": { serverId: string; userId: string | null };
53
+ "server.power": { serverId: string; action: string };
54
+ "node.created": { nodeId: string };
55
+ "node.updated": { nodeId: string };
56
+ "node.deleted": { nodeId: string };
57
+ "user.created": { userId: string };
58
+ "user.updated": { userId: string };
59
+ "user.deleted": { userId: string };
60
+ }
61
+
62
+ export interface HookApi {
63
+ on<E extends HookEvent>(
64
+ event: E,
65
+ handler: (
66
+ payload: E extends keyof HookPayloads
67
+ ? HookPayloads[E]
68
+ : Record<string, unknown>,
69
+ ) => void | Promise<void>,
70
+ ): void;
71
+ }
72
+
73
+ /**
74
+ * Procedure builders capped by the extension's `api:<level>` permission. Builders
75
+ * the extension was not granted are absent (undefined), so attempting to use one
76
+ * is a type error and a runtime no-op.
77
+ */
78
+ export interface ScopedProcedures {
79
+ publicProcedure?: ProcedureBuilder;
80
+ protectedProcedure?: ProcedureBuilder;
81
+ adminProcedure?: ProcedureBuilder;
82
+ }
83
+
84
+ export interface ExtensionContext {
85
+ readonly extId: string;
86
+ readonly version: string;
87
+ readonly logger: ExtensionLogger;
88
+ /**
89
+ * Drizzle handle fenced to this extension's `ext_<id>_*` tables. Present only
90
+ * when `db:own` was granted. Pass your own table objects to query.
91
+ */
92
+ readonly db?: MySql2Database<Record<string, never>>;
93
+ /** Present per granted `core.metadata:<entity>` permission. */
94
+ readonly coreMeta: Partial<Record<CoreMetaEntity, CoreMetaClient>>;
95
+ readonly settings: ScopedSettings;
96
+ readonly hooks: HookApi;
97
+ readonly procedures: ScopedProcedures;
98
+ }
99
+
100
+ export interface ExtensionRegistration {
101
+ /** oRPC router mounted at `ext.<id>.*`. Build it with `ctx.procedures.*`. */
102
+ router?: AnyRouter;
103
+ }
104
+
105
+ export interface ExtensionDefinition {
106
+ register(ctx: ExtensionContext): ExtensionRegistration | Promise<ExtensionRegistration>;
107
+ }
108
+
109
+ /**
110
+ * Author entry point. The default export of `server/index.js` should be the
111
+ * result of this call.
112
+ *
113
+ * @example
114
+ * export default defineExtension({
115
+ * async register(ctx) {
116
+ * ctx.hooks.on("server.created", async ({ serverId }) => {
117
+ * ctx.logger.info("server created", { serverId });
118
+ * });
119
+ * const router = {
120
+ * hello: ctx.procedures.protectedProcedure!.handler(() => "hi"),
121
+ * };
122
+ * return { router };
123
+ * },
124
+ * });
125
+ */
126
+ export function defineExtension(def: ExtensionDefinition): ExtensionDefinition {
127
+ return def;
128
+ }