create-struxa-extension 2.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/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # create-struxa-extension
2
+
3
+ Scaffold a new [Struxa](https://struxa.cloud) extension.
4
+
5
+ ```bash
6
+ # with npm
7
+ npm create struxa-extension my-ext
8
+
9
+ # or directly
10
+ npx create-struxa-extension my-ext --template react
11
+ ```
12
+
13
+ Options:
14
+
15
+ - `<dir>` — target directory (required); its name becomes the extension id.
16
+ - `--template, -t` — `react` (default) or `vanilla`.
17
+ - `--name, -n` — display name (defaults to a title-cased id).
18
+
19
+ Then:
20
+
21
+ ```bash
22
+ cd my-ext
23
+ npm install
24
+ npm run build # -> dist/ (the package the registry publishes)
25
+ ```
26
+
27
+ Add `<path>/dist` to your registry's `registry.config.json`, run the registry
28
+ build, and install from **Admin → Extensions**.
29
+
30
+ ## What you get
31
+
32
+ - `manifest.json` — id, permissions, and a panel page at `/<id>`.
33
+ - `server/index.js` — a `register(ctx)` entry exposing `ext.<id>.ping` (extend it
34
+ with your own oRPC procedures, hooks, scoped DB/settings via `ctx`).
35
+ - `src/` — the UI. The **react** template uses `@struxa/extension-sdk/react`
36
+ (`useHostBridge`) and `@struxa/extension-sdk/client` (`createHostClient`); the
37
+ **vanilla** template uses the same SDK without a UI framework. Both bundle with
38
+ esbuild into `dist/web`.
39
+
40
+ > Until `@struxa/extension-sdk` is published to your registry, use a local link
41
+ > for development: `npm install ../struxa/packages/extension-sdk` (or
42
+ > `npm link @struxa/extension-sdk`).
package/index.mjs ADDED
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import * as p from "@clack/prompts";
14
+
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ const TEXT_EXT = new Set([
17
+ ".json",
18
+ ".ts",
19
+ ".tsx",
20
+ ".js",
21
+ ".mjs",
22
+ ".html",
23
+ ".md",
24
+ ".gitignore",
25
+ ]);
26
+
27
+ function slugify(s) {
28
+ return s
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9-]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 63);
33
+ }
34
+
35
+ function titleize(slug) {
36
+ return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
37
+ }
38
+
39
+ // Pre-fill from CLI args so the tool is still scriptable.
40
+ const args = process.argv.slice(2);
41
+ let argDir = null, argTemplate = null, argName = null;
42
+ let nonInteractive = !process.stdin.isTTY;
43
+ for (let i = 0; i < args.length; i++) {
44
+ const a = args[i];
45
+ if ((a === "--template" || a === "-t") && args[i + 1]) argTemplate = args[++i];
46
+ else if ((a === "--name" || a === "-n") && args[i + 1]) argName = args[++i];
47
+ else if (a === "--yes" || a === "-y") nonInteractive = true;
48
+ else if (!a.startsWith("-")) argDir ??= a;
49
+ }
50
+
51
+ p.intro("Create a Struxa Extension");
52
+
53
+ // --- directory ---
54
+ let dir;
55
+ if (argDir) {
56
+ dir = argDir;
57
+ } else if (nonInteractive) {
58
+ p.cancel("--dir is required in non-interactive mode.");
59
+ process.exit(1);
60
+ } else {
61
+ const rawDir = await p.text({
62
+ message: "Target directory",
63
+ placeholder: "my-extension",
64
+ validate: (v) => (v.trim() ? undefined : "Required."),
65
+ });
66
+ if (p.isCancel(rawDir)) { p.cancel("Cancelled."); process.exit(0); }
67
+ dir = String(rawDir);
68
+ }
69
+ const targetDir = path.resolve(process.cwd(), dir);
70
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
71
+ p.cancel(`"${dir}" already exists and is not empty.`);
72
+ process.exit(1);
73
+ }
74
+
75
+ // --- extension id ---
76
+ const defaultId = slugify(path.basename(targetDir));
77
+ let id;
78
+ if (nonInteractive) {
79
+ id = defaultId;
80
+ } else {
81
+ const rawId = await p.text({
82
+ message: "Extension id",
83
+ initialValue: defaultId,
84
+ validate: (v) =>
85
+ /^[a-z][a-z0-9-]{1,62}$/.test(v)
86
+ ? undefined
87
+ : "Must be a lowercase slug (letters, numbers, hyphens).",
88
+ });
89
+ if (p.isCancel(rawId)) { p.cancel("Cancelled."); process.exit(0); }
90
+ id = String(rawId);
91
+ }
92
+
93
+ // --- display name ---
94
+ let name;
95
+ if (argName) {
96
+ name = argName;
97
+ } else if (nonInteractive) {
98
+ name = titleize(id);
99
+ } else {
100
+ const rawName = await p.text({
101
+ message: "Display name",
102
+ initialValue: titleize(id),
103
+ validate: (v) => (v.trim() ? undefined : "Required."),
104
+ });
105
+ if (p.isCancel(rawName)) { p.cancel("Cancelled."); process.exit(0); }
106
+ name = String(rawName);
107
+ }
108
+
109
+ // --- template ---
110
+ let template;
111
+ if (argTemplate) {
112
+ template = argTemplate;
113
+ } else if (nonInteractive) {
114
+ template = "react";
115
+ } else {
116
+ const rawTemplate = await p.select({
117
+ message: "Template",
118
+ options: [
119
+ {
120
+ value: "react",
121
+ label: "React",
122
+ hint: "TypeScript + React — builds to dist/ with struxa build",
123
+ },
124
+ {
125
+ value: "vanilla",
126
+ label: "Vanilla JS",
127
+ hint: "No build step — plain HTML + JS, loads SDK from CDN",
128
+ },
129
+ ],
130
+ });
131
+ if (p.isCancel(rawTemplate)) { p.cancel("Cancelled."); process.exit(0); }
132
+ template = String(rawTemplate);
133
+ }
134
+
135
+ // --- features (React only) ---
136
+ let features = [];
137
+ if (template === "react" && !nonInteractive) {
138
+ const rawFeatures = await p.multiselect({
139
+ message: "Add features",
140
+ options: [
141
+ {
142
+ value: "add-slots",
143
+ label: "Widget slots",
144
+ hint: "inject a widget into host pages (e.g. server overview)",
145
+ },
146
+ {
147
+ value: "add-database",
148
+ label: "Database tables",
149
+ hint: "own MySQL tables via the db:own permission",
150
+ },
151
+ {
152
+ value: "add-hooks",
153
+ label: "Lifecycle hooks",
154
+ hint: "subscribe to events like server.created",
155
+ },
156
+ ],
157
+ required: false,
158
+ });
159
+ if (p.isCancel(rawFeatures)) { p.cancel("Cancelled."); process.exit(0); }
160
+ features = rawFeatures;
161
+ }
162
+
163
+ // --- scaffold ---
164
+ const s = p.spinner();
165
+ s.start("Scaffolding…");
166
+
167
+ const templateDir = path.join(here, "templates", template);
168
+ if (!existsSync(templateDir)) {
169
+ s.stop("Failed.");
170
+ p.cancel(`Template "${template}" not found.`);
171
+ process.exit(1);
172
+ }
173
+
174
+ mkdirSync(targetDir, { recursive: true });
175
+ cpSync(templateDir, targetDir, {
176
+ recursive: true,
177
+ filter: (src) => {
178
+ // widget.tsx is added only when add-slots is selected.
179
+ const rel = src.slice(templateDir.length);
180
+ if (rel === `${path.sep}src${path.sep}widget.tsx` || rel === "/src/widget.tsx") return false;
181
+ return true;
182
+ },
183
+ });
184
+
185
+ // Replace __EXT_ID__ / __EXT_NAME__ placeholders across the tree.
186
+ (function walk(d) {
187
+ for (const entry of readdirSync(d)) {
188
+ const fp = path.join(d, entry);
189
+ if (statSync(fp).isDirectory()) {
190
+ walk(fp);
191
+ } else if (TEXT_EXT.has(path.extname(entry)) || entry === ".gitignore") {
192
+ writeFileSync(
193
+ fp,
194
+ readFileSync(fp, "utf8")
195
+ .replaceAll("__EXT_ID__", id)
196
+ .replaceAll("__EXT_NAME__", name),
197
+ );
198
+ }
199
+ }
200
+ })(targetDir);
201
+
202
+ // Apply React feature flags.
203
+ if (template === "react") {
204
+ const manifestPath = path.join(targetDir, "manifest.json");
205
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
206
+ const serverPath = path.join(targetDir, "server", "index.js");
207
+ let serverSrc = readFileSync(serverPath, "utf8");
208
+ const dbPrefix = `ext_${id.replaceAll("-", "_")}`;
209
+
210
+ if (features.includes("add-database")) {
211
+ manifest.permissions = [...new Set([...manifest.permissions, "db:own"])];
212
+ mkdirSync(path.join(targetDir, "migrations"), { recursive: true });
213
+ writeFileSync(
214
+ path.join(targetDir, "migrations", "0000_init.sql"),
215
+ [
216
+ `-- Tables must be prefixed ${dbPrefix}_`,
217
+ `CREATE TABLE ${dbPrefix}_items (`,
218
+ ` id VARCHAR(36) NOT NULL PRIMARY KEY,`,
219
+ ` created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP`,
220
+ `);`,
221
+ "",
222
+ ].join("\n"),
223
+ );
224
+ serverSrc = serverSrc.replace(
225
+ "const pp = ctx.procedures",
226
+ `// ctx.db is available (db:own) — use Drizzle or raw SQL:\n // e.g. await ctx.db.execute(sql\`SELECT * FROM ${dbPrefix}_items\`);\n const pp = ctx.procedures`,
227
+ );
228
+ }
229
+
230
+ if (features.includes("add-hooks")) {
231
+ manifest.permissions = [...new Set([...manifest.permissions, "hook:server.created"])];
232
+ serverSrc = serverSrc.replace(
233
+ " return { router };",
234
+ ` ctx.hooks.on("server.created", async ({ server }) => {\n ctx.logger.info("server created", { id: server.id });\n });\n\n return { router };`,
235
+ );
236
+ }
237
+
238
+ if (features.includes("add-slots")) {
239
+ if (!manifest.ui) manifest.ui = { pages: [], slots: [] };
240
+ if (!manifest.ui.slots) manifest.ui.slots = [];
241
+ manifest.ui.slots.push({ slot: "server.overview.after", widget: `/${id}/widget` });
242
+
243
+ const widgetSrc = readFileSync(path.join(templateDir, "src", "widget.tsx"), "utf8")
244
+ .replaceAll("__EXT_ID__", id)
245
+ .replaceAll("__EXT_NAME__", name);
246
+ writeFileSync(path.join(targetDir, "src", "widget.tsx"), widgetSrc);
247
+ }
248
+
249
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
250
+ writeFileSync(serverPath, serverSrc);
251
+ }
252
+
253
+ s.stop("Done.");
254
+
255
+ p.note(
256
+ [
257
+ ` cd ${dir}`,
258
+ ` npm install`,
259
+ ` npm run build`,
260
+ ``,
261
+ `Then add "<path>/dist" to your registry and publish.`,
262
+ ].join("\n"),
263
+ "Next steps",
264
+ );
265
+
266
+ p.outro(`Created "${name}" (${id})`);
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "create-struxa-extension",
3
+ "version": "2.0.0",
4
+ "description": "Interactive scaffolder for Struxa extensions.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-struxa-extension": "index.mjs"
8
+ },
9
+ "files": ["index.mjs", "templates", "README.md"],
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "dependencies": {
14
+ "@clack/prompts": "^0.10.0"
15
+ },
16
+ "license": "MIT"
17
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "__EXT_ID__",
3
+ "name": "__EXT_NAME__",
4
+ "version": "1.0.0",
5
+ "description": "__EXT_NAME__ — a Struxa extension.",
6
+ "struxaApi": "^1.0.0",
7
+ "permissions": ["api:protected"],
8
+ "ui": {
9
+ "pages": [
10
+ { "route": "/__EXT_ID__", "section": "panel", "label": "__EXT_NAME__", "icon": "Blocks" }
11
+ ]
12
+ }
13
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "__EXT_ID__",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "__EXT_NAME__ — a Struxa extension (React).",
7
+ "scripts": {
8
+ "build": "struxa build",
9
+ "dev": "struxa dev"
10
+ },
11
+ "dependencies": {
12
+ "@struxa/extension-build": "^1.0.0",
13
+ "@struxa/extension-sdk": "^1.0.0",
14
+ "react": "^19.0.0",
15
+ "react-dom": "^19.0.0"
16
+ }
17
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Server entry — plain ESM, imports nothing (runs from the data volume). The
3
+ * capability-scoped `ctx` is the only sanctioned host surface.
4
+ */
5
+ export default {
6
+ async register(ctx) {
7
+ ctx.logger.info("__EXT_ID__ registered", { version: ctx.version });
8
+
9
+ const pp = ctx.procedures.protectedProcedure;
10
+ const router = pp
11
+ ? {
12
+ ping: pp.handler(({ context }) => ({
13
+ ok: true,
14
+ ts: Date.now(),
15
+ user: context?.session?.user?.id ?? null,
16
+ })),
17
+ }
18
+ : {};
19
+
20
+ return { router };
21
+ },
22
+ };
@@ -0,0 +1,47 @@
1
+ import { useState } from "react";
2
+ import { useHostBridge } from "@struxa/extension-sdk/react";
3
+ import { createHostClient } from "@struxa/extension-sdk/client";
4
+
5
+ const client = createHostClient<{
6
+ ext: { "__EXT_ID__": { ping: () => Promise<unknown> } };
7
+ }>();
8
+
9
+ export function App() {
10
+ const { context, toast } = useHostBridge();
11
+ const [result, setResult] = useState("Ready.");
12
+
13
+ async function ping() {
14
+ try {
15
+ setResult(JSON.stringify(await client.ext["__EXT_ID__"].ping(), null, 2));
16
+ } catch (err) {
17
+ setResult(`error: ${String(err)}`);
18
+ }
19
+ }
20
+
21
+ const card: React.CSSProperties = {
22
+ margin: 24,
23
+ maxWidth: 640,
24
+ padding: 20,
25
+ borderRadius: 12,
26
+ border: "1px solid var(--border, #2a2a2e)",
27
+ background: "var(--card, #141416)",
28
+ color: "var(--foreground, #e7e7e9)",
29
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
30
+ };
31
+
32
+ return (
33
+ <div style={card}>
34
+ <h1 style={{ fontSize: 18, margin: "0 0 4px" }}>__EXT_NAME__</h1>
35
+ <p style={{ fontSize: 13, color: "var(--muted-foreground, #9a9aa2)", marginTop: 0 }}>
36
+ Signed in as {context?.session.userId ?? "…"}
37
+ </p>
38
+ <div style={{ display: "flex", gap: 8 }}>
39
+ <button onClick={ping}>Call ping</button>
40
+ <button onClick={() => toast("success", "Hello!")}>Toast</button>
41
+ </div>
42
+ <pre style={{ background: "var(--muted, #1b1b1f)", padding: 12, borderRadius: 8, marginTop: 12 }}>
43
+ {result}
44
+ </pre>
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,5 @@
1
+ import { createExtension } from "@struxa/extension-sdk/react";
2
+
3
+ import { App } from "./App";
4
+
5
+ createExtension(<App />);
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>__EXT_NAME__</title>
7
+ <style>body { margin: 0; background: var(--background, #0b0b0c); }</style>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./bundle.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>__EXT_NAME__ widget</title>
7
+ <style>body { margin: 0; background: var(--card, #141416); }</style>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./bundle.js"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,24 @@
1
+ import { createExtension, useHostBridge } from "@struxa/extension-sdk/react";
2
+
3
+ function Widget() {
4
+ const { context } = useHostBridge();
5
+ return (
6
+ <div
7
+ style={{
8
+ padding: 16,
9
+ borderRadius: 12,
10
+ border: "1px solid var(--border, #2a2a2e)",
11
+ background: "var(--card, #141416)",
12
+ color: "var(--foreground, #e7e7e9)",
13
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
14
+ }}
15
+ >
16
+ <strong>__EXT_NAME__</strong>
17
+ <div style={{ fontSize: 12, color: "var(--muted-foreground, #9a9aa2)" }}>
18
+ {context?.params?.serverId ?? "slot widget"}
19
+ </div>
20
+ </div>
21
+ );
22
+ }
23
+
24
+ createExtension(<Widget />);
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
10
+ "types": []
11
+ },
12
+ "include": ["src"]
13
+ }
@@ -0,0 +1,35 @@
1
+ import { copyFileSync, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import * as esbuild from "esbuild";
5
+
6
+ /**
7
+ * Builds this extension into dist/ — the package contract the registry ships.
8
+ * The SDK is bundled inline by esbuild (the "vanilla" template still uses a
9
+ * bundler so it can import the SDK; it just doesn't use a UI framework).
10
+ */
11
+ const root = path.dirname(fileURLToPath(import.meta.url));
12
+ const dist = path.join(root, "dist");
13
+
14
+ rmSync(dist, { recursive: true, force: true });
15
+ mkdirSync(path.join(dist, "web"), { recursive: true });
16
+
17
+ await esbuild.build({
18
+ entryPoints: [path.join(root, "src/main.js")],
19
+ bundle: true,
20
+ format: "esm",
21
+ target: "es2020",
22
+ minify: true,
23
+ outfile: path.join(dist, "web/bundle.js"),
24
+ logLevel: "info",
25
+ });
26
+ copyFileSync(path.join(root, "src/index.html"), path.join(dist, "web/index.html"));
27
+
28
+ copyFileSync(path.join(root, "manifest.json"), path.join(dist, "manifest.json"));
29
+ cpSync(path.join(root, "server"), path.join(dist, "server"), { recursive: true });
30
+ if (existsSync(path.join(root, "migrations"))) {
31
+ cpSync(path.join(root, "migrations"), path.join(dist, "migrations"), { recursive: true });
32
+ }
33
+ writeFileSync(path.join(dist, "package.json"), JSON.stringify({ type: "module" }, null, 2) + "\n");
34
+
35
+ console.log("Built dist/ — point the registry at this dir to publish.");
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "__EXT_ID__",
3
+ "name": "__EXT_NAME__",
4
+ "version": "1.0.0",
5
+ "description": "__EXT_NAME__ — a Struxa extension.",
6
+ "struxaApi": "^1.0.0",
7
+ "permissions": ["api:protected"],
8
+ "ui": {
9
+ "pages": [
10
+ { "route": "/__EXT_ID__", "section": "panel", "label": "__EXT_NAME__", "icon": "Boxes" }
11
+ ]
12
+ }
13
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "__EXT_ID__",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "__EXT_NAME__ — a Struxa extension (vanilla JS).",
7
+ "scripts": {
8
+ "build": "node build.mjs"
9
+ },
10
+ "dependencies": {
11
+ "@struxa/extension-sdk": "^1.0.0"
12
+ },
13
+ "devDependencies": {
14
+ "esbuild": "^0.24.0"
15
+ }
16
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Server entry — plain ESM, imports nothing (runs from the data volume). The
3
+ * capability-scoped `ctx` is the only sanctioned host surface.
4
+ */
5
+ export default {
6
+ async register(ctx) {
7
+ ctx.logger.info("__EXT_ID__ registered", { version: ctx.version });
8
+
9
+ const pp = ctx.procedures.protectedProcedure;
10
+ const router = pp
11
+ ? {
12
+ ping: pp.handler(({ context }) => ({
13
+ ok: true,
14
+ ts: Date.now(),
15
+ user: context?.session?.user?.id ?? null,
16
+ })),
17
+ }
18
+ : {};
19
+
20
+ return { router };
21
+ },
22
+ };
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>__EXT_NAME__</title>
7
+ <style>
8
+ body {
9
+ margin: 0;
10
+ padding: 24px;
11
+ font-family: ui-sans-serif, system-ui, sans-serif;
12
+ background: var(--background, #0b0b0c);
13
+ color: var(--foreground, #e7e7e9);
14
+ }
15
+ .card {
16
+ max-width: 640px;
17
+ padding: 20px;
18
+ border-radius: 12px;
19
+ border: 1px solid var(--border, #2a2a2e);
20
+ background: var(--card, #141416);
21
+ }
22
+ pre { background: var(--muted, #1b1b1f); padding: 12px; border-radius: 8px; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div class="card">
27
+ <h1 style="font-size:18px;margin:0 0 4px">__EXT_NAME__</h1>
28
+ <p id="who" style="font-size:13px;color:var(--muted-foreground,#9a9aa2)">…</p>
29
+ <button id="ping">Call ping</button>
30
+ <pre id="out">Ready.</pre>
31
+ </div>
32
+ <script type="module" src="./bundle.js"></script>
33
+ </body>
34
+ </html>
@@ -0,0 +1,23 @@
1
+ import { createBridge, createHostClient } from "@struxa/extension-sdk/client";
2
+
3
+ // Host bridge: theme mirroring, session context, auto-resize, toast/navigate.
4
+ const bridge = createBridge({
5
+ autoResize: true,
6
+ onInit: (ctx) => {
7
+ document.getElementById("who").textContent = `Signed in as ${ctx.session.userId ?? "—"}`;
8
+ },
9
+ });
10
+
11
+ // Host API client (same-origin, cookie-authed).
12
+ const client = createHostClient();
13
+
14
+ document.getElementById("ping").addEventListener("click", async () => {
15
+ const out = document.getElementById("out");
16
+ try {
17
+ const res = await client.ext["__EXT_ID__"].ping();
18
+ out.textContent = JSON.stringify(res, null, 2);
19
+ bridge.toast("success", "pong");
20
+ } catch (err) {
21
+ out.textContent = `error: ${String(err)}`;
22
+ }
23
+ });