@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 +55 -0
- package/src/client.ts +157 -0
- package/src/index.ts +103 -0
- package/src/react.tsx +87 -0
- package/src/server.ts +128 -0
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
|
+
}
|