@voilabs/plugins 0.0.1-beta.0 → 0.0.1-beta.2
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 +424 -32
- package/dist/client.d.ts +16 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +87 -5
- package/dist/client.js.map +1 -1
- package/dist/http.js +19 -3
- package/dist/http.js.map +1 -1
- package/dist/injection.d.ts +1 -1
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +66 -22
- package/dist/injection.js.map +1 -1
- package/dist/manager.d.ts +42 -13
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +454 -19
- package/dist/manager.js.map +1 -1
- package/dist/react.d.ts +35 -3
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +165 -1
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +13 -1
- package/dist/types.d.ts.map +1 -1
- package/github-examples/README.md +23 -0
- package/github-examples/google-tag-manager/assets/admin.css +27 -0
- package/github-examples/google-tag-manager/components/dashboard-card.js +10 -0
- package/github-examples/google-tag-manager/components/settings.js +21 -0
- package/github-examples/google-tag-manager/schema.json +139 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -9,8 +9,9 @@ A framework-agnostic plugin system for Node.js applications. The core package ru
|
|
|
9
9
|
- HTTP runtime for `/plugins`, `/plugins/:id/install`, `/plugins/:id/config`, and custom plugin routes.
|
|
10
10
|
- WordPress-like injection support: plugins can inject `script`, `style`, `html`, `meta`, `link`, and `noscript` into placements such as `head`, `body:end`, `admin:head`, and `public:footer`.
|
|
11
11
|
- Elysia and Next.js adapters that use structural typing and do not require hard dependencies.
|
|
12
|
-
- React integration through a fetch client
|
|
13
|
-
- GitHub marketplace support through
|
|
12
|
+
- React integration through a fetch client, hook factory, and slot renderer without making React a peer dependency.
|
|
13
|
+
- GitHub marketplace support through plugin folders such as `lumina/schema.json`, plus optional `marketplace.json`, `plugins.json`, and `index.json` manifests.
|
|
14
|
+
- Plugin-contained frontend modules and assets: remote schemas can reference component, script, style, image, and font files living beside `schema.json`.
|
|
14
15
|
|
|
15
16
|
## Installation
|
|
16
17
|
|
|
@@ -109,7 +110,7 @@ export const luminaAIPlugin = new Plugin({
|
|
|
109
110
|
});
|
|
110
111
|
```
|
|
111
112
|
|
|
112
|
-
When you create a plugin locally with `new Plugin()`, `provider` is required and the exact value you provide is used. For GitHub-loaded
|
|
113
|
+
When you create a plugin locally with `new Plugin()`, `provider` is required and the exact value you provide is used. For GitHub-loaded `schema.json` files, `provider` and `iconUrl` from the file are ignored.
|
|
113
114
|
|
|
114
115
|
Ready-to-use example: `@voilabs/plugins/examples/lumina`.
|
|
115
116
|
|
|
@@ -155,7 +156,7 @@ const plugins = new PluginManager({
|
|
|
155
156
|
branch: "main",
|
|
156
157
|
token: process.env.GITHUB_TOKEN,
|
|
157
158
|
files: ["marketplace.json", "plugins.json"],
|
|
158
|
-
|
|
159
|
+
schemaFileNames: ["schema.json"],
|
|
159
160
|
},
|
|
160
161
|
{
|
|
161
162
|
github: "voilabs/plugins-marketplace",
|
|
@@ -168,18 +169,19 @@ const plugins = new PluginManager({
|
|
|
168
169
|
|
|
169
170
|
By default, a GitHub repository is read from two sources:
|
|
170
171
|
|
|
171
|
-
-
|
|
172
|
+
- `schema.json` files discovered through the repository tree. Each plugin normally owns a folder such as `lumina/schema.json`.
|
|
172
173
|
- `marketplace.json`, `plugins.json`, and `index.json` marketplace manifests.
|
|
173
174
|
|
|
174
175
|
If no branch is configured, `main` and `master` are tried. If you provide a GitHub `blob` URL, that exact JSON file is used.
|
|
175
176
|
|
|
176
|
-
## GitHub
|
|
177
|
+
## GitHub Plugin Folders
|
|
177
178
|
|
|
178
|
-
A
|
|
179
|
+
A plugin folder contains one `schema.json` manifest plus any files the plugin needs. For GitHub-loaded plugins:
|
|
179
180
|
|
|
180
181
|
- `provider` is always the repository owner.
|
|
181
182
|
- `iconUrl` is always generated from the repository owner avatar: `https://github.com/<owner>.png?size=128`.
|
|
182
|
-
- Any `provider` or `iconUrl` inside
|
|
183
|
+
- Any `provider` or `iconUrl` inside `schema.json` is ignored.
|
|
184
|
+
- Relative component, asset, script, style, and injection URLs are resolved from the folder containing `schema.json`.
|
|
183
185
|
|
|
184
186
|
```json
|
|
185
187
|
{
|
|
@@ -195,6 +197,23 @@ A `.plugin` file contains one plugin manifest as JSON. For GitHub-loaded plugins
|
|
|
195
197
|
"required": true
|
|
196
198
|
}
|
|
197
199
|
],
|
|
200
|
+
"frontend": {
|
|
201
|
+
"components": [
|
|
202
|
+
{
|
|
203
|
+
"key": "lumina-settings",
|
|
204
|
+
"slot": "settings-panel",
|
|
205
|
+
"label": "Lumina settings",
|
|
206
|
+
"component": {
|
|
207
|
+
"type": "remote",
|
|
208
|
+
"path": "components/settings.js",
|
|
209
|
+
"exportName": "default"
|
|
210
|
+
},
|
|
211
|
+
"props": {
|
|
212
|
+
"dense": true
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
},
|
|
198
217
|
"routes": [
|
|
199
218
|
{
|
|
200
219
|
"id": "create-article",
|
|
@@ -221,6 +240,13 @@ A `.plugin` file contains one plugin manifest as JSON. For GitHub-loaded plugins
|
|
|
221
240
|
"type": "html",
|
|
222
241
|
"placement": "body:end",
|
|
223
242
|
"content": "<div data-lumina-widget></div>"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"key": "lumina-widget-script",
|
|
246
|
+
"type": "script",
|
|
247
|
+
"placement": "public:footer",
|
|
248
|
+
"src": "assets/widget.js",
|
|
249
|
+
"defer": true
|
|
224
250
|
}
|
|
225
251
|
]
|
|
226
252
|
}
|
|
@@ -230,12 +256,27 @@ Example repository:
|
|
|
230
256
|
|
|
231
257
|
```text
|
|
232
258
|
plugins-marketplace/
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
259
|
+
lumina/
|
|
260
|
+
schema.json
|
|
261
|
+
components/settings.js
|
|
262
|
+
assets/widget.js
|
|
263
|
+
analytics-pixel/
|
|
264
|
+
schema.json
|
|
265
|
+
assets/pixel.js
|
|
266
|
+
commerce/iyzico/
|
|
267
|
+
schema.json
|
|
268
|
+
components/settings.js
|
|
236
269
|
```
|
|
237
270
|
|
|
238
|
-
If you set `path`, only
|
|
271
|
+
If you set `path`, only `schema.json` files under that folder are read. A single-plugin repository can also put `schema.json` at the repository root.
|
|
272
|
+
|
|
273
|
+
## GitHub Examples
|
|
274
|
+
|
|
275
|
+
See `github-examples/google-tag-manager` for a folder-based marketplace plugin. It includes:
|
|
276
|
+
|
|
277
|
+
- `schema.json` with fields, routes, frontend components, assets, and GTM injections.
|
|
278
|
+
- `components/settings.js` and `components/dashboard-card.js` as browser-ready remote components.
|
|
279
|
+
- `assets/admin.css` as a plugin-owned asset referenced from the schema.
|
|
239
280
|
|
|
240
281
|
GitHub-loaded plugins support the same manifest surface as local plugins: routes, frontend components, injections, meta tags, assets, permissions, webhooks, and more. Since JSON cannot carry JavaScript functions, remote plugin routes use declarative actions:
|
|
241
282
|
|
|
@@ -395,25 +436,340 @@ await plugins.renderInjections({
|
|
|
395
436
|
## Next.js App Router Adapter
|
|
396
437
|
|
|
397
438
|
```ts
|
|
398
|
-
// app/api/plugins/[...voilabs]/route.ts
|
|
439
|
+
// app/api/plugins/[[...voilabs]]/route.ts
|
|
440
|
+
import { createNextPluginRouteHandlers } from "@voilabs/plugins/adapters/next";
|
|
441
|
+
import { plugins } from "@/server/plugins";
|
|
442
|
+
|
|
443
|
+
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD } =
|
|
444
|
+
createNextPluginRouteHandlers(plugins, {
|
|
445
|
+
prefix: "/api/plugins",
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Use an optional catch-all route (`[[...voilabs]]`) so both `/api/plugins` and `/api/plugins/:pluginId/*` reach the same handler.
|
|
450
|
+
|
|
451
|
+
## Minimal Next.js App Router Example
|
|
452
|
+
|
|
453
|
+
This is a copy-paste friendly setup for a native Next.js app. It uses the App Router, one API route, one client-side plugin provider, a tiny admin page, and a client injection bridge.
|
|
454
|
+
|
|
455
|
+
```text
|
|
456
|
+
instrumentation.ts
|
|
457
|
+
src/
|
|
458
|
+
server/plugins.ts
|
|
459
|
+
app/
|
|
460
|
+
api/plugins/[[...voilabs]]/route.ts
|
|
461
|
+
admin/plugins/page.tsx
|
|
462
|
+
admin/plugins/voilabs-plugin-react.tsx
|
|
463
|
+
plugin-injections.tsx
|
|
464
|
+
layout.tsx
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### `src/server/plugins.ts`
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
import "server-only";
|
|
471
|
+
import { MemoryPluginDatabase, PluginManager } from "@voilabs/plugins";
|
|
472
|
+
|
|
473
|
+
const globalForPlugins = globalThis as typeof globalThis & {
|
|
474
|
+
__voilabsPlugins?: PluginManager;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const marketplace = process.env.VOILABS_PLUGIN_MARKETPLACE;
|
|
478
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
479
|
+
|
|
480
|
+
export const plugins =
|
|
481
|
+
globalForPlugins.__voilabsPlugins ??
|
|
482
|
+
(globalForPlugins.__voilabsPlugins = new PluginManager({
|
|
483
|
+
marketplaces: marketplace ? [marketplace] : [],
|
|
484
|
+
autoSyncMarketplaces: Boolean(marketplace),
|
|
485
|
+
database: new MemoryPluginDatabase(),
|
|
486
|
+
encryption: {
|
|
487
|
+
encrypt: async (value) => value,
|
|
488
|
+
decrypt: async (value) => value,
|
|
489
|
+
},
|
|
490
|
+
github: githubToken ? { token: githubToken } : undefined,
|
|
491
|
+
marketplaceRequestTimeoutMs: 10_000,
|
|
492
|
+
marketplaceRefreshIntervalMs: 5 * 60 * 1000,
|
|
493
|
+
}));
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
If `VOILABS_PLUGIN_MARKETPLACE` is not set, the API returns an empty plugin list instead of trying to fetch a placeholder repository.
|
|
497
|
+
|
|
498
|
+
For Redis persistence, install a Redis client and assign it to `redis`. Upstash works well in serverless Next.js projects:
|
|
499
|
+
|
|
500
|
+
```ts
|
|
501
|
+
import { Redis } from "@upstash/redis";
|
|
502
|
+
import { MemoryPluginDatabase, RedisPluginDatabase } from "@voilabs/plugins";
|
|
503
|
+
|
|
504
|
+
const redis =
|
|
505
|
+
process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
|
|
506
|
+
? Redis.fromEnv()
|
|
507
|
+
: undefined;
|
|
508
|
+
|
|
509
|
+
const database = redis
|
|
510
|
+
? new RedisPluginDatabase(redis, { prefix: "my-app:plugins" })
|
|
511
|
+
: new MemoryPluginDatabase();
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Then pass `database` into `new PluginManager({ database, ... })`. You can also let the manager create the adapter:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
new PluginManager({
|
|
518
|
+
redis,
|
|
519
|
+
redisPrefix: "my-app:plugins",
|
|
520
|
+
});
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
The built-in `RedisPluginDatabase` stores installations and config in Redis. For production secret fields, replace the placeholder encryption provider with your KMS or app encryption layer.
|
|
524
|
+
|
|
525
|
+
### `instrumentation.ts`
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
export async function register() {
|
|
529
|
+
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
530
|
+
const { plugins } = await import("./src/server/plugins");
|
|
531
|
+
await plugins.ready();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
`instrumentation.ts` warms the marketplace registry when the Next.js server starts, so `/admin/plugins` does not have to perform the first GitHub sync during its initial loading state.
|
|
537
|
+
|
|
538
|
+
### `app/api/plugins/[[...voilabs]]/route.ts`
|
|
539
|
+
|
|
540
|
+
```ts
|
|
399
541
|
import { createNextPluginRouteHandlers } from "@voilabs/plugins/adapters/next";
|
|
400
542
|
import { plugins } from "@/server/plugins";
|
|
401
543
|
|
|
544
|
+
export const runtime = "nodejs";
|
|
545
|
+
export const dynamic = "force-dynamic";
|
|
546
|
+
|
|
402
547
|
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD } =
|
|
403
548
|
createNextPluginRouteHandlers(plugins, {
|
|
404
549
|
prefix: "/api/plugins",
|
|
550
|
+
exposeErrors: process.env.NODE_ENV !== "production",
|
|
405
551
|
});
|
|
406
552
|
```
|
|
407
553
|
|
|
554
|
+
### `app/admin/plugins/voilabs-plugin-react.tsx`
|
|
555
|
+
|
|
556
|
+
```tsx
|
|
557
|
+
"use client";
|
|
558
|
+
|
|
559
|
+
import * as React from "react";
|
|
560
|
+
import { createPluginReactIntegration } from "@voilabs/plugins/react";
|
|
561
|
+
|
|
562
|
+
export const {
|
|
563
|
+
PluginProvider,
|
|
564
|
+
PluginSlot,
|
|
565
|
+
usePluginClient,
|
|
566
|
+
usePlugins,
|
|
567
|
+
} = createPluginReactIntegration(React, {
|
|
568
|
+
baseUrl: "/api/plugins",
|
|
569
|
+
requestTimeoutMs: 15_000,
|
|
570
|
+
});
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
### `app/admin/plugins/page.tsx`
|
|
574
|
+
|
|
575
|
+
```tsx
|
|
576
|
+
"use client";
|
|
577
|
+
|
|
578
|
+
import {
|
|
579
|
+
PluginProvider,
|
|
580
|
+
PluginSlot,
|
|
581
|
+
usePluginClient,
|
|
582
|
+
usePlugins,
|
|
583
|
+
} from "./voilabs-plugin-react";
|
|
584
|
+
|
|
585
|
+
function defaultConfig(plugin: { id: string; fields?: Array<{ key: string; defaultValue?: unknown }> }) {
|
|
586
|
+
if (plugin.id === "google-tag-manager") {
|
|
587
|
+
return {
|
|
588
|
+
containerId: "GTM-ABC123",
|
|
589
|
+
dataLayerName: "dataLayer",
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return Object.fromEntries(
|
|
594
|
+
(plugin.fields ?? []).map((field) => [field.key, field.defaultValue ?? ""]),
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function PluginAdmin() {
|
|
599
|
+
const client = usePluginClient();
|
|
600
|
+
const { data, loading, error } = usePlugins({ page: 1, limit: 50 });
|
|
601
|
+
|
|
602
|
+
if (loading) return <main>Loading plugins...</main>;
|
|
603
|
+
if (error) {
|
|
604
|
+
return (
|
|
605
|
+
<main style={{ padding: 24 }}>
|
|
606
|
+
<h1>Plugin API error</h1>
|
|
607
|
+
<pre>{error instanceof Error ? error.message : String(error)}</pre>
|
|
608
|
+
</main>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return (
|
|
613
|
+
<main style={{ display: "grid", gap: 24, padding: 24 }}>
|
|
614
|
+
<section style={{ display: "grid", gap: 12 }}>
|
|
615
|
+
{data?.data.map((plugin) => (
|
|
616
|
+
<article
|
|
617
|
+
key={plugin.id}
|
|
618
|
+
style={{ border: "1px solid #ddd", borderRadius: 8, padding: 16 }}
|
|
619
|
+
>
|
|
620
|
+
<img src={plugin.iconUrl} alt="" width={32} height={32} />
|
|
621
|
+
<h2>{plugin.name}</h2>
|
|
622
|
+
<p>{plugin.summary}</p>
|
|
623
|
+
<p>
|
|
624
|
+
{plugin.provider} / {plugin.category}
|
|
625
|
+
</p>
|
|
626
|
+
<button
|
|
627
|
+
onClick={() => client.install(plugin.id, defaultConfig(plugin))}
|
|
628
|
+
>
|
|
629
|
+
Install
|
|
630
|
+
</button>
|
|
631
|
+
<button onClick={() => client.enable(plugin.id)}>Enable</button>
|
|
632
|
+
<button onClick={() => client.disable(plugin.id)}>Disable</button>
|
|
633
|
+
</article>
|
|
634
|
+
))}
|
|
635
|
+
</section>
|
|
636
|
+
|
|
637
|
+
<aside>
|
|
638
|
+
<PluginSlot
|
|
639
|
+
slot="settings-panel"
|
|
640
|
+
installedOnly={false}
|
|
641
|
+
enabledOnly={false}
|
|
642
|
+
/>
|
|
643
|
+
</aside>
|
|
644
|
+
</main>
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export default function PluginsPage() {
|
|
649
|
+
return (
|
|
650
|
+
<PluginProvider>
|
|
651
|
+
<PluginAdmin />
|
|
652
|
+
</PluginProvider>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### `app/plugin-injections.tsx`
|
|
658
|
+
|
|
659
|
+
```tsx
|
|
660
|
+
"use client";
|
|
661
|
+
|
|
662
|
+
import { useEffect } from "react";
|
|
663
|
+
|
|
664
|
+
type Placement = "head" | "body:start" | "body:end";
|
|
665
|
+
|
|
666
|
+
export function PluginInjections() {
|
|
667
|
+
useEffect(() => {
|
|
668
|
+
const controller = new AbortController();
|
|
669
|
+
|
|
670
|
+
void inject("head", document.head, "beforeend", controller.signal);
|
|
671
|
+
void inject("body:start", document.body, "afterbegin", controller.signal);
|
|
672
|
+
void inject("body:end", document.body, "beforeend", controller.signal);
|
|
673
|
+
|
|
674
|
+
return () => controller.abort();
|
|
675
|
+
}, []);
|
|
676
|
+
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function inject(
|
|
681
|
+
placement: Placement,
|
|
682
|
+
target: HTMLElement,
|
|
683
|
+
position: InsertPosition,
|
|
684
|
+
signal: AbortSignal,
|
|
685
|
+
) {
|
|
686
|
+
const marker = `voilabs:${placement}`;
|
|
687
|
+
if (document.querySelector(`[data-voilabs-injection="${marker}"]`)) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const response = await fetch(
|
|
692
|
+
`/api/plugins/injections?area=public&placement=${encodeURIComponent(placement)}`,
|
|
693
|
+
{ signal },
|
|
694
|
+
);
|
|
695
|
+
const payload = (await response.json()) as { html?: string };
|
|
696
|
+
if (!payload.html) return;
|
|
697
|
+
|
|
698
|
+
const wrapper = document.createElement("div");
|
|
699
|
+
wrapper.dataset.voilabsInjection = marker;
|
|
700
|
+
appendExecutableHtml(wrapper, payload.html);
|
|
701
|
+
target.insertAdjacentElement(position, wrapper);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function appendExecutableHtml(target: HTMLElement, html: string) {
|
|
705
|
+
const template = document.createElement("template");
|
|
706
|
+
template.innerHTML = html;
|
|
707
|
+
|
|
708
|
+
for (const node of Array.from(template.content.childNodes)) {
|
|
709
|
+
if (node instanceof HTMLScriptElement) {
|
|
710
|
+
const script = document.createElement("script");
|
|
711
|
+
for (const attribute of Array.from(node.attributes)) {
|
|
712
|
+
script.setAttribute(attribute.name, attribute.value);
|
|
713
|
+
}
|
|
714
|
+
script.text = node.text;
|
|
715
|
+
target.appendChild(script);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
target.appendChild(node);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
### `app/layout.tsx`
|
|
725
|
+
|
|
726
|
+
```tsx
|
|
727
|
+
import { PluginInjections } from "./plugin-injections";
|
|
728
|
+
|
|
729
|
+
export default function RootLayout({
|
|
730
|
+
children,
|
|
731
|
+
}: {
|
|
732
|
+
children: React.ReactNode;
|
|
733
|
+
}) {
|
|
734
|
+
return (
|
|
735
|
+
<html lang="en">
|
|
736
|
+
<body>
|
|
737
|
+
<PluginInjections />
|
|
738
|
+
{children}
|
|
739
|
+
</body>
|
|
740
|
+
</html>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
### Try It With The GTM Example
|
|
746
|
+
|
|
747
|
+
Push `github-examples/google-tag-manager` to a GitHub repo, then set:
|
|
748
|
+
|
|
749
|
+
```bash
|
|
750
|
+
VOILABS_PLUGIN_MARKETPLACE=<github-owner>/<plugins-repo>
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
Open `/admin/plugins`, install `google-tag-manager`, then visit any public page. The app will request `/api/plugins/injections?area=public&placement=head` and inject the GTM script for installed and enabled plugins.
|
|
754
|
+
|
|
755
|
+
### If The Page Stays Loading
|
|
756
|
+
|
|
757
|
+
Open `/api/plugins` directly in the browser.
|
|
758
|
+
|
|
759
|
+
- If it is pending, the server is waiting on GitHub. Check `VOILABS_PLUGIN_MARKETPLACE`, add `GITHUB_TOKEN` for private or rate-limited repos, or lower `marketplaceRequestTimeoutMs`.
|
|
760
|
+
- If it returns 404, make sure the route folder is `app/api/plugins/[[...voilabs]]/route.ts`, not `[...voilabs]`.
|
|
761
|
+
- If it returns JSON with `data: []`, the Next.js wiring is working and the marketplace repo did not provide discoverable `schema.json` files.
|
|
762
|
+
- If `/admin/plugins` still says loading after `/api/plugins` returns JSON, confirm `PluginProvider` uses `baseUrl: "/api/plugins"` and the page is a client component with `"use client"`.
|
|
763
|
+
|
|
408
764
|
## React Integration
|
|
409
765
|
|
|
410
|
-
React is not imported by the package. Pass your app's React object into the integration factory.
|
|
766
|
+
React is not imported by the package. Pass your app's React object into the integration factory. The host app does not need to register every marketplace component manually; it can render plugin slots and let the plugin schema load browser-ready component modules from the plugin folder.
|
|
411
767
|
|
|
412
768
|
```tsx
|
|
413
769
|
import * as React from "react";
|
|
414
770
|
import { createPluginReactIntegration } from "@voilabs/plugins/react";
|
|
415
771
|
|
|
416
|
-
const { PluginProvider, usePlugins } = createPluginReactIntegration(React, {
|
|
772
|
+
const { PluginProvider, PluginSlot, usePlugins } = createPluginReactIntegration(React, {
|
|
417
773
|
baseUrl: "/api/plugins",
|
|
418
774
|
});
|
|
419
775
|
|
|
@@ -436,11 +792,45 @@ export function App() {
|
|
|
436
792
|
return (
|
|
437
793
|
<PluginProvider>
|
|
438
794
|
<PluginList />
|
|
795
|
+
<PluginSlot slot="settings-panel" />
|
|
439
796
|
</PluginProvider>
|
|
440
797
|
);
|
|
441
798
|
}
|
|
442
799
|
```
|
|
443
800
|
|
|
801
|
+
Remote component modules are loaded from the plugin asset endpoint when a schema uses `component.path`:
|
|
802
|
+
|
|
803
|
+
```json
|
|
804
|
+
{
|
|
805
|
+
"frontend": {
|
|
806
|
+
"components": [
|
|
807
|
+
{
|
|
808
|
+
"key": "analytics-card",
|
|
809
|
+
"slot": "dashboard-card",
|
|
810
|
+
"component": {
|
|
811
|
+
"type": "remote",
|
|
812
|
+
"path": "components/dashboard-card.js"
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
]
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
The component file must be browser-ready ESM:
|
|
821
|
+
|
|
822
|
+
```js
|
|
823
|
+
export default function DashboardCard(props) {
|
|
824
|
+
return props.React.createElement(
|
|
825
|
+
"section",
|
|
826
|
+
null,
|
|
827
|
+
`Plugin: ${props.plugin.name}`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
If a project uses a custom module federation/runtime loader, pass `loadRemoteModule` to `createPluginReactIntegration` or `PluginProvider`.
|
|
833
|
+
|
|
444
834
|
## Marketplace JSON Format
|
|
445
835
|
|
|
446
836
|
Marketplace JSON can be either an array or an object:
|
|
@@ -460,7 +850,7 @@ Marketplace JSON can be either an array or an object:
|
|
|
460
850
|
}
|
|
461
851
|
```
|
|
462
852
|
|
|
463
|
-
When a GitHub repository URL is provided,
|
|
853
|
+
When a GitHub repository URL is provided, folder-based `schema.json` files are discovered first. `marketplace.json`, `plugins.json`, and `index.json` are also tried for curated indexes or non-GitHub sources.
|
|
464
854
|
|
|
465
855
|
## Integration Prompts
|
|
466
856
|
|
|
@@ -473,11 +863,11 @@ Integrate @voilabs/plugins into this project using Next.js for the frontend/admi
|
|
|
473
863
|
|
|
474
864
|
Goals:
|
|
475
865
|
- Use GitHub marketplace repositories as the plugin source.
|
|
476
|
-
- Automatically discover
|
|
866
|
+
- Automatically discover plugin folders with schema.json files from the configured GitHub repo.
|
|
477
867
|
- Treat the GitHub repo owner as the provider for remote plugins.
|
|
478
868
|
- Use the GitHub owner avatar as the plugin icon.
|
|
479
|
-
- Ignore provider and iconUrl values inside remote .
|
|
480
|
-
- Support plugin fields, install/update/enable/disable/uninstall, declarative routes, proxy routes, redirects, frontend
|
|
869
|
+
- Ignore provider and iconUrl values inside remote schema.json files.
|
|
870
|
+
- Support plugin fields, install/update/enable/disable/uninstall, declarative routes, proxy routes, redirects, plugin-contained frontend modules/assets, and HTML/script/style injections.
|
|
481
871
|
|
|
482
872
|
Backend/Elysia work:
|
|
483
873
|
1. Create a singleton plugin manager, for example in src/server/plugins.ts:
|
|
@@ -508,9 +898,10 @@ Next.js/React work:
|
|
|
508
898
|
- generate config forms from plugin.fields
|
|
509
899
|
- support secret fields without echoing plain secret values back into the UI
|
|
510
900
|
- install, update config, enable, disable, and uninstall plugins
|
|
511
|
-
4. Render frontend component manifests through
|
|
512
|
-
-
|
|
513
|
-
-
|
|
901
|
+
4. Render frontend component manifests through plugin slots:
|
|
902
|
+
- use PluginSlot from @voilabs/plugins/react for slots such as settings-panel, dashboard-card, sidebar-item
|
|
903
|
+
- support component.type === "remote" with component.path resolved from the plugin folder
|
|
904
|
+
- avoid host-app component registration unless the project intentionally uses local registry components
|
|
514
905
|
5. Add injection support:
|
|
515
906
|
- head injections: render via the API or server helper and place into the document head
|
|
516
907
|
- body/footer injections: provide slots in the app layout
|
|
@@ -518,7 +909,7 @@ Next.js/React work:
|
|
|
518
909
|
|
|
519
910
|
Verification:
|
|
520
911
|
- Run TypeScript checks and the project build.
|
|
521
|
-
- Verify GitHub .
|
|
912
|
+
- Verify GitHub schema.json discovery with at least one plugin that does not define provider or iconUrl.
|
|
522
913
|
- Confirm the UI shows provider as the GitHub owner and icon as the GitHub avatar.
|
|
523
914
|
- Confirm install + enable works.
|
|
524
915
|
- Confirm a declarative route using response/proxy/redirect works.
|
|
@@ -533,10 +924,10 @@ Integrate @voilabs/plugins into this native Next.js project.
|
|
|
533
924
|
Goals:
|
|
534
925
|
- Use Next.js App Router API routes for the plugin runtime.
|
|
535
926
|
- Use React integration for the admin/plugin UI.
|
|
536
|
-
- Load remote plugins from GitHub marketplace repositories and
|
|
927
|
+
- Load remote plugins from GitHub marketplace repositories and plugin folders with schema.json files.
|
|
537
928
|
- Remote plugin provider must always be the GitHub repo owner.
|
|
538
929
|
- Remote plugin iconUrl must always be the GitHub owner avatar.
|
|
539
|
-
- Ignore provider and iconUrl fields inside GitHub-loaded
|
|
930
|
+
- Ignore provider and iconUrl fields inside GitHub-loaded schema.json files.
|
|
540
931
|
|
|
541
932
|
Server work:
|
|
542
933
|
1. Create src/server/plugins.ts:
|
|
@@ -544,7 +935,7 @@ Server work:
|
|
|
544
935
|
- configure marketplaces: ["<github-owner>/<plugins-repo>"]
|
|
545
936
|
- use MemoryPluginDatabase as a temporary adapter if no DB adapter exists
|
|
546
937
|
- add encryption provider hooks for secret fields
|
|
547
|
-
2. Create app/api/plugins/[...voilabs]/route.ts:
|
|
938
|
+
2. Create app/api/plugins/[[...voilabs]]/route.ts:
|
|
548
939
|
- import createNextPluginRouteHandlers from @voilabs/plugins/adapters/next
|
|
549
940
|
- export GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD from createNextPluginRouteHandlers(plugins, { prefix: "/api/plugins" })
|
|
550
941
|
3. Ensure the following routes work:
|
|
@@ -569,8 +960,9 @@ React/admin work:
|
|
|
569
960
|
- handle secret fields carefully and do not display stored secrets as plain text
|
|
570
961
|
- wire install, updateConfig, enable, disable, uninstall
|
|
571
962
|
4. Add support for plugin frontend manifests:
|
|
572
|
-
-
|
|
573
|
-
- render
|
|
963
|
+
- use PluginSlot from @voilabs/plugins/react
|
|
964
|
+
- render remote components declared by frontend.components without adding per-plugin host code
|
|
965
|
+
- only use a local registry map for explicitly local components
|
|
574
966
|
5. Add injection support:
|
|
575
967
|
- for server layouts, call plugins.renderInjections({ area: "public", placement: "head", nonce })
|
|
576
968
|
- create safe body/footer slots for public and admin injections
|
|
@@ -578,7 +970,7 @@ React/admin work:
|
|
|
578
970
|
|
|
579
971
|
Verification:
|
|
580
972
|
- Run lint/typecheck/build.
|
|
581
|
-
- Test a GitHub .
|
|
973
|
+
- Test a GitHub schema.json file without provider/iconUrl.
|
|
582
974
|
- Confirm provider is GitHub owner and iconUrl is https://github.com/<owner>.png?size=128.
|
|
583
975
|
- Confirm declarative response, proxy, and redirect routes work.
|
|
584
976
|
- Confirm plugin injections render in the expected placements.
|
|
@@ -591,10 +983,10 @@ Integrate @voilabs/plugins into this native Elysia.js project.
|
|
|
591
983
|
|
|
592
984
|
Goals:
|
|
593
985
|
- Use Elysia as the plugin API runtime.
|
|
594
|
-
- Load plugins from GitHub marketplace repositories and
|
|
986
|
+
- Load plugins from GitHub marketplace repositories and plugin folders with schema.json files.
|
|
595
987
|
- GitHub-loaded plugin provider must be the GitHub repo owner.
|
|
596
988
|
- GitHub-loaded plugin iconUrl must be the GitHub owner avatar.
|
|
597
|
-
- Ignore provider and iconUrl fields inside remote .
|
|
989
|
+
- Ignore provider and iconUrl fields inside remote schema.json files.
|
|
598
990
|
- Support install/config/enable/disable/uninstall, declarative routes, proxy routes, redirects, and injection rendering endpoints.
|
|
599
991
|
|
|
600
992
|
Implementation:
|
|
@@ -626,7 +1018,7 @@ Implementation:
|
|
|
626
1018
|
|
|
627
1019
|
Verification:
|
|
628
1020
|
- Run typecheck/build/tests.
|
|
629
|
-
- Mock or use a real GitHub marketplace with at least one
|
|
1021
|
+
- Mock or use a real GitHub marketplace with at least one plugin-folder/schema.json file.
|
|
630
1022
|
- Confirm provider and iconUrl are derived from the GitHub repo owner.
|
|
631
1023
|
- Install and enable the plugin.
|
|
632
1024
|
- Confirm declarative response/proxy/redirect routes work.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
import type { PaginatedResult, PluginConfig, PluginInstallOptions, PluginListItem, PluginListOptions } from "./types.js";
|
|
1
|
+
import type { PaginatedResult, PluginConfig, PluginInstallOptions, PluginListItem, PluginListOptions, ResolvedPluginFrontendComponent } from "./types.js";
|
|
2
2
|
export interface PluginApiClientOptions {
|
|
3
3
|
baseUrl?: string;
|
|
4
4
|
fetcher?: typeof fetch;
|
|
5
5
|
headers?: HeadersInit | (() => HeadersInit);
|
|
6
|
+
requestTimeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PluginComponentListOptions {
|
|
9
|
+
pluginId?: string;
|
|
10
|
+
slot?: string;
|
|
11
|
+
tenantId?: string;
|
|
12
|
+
installedOnly?: boolean;
|
|
13
|
+
enabledOnly?: boolean;
|
|
6
14
|
}
|
|
7
15
|
export declare class PluginApiClient {
|
|
8
16
|
private readonly baseUrl;
|
|
9
17
|
private readonly fetcher;
|
|
10
18
|
private readonly headers?;
|
|
19
|
+
private readonly requestTimeoutMs;
|
|
11
20
|
constructor(options?: PluginApiClientOptions);
|
|
12
21
|
list(options?: PluginListOptions): Promise<PaginatedResult<PluginListItem>>;
|
|
13
22
|
get(pluginId: string): Promise<PluginListItem>;
|
|
23
|
+
components(options?: PluginComponentListOptions): Promise<ResolvedPluginFrontendComponent[]>;
|
|
24
|
+
assetUrl(pluginId: string, path: string): string;
|
|
14
25
|
install<TConfig extends PluginConfig = PluginConfig>(pluginId: string, config: Partial<TConfig>, options?: PluginInstallOptions): Promise<unknown>;
|
|
15
26
|
updateConfig<TConfig extends PluginConfig = PluginConfig>(pluginId: string, config: Partial<TConfig>, options?: {
|
|
16
27
|
tenantId?: string;
|
|
@@ -25,6 +36,10 @@ export declare class PluginApiClient {
|
|
|
25
36
|
}): Promise<TResponse>;
|
|
26
37
|
private request;
|
|
27
38
|
}
|
|
39
|
+
export declare class PluginApiClientTimeoutError extends Error {
|
|
40
|
+
readonly timeoutMs: number;
|
|
41
|
+
constructor(timeoutMs: number);
|
|
42
|
+
}
|
|
28
43
|
export declare class PluginApiClientError extends Error {
|
|
29
44
|
readonly status: number;
|
|
30
45
|
readonly payload: unknown;
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,YAAY,EACZ,oBAAoB,EACpB,cAAc,EACd,iBAAiB,
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,YAAY,EACZ,oBAAoB,EACpB,cAAc,EACd,iBAAiB,EACjB,+BAA+B,EAChC,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,OAAO,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,WAAW,CAAC,CAAC;IAC5C,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;gBAE9B,OAAO,GAAE,sBAA2B;IAWhD,IAAI,CAAC,OAAO,GAAE,iBAAsB,GAAG,OAAO,CAAC,eAAe,CAAC,cAAc,CAAC,CAAC;IAe/E,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAI9C,UAAU,CACR,OAAO,GAAE,0BAA+B,GACvC,OAAO,CAAC,+BAA+B,EAAE,CAAC;IAe7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM;IAOhD,OAAO,CAAC,OAAO,SAAS,YAAY,GAAG,YAAY,EACjD,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,EACxB,OAAO,GAAE,oBAAyB;IAapC,YAAY,CAAC,OAAO,SAAS,YAAY,GAAG,YAAY,EACtD,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,EACxB,OAAO,GAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAO;IAatF,MAAM,CAAC,QAAQ,EAAE,MAAM;IAMvB,OAAO,CAAC,QAAQ,EAAE,MAAM;IAMxB,SAAS,CAAC,QAAQ,EAAE,MAAM;IAM1B,IAAI,CAAC,SAAS,GAAG,OAAO,EACtB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG;QAAE,IAAI,CAAC,EAAE,OAAO,CAAA;KAAO,GACxD,OAAO,CAAC,SAAS,CAAC;YAOP,OAAO;CA8CtB;AAED,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBAEf,SAAS,EAAE,MAAM;CAK9B;AAED,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;gBAEd,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO;CAM7C"}
|