nextpluggable 0.0.0-stage → 0.1.1
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 +288 -2
- package/bin/nextpluggable.js +10 -0
- package/dist/api/handler.d.ts +10 -0
- package/dist/api/handler.js +188 -0
- package/dist/auth/custom.d.ts +13 -0
- package/dist/auth/custom.js +12 -0
- package/dist/auth/password.d.ts +10 -0
- package/dist/auth/password.js +66 -0
- package/dist/auth/types.d.ts +23 -0
- package/dist/auth/types.js +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +123 -0
- package/dist/client/host-runtime.d.ts +9 -0
- package/dist/client/host-runtime.js +71 -0
- package/dist/client/island.d.ts +28 -0
- package/dist/client/island.js +177 -0
- package/dist/client/ssr-guard.d.ts +11 -0
- package/dist/client/ssr-guard.js +100 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.js +57 -0
- package/dist/core/installer.d.ts +30 -0
- package/dist/core/installer.js +340 -0
- package/dist/core/routes.d.ts +20 -0
- package/dist/core/routes.js +95 -0
- package/dist/core/runtime.d.ts +34 -0
- package/dist/core/runtime.js +195 -0
- package/dist/core/sitemap.d.ts +12 -0
- package/dist/core/sitemap.js +83 -0
- package/dist/core/state.d.ts +13 -0
- package/dist/core/state.js +23 -0
- package/dist/core/store.d.ts +17 -0
- package/dist/core/store.js +58 -0
- package/dist/core/types.d.ts +69 -0
- package/dist/core/types.js +1 -0
- package/dist/crypto.d.ts +14 -0
- package/dist/crypto.js +61 -0
- package/dist/dashboard/Dashboard.d.ts +11 -0
- package/dist/dashboard/Dashboard.js +324 -0
- package/dist/dashboard/styles.d.ts +2 -0
- package/dist/dashboard/styles.js +129 -0
- package/dist/dashboard/ui.d.ts +61 -0
- package/dist/dashboard/ui.js +135 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +29 -0
- package/dist/globals.d.ts +24 -0
- package/dist/globals.js +24 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +65 -0
- package/dist/next/dashboard-page.d.ts +5 -0
- package/dist/next/dashboard-page.js +23 -0
- package/dist/next/plugin-page.d.ts +16 -0
- package/dist/next/plugin-page.js +91 -0
- package/dist/next/seo.d.ts +32 -0
- package/dist/next/seo.js +86 -0
- package/dist/next/slot.d.ts +7 -0
- package/dist/next/slot.js +43 -0
- package/dist/next/style.d.ts +10 -0
- package/dist/next/style.js +13 -0
- package/dist/sources/github.d.ts +19 -0
- package/dist/sources/github.js +109 -0
- package/dist/sources/local.d.ts +3 -0
- package/dist/sources/local.js +22 -0
- package/dist/sources/reader.d.ts +17 -0
- package/dist/sources/reader.js +78 -0
- package/dist/storage/file.d.ts +12 -0
- package/dist/storage/file.js +61 -0
- package/dist/storage/memory.d.ts +3 -0
- package/dist/storage/memory.js +22 -0
- package/dist/storage/postgres.d.ts +15 -0
- package/dist/storage/postgres.js +39 -0
- package/dist/storage/types.d.ts +18 -0
- package/dist/storage/types.js +15 -0
- package/dist/storage/upstash.d.ts +15 -0
- package/dist/storage/upstash.js +61 -0
- package/dist/views.d.ts +112 -0
- package/dist/views.js +124 -0
- package/package.json +48 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx nextpluggable init` — wire NextPluggable into an existing Next.js (App Router) site.
|
|
3
|
+
* Creates four small files and never overwrites anything that already exists.
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
const importPath = (fromFile, toFile) => {
|
|
10
|
+
const rel = path.relative(path.dirname(fromFile), toFile).split(path.sep).join('/').replace(/\.(t|j)sx?$/, '');
|
|
11
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
12
|
+
};
|
|
13
|
+
export async function init(projectDir) {
|
|
14
|
+
const root = path.resolve(projectDir);
|
|
15
|
+
const result = { created: [], skipped: [], notes: [] };
|
|
16
|
+
const appDir = ['app', 'src/app'].map((d) => path.join(root, d)).find((d) => existsSync(d));
|
|
17
|
+
if (!appDir)
|
|
18
|
+
throw new Error('No "app" directory found. NextPluggable needs a Next.js project that uses the App Router (run this in the project root).');
|
|
19
|
+
const ts = existsSync(path.join(root, 'tsconfig.json'));
|
|
20
|
+
const x = ts ? 'ts' : 'js';
|
|
21
|
+
const libFile = path.join(path.dirname(appDir), 'lib', `nextpluggable.${x}`);
|
|
22
|
+
const rel = (file) => path.relative(root, file).split(path.sep).join('/');
|
|
23
|
+
async function create(file, content) {
|
|
24
|
+
if (existsSync(file))
|
|
25
|
+
return void result.skipped.push(rel(file));
|
|
26
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
27
|
+
await writeFile(file, content);
|
|
28
|
+
result.created.push(rel(file));
|
|
29
|
+
}
|
|
30
|
+
await create(libFile, `import { createNextPluggable } from 'nextpluggable';
|
|
31
|
+
|
|
32
|
+
// Sign-in: one password, read from NEXTPLUGGABLE_PASSWORD. To use your site's own
|
|
33
|
+
// login instead, pass \`auth: customAuth({ getUser })\`.
|
|
34
|
+
// Data: a .nextpluggable folder next to your project. On serverless hosts
|
|
35
|
+
// (Vercel, Netlify) pass a database instead, e.g. \`storage: upstashStorage()\`.
|
|
36
|
+
export const np = createNextPluggable({
|
|
37
|
+
siteName: 'My site',
|
|
38
|
+
});
|
|
39
|
+
`);
|
|
40
|
+
const apiRoute = path.join(appDir, 'api', 'nextpluggable', '[...np]', `route.${x}`);
|
|
41
|
+
await create(apiRoute, `import { np } from '${importPath(apiRoute, libFile)}';\n\nexport const { GET, POST, PUT, PATCH, DELETE } = np.handlers;\n`);
|
|
42
|
+
const dashboard = path.join(appDir, 'admin', 'plugins', '[[...np]]', `page.${x}x`);
|
|
43
|
+
await create(dashboard, `import { np } from '${importPath(dashboard, libFile)}';\n\nexport const metadata = np.dashboardMetadata;\nexport default np.DashboardPage;\n`);
|
|
44
|
+
// A root catch-all is where plugin pages are served. If the site already has one, it must be merged by hand.
|
|
45
|
+
const existingCatchAll = (await readdir(appDir, { withFileTypes: true })).find((e) => e.isDirectory() && /^\[\[?\.\.\..+\]\]?$/.test(e.name));
|
|
46
|
+
if (existingCatchAll) {
|
|
47
|
+
result.notes.push(`Your site already has a catch-all route (${rel(path.join(appDir, existingCatchAll.name))}). To serve plugin pages from it, render \`<np.PluginPage params={params} />\` there when your own lookup finds nothing, or ask \`await np.hasPluginPage(pathname)\` first if that route never returns a 404.`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const catchAll = path.join(appDir, '[...slug]', `page.${x}x`);
|
|
51
|
+
await create(catchAll, `import { np } from '${importPath(catchAll, libFile)}';
|
|
52
|
+
|
|
53
|
+
// Addresses your own routes don't handle land here. Installed plugins answer
|
|
54
|
+
// for the pages they add; everything else gets your normal 404 page.
|
|
55
|
+
export const generateMetadata = np.generatePluginMetadata;
|
|
56
|
+
export default np.PluginPage;
|
|
57
|
+
|
|
58
|
+
// Faster and cheaper: uncomment the next two lines to cache plugin pages like
|
|
59
|
+
// static pages. The dashboard refreshes the cache whenever a plugin changes.
|
|
60
|
+
// Only do this if nothing in your root layout reads cookies() or headers() on
|
|
61
|
+
// the server: with caching on, such a layout makes these pages fail with a
|
|
62
|
+
// 500 error in production builds (it will not show up in \`next dev\`).
|
|
63
|
+
// export const generateStaticParams = async () => [];
|
|
64
|
+
// export const revalidate = 3600;
|
|
65
|
+
`);
|
|
66
|
+
}
|
|
67
|
+
if (['tsx', 'jsx', 'js'].some((ext) => existsSync(path.join(appDir, `loading.${ext}`))))
|
|
68
|
+
result.notes.push('Your app has a root loading file. While plugin pages are uncached (the default), it makes addresses that do not exist return "200 OK" with a noindex tag instead of a 404. To get real 404s, switch on caching in the catch-all route (see the comment in that file) or move the loading file into a route group.');
|
|
69
|
+
const sitemap = ['ts', 'tsx', 'js', 'jsx'].map((ext) => path.join(appDir, `sitemap.${ext}`)).find((f) => existsSync(f));
|
|
70
|
+
result.notes.push(sitemap
|
|
71
|
+
? `List plugin pages in your sitemap: add \`...(await np.sitemap())\` to the entries ${rel(sitemap)} returns.`
|
|
72
|
+
: 'Your site has no sitemap. To list plugin pages for search engines, create app/sitemap.ts that returns `await np.sitemap()` along with your own pages.');
|
|
73
|
+
// Secrets for local development.
|
|
74
|
+
const envFile = path.join(root, '.env.local');
|
|
75
|
+
const env = existsSync(envFile) ? await readFile(envFile, 'utf8') : '';
|
|
76
|
+
const additions = [];
|
|
77
|
+
if (!/^NEXTPLUGGABLE_PASSWORD=/m.test(env)) {
|
|
78
|
+
result.password = randomBytes(9).toString('base64url');
|
|
79
|
+
additions.push(`NEXTPLUGGABLE_PASSWORD=${result.password}`);
|
|
80
|
+
}
|
|
81
|
+
if (!/^NEXTPLUGGABLE_SECRET=/m.test(env))
|
|
82
|
+
additions.push(`NEXTPLUGGABLE_SECRET=${randomBytes(32).toString('base64url')}`);
|
|
83
|
+
if (additions.length) {
|
|
84
|
+
await appendFile(envFile, `${env && !env.endsWith('\n') ? '\n' : ''}\n# NextPluggable (plugin dashboard)\n${additions.join('\n')}\n`);
|
|
85
|
+
result.created.push('.env.local (added NextPluggable settings)');
|
|
86
|
+
}
|
|
87
|
+
const gitignore = path.join(root, '.gitignore');
|
|
88
|
+
if (existsSync(gitignore)) {
|
|
89
|
+
const current = await readFile(gitignore, 'utf8');
|
|
90
|
+
if (!/^\/?\.nextpluggable\/?$/m.test(current))
|
|
91
|
+
await appendFile(gitignore, `${current.endsWith('\n') ? '' : '\n'}\n# NextPluggable local data\n.nextpluggable/\n`);
|
|
92
|
+
if (!/^\.env(\.local|\*|\.\*)?$/m.test(current))
|
|
93
|
+
result.notes.push('Make sure .env.local is not committed: it now holds your dashboard password.');
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
const HELP = `nextpluggable — WordPress-style plugins for Next.js
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
npx nextpluggable init [dir] Add the dashboard, API route and plugin page route to a Next.js site
|
|
101
|
+
`;
|
|
102
|
+
export async function main() {
|
|
103
|
+
const [command, dir] = process.argv.slice(2);
|
|
104
|
+
if (command !== 'init') {
|
|
105
|
+
console.log(HELP);
|
|
106
|
+
return command && command !== 'help' && command !== '--help' ? 1 : 0;
|
|
107
|
+
}
|
|
108
|
+
const result = await init(dir ?? '.');
|
|
109
|
+
for (const f of result.created)
|
|
110
|
+
console.log(` created ${f}`);
|
|
111
|
+
for (const f of result.skipped)
|
|
112
|
+
console.log(` kept ${f} (already exists)`);
|
|
113
|
+
console.log(`
|
|
114
|
+
Next steps
|
|
115
|
+
1. Start your site and open /admin/plugins${result.password ? `\n Dashboard password: ${result.password} (saved in .env.local)` : ''}
|
|
116
|
+
2. Optional: add <np.Slot name="header.actions" />, <np.Slot name="footer" /> and
|
|
117
|
+
<np.Slot name="body.end" /> to your root layout so plugins can add buttons and widgets there.
|
|
118
|
+
3. Before going live, set NEXTPLUGGABLE_PASSWORD and NEXTPLUGGABLE_SECRET on your host,
|
|
119
|
+
and choose a storage adapter if the host has no persistent disk.`);
|
|
120
|
+
for (const note of result.notes)
|
|
121
|
+
console.log(`\nNote: ${note}`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { HostRuntime, PluginApi, PluginInfo } from '@nextpluggable/plugin';
|
|
3
|
+
export interface PluginRuntimeValue {
|
|
4
|
+
info: PluginInfo;
|
|
5
|
+
config: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
export declare const PluginRuntimeContext: React.Context<PluginRuntimeValue | null>;
|
|
8
|
+
export declare function createPluginApi(apiBase: string): PluginApi;
|
|
9
|
+
export declare const hostRuntime: HostRuntime;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* The host's implementation of `@nextpluggable/plugin`. Plugin bundles keep that
|
|
5
|
+
* module external; when they `require` it they receive this object, so every
|
|
6
|
+
* plugin shares the site's React, router and context.
|
|
7
|
+
*/
|
|
8
|
+
import NextLink from 'next/link';
|
|
9
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
export const PluginRuntimeContext = React.createContext(null);
|
|
12
|
+
function useRuntime() {
|
|
13
|
+
const value = React.useContext(PluginRuntimeContext);
|
|
14
|
+
if (!value)
|
|
15
|
+
throw new Error('Plugin hooks can only be used inside a plugin component.');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function createPluginApi(apiBase) {
|
|
19
|
+
return (path, init = {}) => {
|
|
20
|
+
const { json, headers, ...rest } = init;
|
|
21
|
+
const h = new Headers(headers);
|
|
22
|
+
h.set('x-nextpluggable', '1');
|
|
23
|
+
if (json !== undefined)
|
|
24
|
+
h.set('content-type', 'application/json');
|
|
25
|
+
return fetch(`${apiBase}${path.startsWith('/') ? path : `/${path}`}`, {
|
|
26
|
+
credentials: 'same-origin',
|
|
27
|
+
...rest,
|
|
28
|
+
headers: h,
|
|
29
|
+
body: json !== undefined ? JSON.stringify(json) : rest.body,
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const subscribeToNothing = () => () => { };
|
|
34
|
+
export const hostRuntime = {
|
|
35
|
+
usePluginConfig() {
|
|
36
|
+
return useRuntime().config;
|
|
37
|
+
},
|
|
38
|
+
usePlugin() {
|
|
39
|
+
return useRuntime().info;
|
|
40
|
+
},
|
|
41
|
+
usePluginApi() {
|
|
42
|
+
const { apiBase } = useRuntime().info;
|
|
43
|
+
return React.useMemo(() => createPluginApi(apiBase), [apiBase]);
|
|
44
|
+
},
|
|
45
|
+
useNavigation() {
|
|
46
|
+
const router = useRouter();
|
|
47
|
+
const pathname = usePathname();
|
|
48
|
+
return React.useMemo(() => ({
|
|
49
|
+
pathname,
|
|
50
|
+
push: (href) => router.push(href),
|
|
51
|
+
replace: (href) => router.replace(href),
|
|
52
|
+
back: () => router.back(),
|
|
53
|
+
}), [router, pathname]);
|
|
54
|
+
},
|
|
55
|
+
useQuery() {
|
|
56
|
+
// The query string must never reach server-rendered HTML: plugin pages may be cached and
|
|
57
|
+
// served to every visitor, and a query can carry private values. So it is not read on the
|
|
58
|
+
// server at all, stays empty while the browser hydrates (to match that HTML), and becomes
|
|
59
|
+
// available on the first update after.
|
|
60
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- the branch is fixed per environment
|
|
61
|
+
const live = typeof window === 'undefined' ? null : useSearchParams();
|
|
62
|
+
const hydrated = React.useSyncExternalStore(subscribeToNothing, () => true, () => false);
|
|
63
|
+
const search = hydrated && live ? live.toString() : '';
|
|
64
|
+
return React.useMemo(() => new URLSearchParams(search), [search]);
|
|
65
|
+
},
|
|
66
|
+
Link(props) {
|
|
67
|
+
return _jsx(NextLink, { ...props });
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
// Also reachable through the published SDK module, in case a plugin bundled it by mistake.
|
|
71
|
+
globalThis[Symbol.for('nextpluggable.host-runtime')] = hostRuntime;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a component from a plugin bundle that was not part of this site's build.
|
|
3
|
+
*
|
|
4
|
+
* Server (SSR pass): the bundle's source was placed in process memory by the
|
|
5
|
+
* server component that rendered us; it is evaluated here with THIS module
|
|
6
|
+
* graph's React, so the plugin renders into the same tree as the rest of the page.
|
|
7
|
+
*
|
|
8
|
+
* Browser: the same bundle arrives as a <script>, registers itself, and is
|
|
9
|
+
* given the browser's React. `use()` suspends until it has, and React keeps the
|
|
10
|
+
* server-rendered HTML in place and hydrates it once the module is ready.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from 'react';
|
|
13
|
+
import * as jsxRuntime from 'react/jsx-runtime';
|
|
14
|
+
import type { PluginInfo } from '@nextpluggable/plugin';
|
|
15
|
+
export interface PluginIslandProps {
|
|
16
|
+
moduleKey: string;
|
|
17
|
+
src: string;
|
|
18
|
+
/** Name of the export to render. */
|
|
19
|
+
component: string;
|
|
20
|
+
info: PluginInfo;
|
|
21
|
+
config: Record<string, unknown>;
|
|
22
|
+
props?: Record<string, unknown>;
|
|
23
|
+
/** Shown if the plugin crashes. Slots default to nothing so a broken widget never breaks the page around it. */
|
|
24
|
+
errorFallback?: React.ReactNode;
|
|
25
|
+
/** Shown while the bundle loads on client-side navigation. */
|
|
26
|
+
loadingFallback?: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export declare function PluginIsland(p: PluginIslandProps): jsxRuntime.JSX.Element;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Renders a component from a plugin bundle that was not part of this site's build.
|
|
5
|
+
*
|
|
6
|
+
* Server (SSR pass): the bundle's source was placed in process memory by the
|
|
7
|
+
* server component that rendered us; it is evaluated here with THIS module
|
|
8
|
+
* graph's React, so the plugin renders into the same tree as the rest of the page.
|
|
9
|
+
*
|
|
10
|
+
* Browser: the same bundle arrives as a <script>, registers itself, and is
|
|
11
|
+
* given the browser's React. `use()` suspends until it has, and React keeps the
|
|
12
|
+
* server-rendered HTML in place and hydrates it once the module is ready.
|
|
13
|
+
*/
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import * as ReactDOM from 'react-dom';
|
|
16
|
+
import * as jsxRuntime from 'react/jsx-runtime';
|
|
17
|
+
import { hostRuntime, PluginRuntimeContext } from './host-runtime.js';
|
|
18
|
+
import { ClientOnly, createGuardedRuntime } from './ssr-guard.js';
|
|
19
|
+
const shared = {
|
|
20
|
+
react: React,
|
|
21
|
+
'react/jsx-runtime': jsxRuntime,
|
|
22
|
+
'react-dom': ReactDOM,
|
|
23
|
+
'@nextpluggable/plugin': hostRuntime,
|
|
24
|
+
};
|
|
25
|
+
function hostRequire(name) {
|
|
26
|
+
if (name in shared)
|
|
27
|
+
return shared[name];
|
|
28
|
+
throw new Error(`A plugin tried to load "${name}", which this site does not provide. Bundle it into the plugin instead.`);
|
|
29
|
+
}
|
|
30
|
+
function instantiate(factory, require = hostRequire) {
|
|
31
|
+
const module = { exports: {} };
|
|
32
|
+
factory(require, module, module.exports);
|
|
33
|
+
return module.exports;
|
|
34
|
+
}
|
|
35
|
+
/** Server only: the crash guard each plugin bundle was instantiated with. */
|
|
36
|
+
const guards = new Map();
|
|
37
|
+
function settled(value) {
|
|
38
|
+
const p = Promise.resolve(value);
|
|
39
|
+
p.status = 'fulfilled';
|
|
40
|
+
p.value = value;
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
function failed(reason) {
|
|
44
|
+
const p = Promise.reject(reason);
|
|
45
|
+
p.catch(() => { });
|
|
46
|
+
p.status = 'rejected';
|
|
47
|
+
p.reason = reason;
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
const modules = new Map();
|
|
51
|
+
const pending = new Map();
|
|
52
|
+
const LOAD_TIMEOUT_MS = 20_000;
|
|
53
|
+
function register(key, factory) {
|
|
54
|
+
const waiter = pending.get(key);
|
|
55
|
+
pending.delete(key);
|
|
56
|
+
try {
|
|
57
|
+
const mod = instantiate(factory);
|
|
58
|
+
if (waiter)
|
|
59
|
+
waiter.resolve(mod);
|
|
60
|
+
else
|
|
61
|
+
modules.set(key, settled(mod));
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (waiter)
|
|
65
|
+
waiter.reject(e);
|
|
66
|
+
else
|
|
67
|
+
modules.set(key, failed(e));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (typeof window !== 'undefined') {
|
|
71
|
+
// Bundles that arrived before this module ran queued themselves; later ones call straight in.
|
|
72
|
+
const w = window;
|
|
73
|
+
const queued = Array.isArray(w.__NP_Q__) ? w.__NP_Q__ : [];
|
|
74
|
+
w.__NP_Q__ = { push: (entry) => register(entry[0], entry[1]) };
|
|
75
|
+
for (const entry of queued)
|
|
76
|
+
register(entry[0], entry[1]);
|
|
77
|
+
}
|
|
78
|
+
function loadModule(key, src) {
|
|
79
|
+
const existing = modules.get(key);
|
|
80
|
+
if (existing)
|
|
81
|
+
return existing;
|
|
82
|
+
let tracked;
|
|
83
|
+
if (typeof window === 'undefined') {
|
|
84
|
+
const state = globalThis[Symbol.for('nextpluggable.state')];
|
|
85
|
+
const code = state?.ssrCode?.get(key);
|
|
86
|
+
// Keep only the newest build of each plugin in memory.
|
|
87
|
+
const prefix = `${key.split('@')[0]}@`;
|
|
88
|
+
for (const k of modules.keys())
|
|
89
|
+
if (k.startsWith(prefix))
|
|
90
|
+
(modules.delete(k), guards.delete(k));
|
|
91
|
+
try {
|
|
92
|
+
if (code === undefined)
|
|
93
|
+
throw new Error(`Plugin bundle ${key} was not prepared for server rendering.`);
|
|
94
|
+
// On the server the plugin is given a React that contains its crashes (see ssr-guard).
|
|
95
|
+
const guarded = createGuardedRuntime(`Plugin "${key.split('@')[0]}"`);
|
|
96
|
+
guards.set(key, guarded);
|
|
97
|
+
const guardedRequire = (name) => (name === 'react' ? guarded.react : name === 'react/jsx-runtime' ? guarded.jsxRuntime : hostRequire(name));
|
|
98
|
+
tracked = settled(instantiate(new Function('require', 'module', 'exports', `${code}\n//# sourceURL=nextpluggable-plugin:///${key}/client.js`), guardedRequire));
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
console.error(`[nextpluggable] Plugin bundle ${key} could not be loaded on the server and was left out of the page:`, e);
|
|
102
|
+
tracked = failed(e);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
tracked = new Promise((resolve, reject) => {
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
pending.delete(key);
|
|
109
|
+
reject(new Error(`Timed out loading plugin bundle ${key}.`));
|
|
110
|
+
}, LOAD_TIMEOUT_MS);
|
|
111
|
+
pending.set(key, {
|
|
112
|
+
resolve: (m) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolve(m);
|
|
115
|
+
},
|
|
116
|
+
reject: (e) => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
reject(e);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
if (!document.querySelector(`script[src="${CSS.escape(src)}"]`)) {
|
|
122
|
+
const script = document.createElement('script');
|
|
123
|
+
script.src = src;
|
|
124
|
+
script.async = true;
|
|
125
|
+
script.onerror = () => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
pending.delete(key);
|
|
128
|
+
reject(new Error(`Could not load plugin bundle ${key}.`));
|
|
129
|
+
};
|
|
130
|
+
document.head.appendChild(script);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
tracked.then((value) => {
|
|
134
|
+
tracked.status = 'fulfilled';
|
|
135
|
+
tracked.value = value;
|
|
136
|
+
}, (reason) => {
|
|
137
|
+
tracked.status = 'rejected';
|
|
138
|
+
tracked.reason = reason;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
modules.set(key, tracked);
|
|
142
|
+
return tracked;
|
|
143
|
+
}
|
|
144
|
+
function Loaded(props) {
|
|
145
|
+
const tracked = loadModule(props.moduleKey, props.src);
|
|
146
|
+
if (typeof window === 'undefined') {
|
|
147
|
+
// A bundle that failed to load must not fail the page: leave it to the browser, where the error boundary handles it.
|
|
148
|
+
const Component = tracked.status === 'fulfilled' ? tracked.value?.[props.component] : undefined;
|
|
149
|
+
if (typeof Component !== 'function')
|
|
150
|
+
return _jsx(ClientOnly, {});
|
|
151
|
+
const Guarded = (guards.get(props.moduleKey)?.guard(Component) ?? Component);
|
|
152
|
+
return _jsx(Guarded, { ...props.componentProps });
|
|
153
|
+
}
|
|
154
|
+
const mod = React.use(tracked);
|
|
155
|
+
const Component = mod[props.component];
|
|
156
|
+
if (typeof Component !== 'function')
|
|
157
|
+
throw new Error(`Plugin bundle ${props.moduleKey} does not export "${props.component}".`);
|
|
158
|
+
return _jsx(Component, { ...props.componentProps });
|
|
159
|
+
}
|
|
160
|
+
class Boundary extends React.Component {
|
|
161
|
+
state = { failed: false };
|
|
162
|
+
static getDerivedStateFromError() {
|
|
163
|
+
return { failed: true };
|
|
164
|
+
}
|
|
165
|
+
componentDidCatch(error) {
|
|
166
|
+
console.error(`[nextpluggable] ${this.props.label} failed to render:`, error);
|
|
167
|
+
}
|
|
168
|
+
render() {
|
|
169
|
+
return this.state.failed ? this.props.fallback : this.props.children;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export function PluginIsland(p) {
|
|
173
|
+
// Start fetching the bundle as early as possible; during SSR this emits an async <script> in the document head.
|
|
174
|
+
ReactDOM.preinit(p.src, { as: 'script' });
|
|
175
|
+
const value = React.useMemo(() => ({ info: p.info, config: p.config }), [p.info, p.config]);
|
|
176
|
+
return (_jsx(PluginRuntimeContext.Provider, { value: value, children: _jsx(Boundary, { fallback: p.errorFallback ?? null, label: `Plugin "${p.info.id}"`, children: _jsx(React.Suspense, { fallback: p.loadingFallback ?? null, children: _jsx(Loaded, { moduleKey: p.moduleKey, src: p.src, component: p.component, componentProps: p.props ?? {} }) }) }) }));
|
|
177
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as jsxRuntime from 'react/jsx-runtime';
|
|
3
|
+
/** Renders nothing in the browser; on the server it hands the enclosing Suspense boundary over to client rendering. */
|
|
4
|
+
export declare const ClientOnly: React.ComponentType<{}>;
|
|
5
|
+
export interface GuardedRuntime {
|
|
6
|
+
/** Wrap one component type. */
|
|
7
|
+
guard<T>(type: T): T;
|
|
8
|
+
react: typeof React;
|
|
9
|
+
jsxRuntime: typeof jsxRuntime;
|
|
10
|
+
}
|
|
11
|
+
export declare function createGuardedRuntime(label: string): GuardedRuntime;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/**
|
|
4
|
+
* Keeps a crashing plugin from failing the server render of the page around it.
|
|
5
|
+
*
|
|
6
|
+
* React has no error boundaries on the server: a component that throws there
|
|
7
|
+
* fails the nearest Suspense boundary, and for statically cached pages Next.js
|
|
8
|
+
* then (rightly) refuses to produce the page at all. A widget in the site's
|
|
9
|
+
* layout would take every page down with it.
|
|
10
|
+
*
|
|
11
|
+
* Plugins get React from the host, so on the server the host hands them a
|
|
12
|
+
* React whose element factories wrap every plugin function component in a
|
|
13
|
+
* try/catch. When one throws, it is replaced by a client-only placeholder:
|
|
14
|
+
* the page is produced without that plugin's HTML and the browser renders the
|
|
15
|
+
* plugin itself. If it throws there too, the island's error boundary shows the
|
|
16
|
+
* fallback; if it only failed on the server (say, it read `window` while
|
|
17
|
+
* rendering), it simply works.
|
|
18
|
+
*/
|
|
19
|
+
import dynamic from 'next/dynamic';
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
import * as jsxRuntime from 'react/jsx-runtime';
|
|
22
|
+
/** Renders nothing in the browser; on the server it hands the enclosing Suspense boundary over to client rendering. */
|
|
23
|
+
export const ClientOnly = dynamic(() => Promise.resolve({ default: () => null }), { ssr: false });
|
|
24
|
+
const MEMO = Symbol.for('react.memo');
|
|
25
|
+
const FORWARD_REF = Symbol.for('react.forward_ref');
|
|
26
|
+
/** Things components throw on purpose: suspending (a promise, or React's own marker) and framework control flow. */
|
|
27
|
+
function isControlFlow(e) {
|
|
28
|
+
if (e === null || typeof e !== 'object')
|
|
29
|
+
return false;
|
|
30
|
+
const x = e;
|
|
31
|
+
return typeof x.then === 'function' || typeof x.digest === 'string' || (typeof x.message === 'string' && x.message.startsWith('Suspense Exception'));
|
|
32
|
+
}
|
|
33
|
+
export function createGuardedRuntime(label) {
|
|
34
|
+
const wrapped = new WeakMap();
|
|
35
|
+
let reported = false;
|
|
36
|
+
const crashed = (error) => {
|
|
37
|
+
if (!reported) {
|
|
38
|
+
reported = true;
|
|
39
|
+
console.error(`[nextpluggable] ${label} crashed while rendering on the server and was left out of the page:`, error);
|
|
40
|
+
}
|
|
41
|
+
return _jsx(ClientOnly, {});
|
|
42
|
+
};
|
|
43
|
+
function guard(type) {
|
|
44
|
+
if (type === null || (typeof type !== 'function' && typeof type !== 'object'))
|
|
45
|
+
return type;
|
|
46
|
+
const hit = wrapped.get(type);
|
|
47
|
+
if (hit)
|
|
48
|
+
return hit;
|
|
49
|
+
let result = type;
|
|
50
|
+
const t = type;
|
|
51
|
+
if (typeof type === 'function') {
|
|
52
|
+
// Class components cannot be called directly; they stay as they are.
|
|
53
|
+
if (!t.prototype?.isReactComponent) {
|
|
54
|
+
const component = type;
|
|
55
|
+
const Guarded = (props) => {
|
|
56
|
+
try {
|
|
57
|
+
return component(props);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (isControlFlow(e))
|
|
61
|
+
throw e;
|
|
62
|
+
return crashed(e);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
Guarded.displayName = t.displayName ?? t.name;
|
|
66
|
+
result = Guarded;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else if (t.$$typeof === MEMO) {
|
|
70
|
+
const inner = guard(t.type);
|
|
71
|
+
if (inner !== t.type)
|
|
72
|
+
result = React.memo(inner, t.compare);
|
|
73
|
+
}
|
|
74
|
+
else if (t.$$typeof === FORWARD_REF) {
|
|
75
|
+
const render = t.render;
|
|
76
|
+
result = React.forwardRef((props, ref) => {
|
|
77
|
+
try {
|
|
78
|
+
return render(props, ref);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (isControlFlow(e))
|
|
82
|
+
throw e;
|
|
83
|
+
return crashed(e);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
wrapped.set(type, result);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
const jsx = jsxRuntime;
|
|
91
|
+
return {
|
|
92
|
+
guard,
|
|
93
|
+
react: { ...React, createElement: (type, ...rest) => React.createElement(guard(type), ...rest) },
|
|
94
|
+
jsxRuntime: {
|
|
95
|
+
...jsxRuntime,
|
|
96
|
+
jsx: (type, ...rest) => jsx.jsx(guard(type), ...rest),
|
|
97
|
+
jsxs: (type, ...rest) => jsx.jsxs(guard(type), ...rest),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AuthAdapter } from './auth/types.js';
|
|
2
|
+
import type { StorageAdapter } from './storage/types.js';
|
|
3
|
+
export interface NextPluggableConfig {
|
|
4
|
+
/** Who may use the dashboard. Default: `passwordAuth()` reading `NEXTPLUGGABLE_PASSWORD`. */
|
|
5
|
+
auth?: AuthAdapter;
|
|
6
|
+
/** Where data lives. Default: `fileStorage()` (a `.nextpluggable` folder). */
|
|
7
|
+
storage?: StorageAdapter;
|
|
8
|
+
/** Signs sessions and encrypts stored secrets. Default: `NEXTPLUGGABLE_SECRET`. Required in production. */
|
|
9
|
+
secret?: string;
|
|
10
|
+
/** URL path of the dashboard. Must match where you put the dashboard page. Default `/admin/plugins`. */
|
|
11
|
+
dashboardPath?: string;
|
|
12
|
+
/** URL path of the API route. Must match where you put the route handler. Default `/api/nextpluggable`. */
|
|
13
|
+
apiPath?: string;
|
|
14
|
+
/** Public origin of the site, e.g. `https://example.com`. Default: `NEXT_PUBLIC_SITE_URL`, then the host's own URL. */
|
|
15
|
+
siteUrl?: string;
|
|
16
|
+
/** Shown in the dashboard header. */
|
|
17
|
+
siteName?: string;
|
|
18
|
+
github?: {
|
|
19
|
+
/** API base, for GitHub Enterprise. Default `https://api.github.com`. */
|
|
20
|
+
apiUrl?: string;
|
|
21
|
+
/** A token used for every GitHub source that has none of its own. Default: `NEXTPLUGGABLE_GITHUB_TOKEN`. */
|
|
22
|
+
token?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Allow plugin sources that are folders on the server. Default: only outside production, or when `NEXTPLUGGABLE_ALLOW_LOCAL_SOURCES=1`. */
|
|
25
|
+
allowLocalSources?: boolean;
|
|
26
|
+
/** How long the list of installed plugins is cached in memory per server instance. Default 5000 (0 in development). */
|
|
27
|
+
cacheTtlMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Write small plugin stylesheets into the page instead of linking to them, so a plugin
|
|
30
|
+
* never adds a request the browser must wait for. Default true. Set false on sites
|
|
31
|
+
* whose Content Security Policy forbids inline styles.
|
|
32
|
+
*/
|
|
33
|
+
inlineStyles?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface ResolvedConfig {
|
|
36
|
+
auth: AuthAdapter;
|
|
37
|
+
storage: StorageAdapter;
|
|
38
|
+
secret: string;
|
|
39
|
+
dashboardPath: string;
|
|
40
|
+
apiPath: string;
|
|
41
|
+
siteUrl: string;
|
|
42
|
+
siteName: string;
|
|
43
|
+
/** The site name only when the developer set one: used where a placeholder would be wrong (sharing previews, structured data). */
|
|
44
|
+
publicSiteName: string | undefined;
|
|
45
|
+
inlineStyles: boolean;
|
|
46
|
+
githubApiUrl: string;
|
|
47
|
+
githubToken: string | undefined;
|
|
48
|
+
allowLocalSources: boolean;
|
|
49
|
+
cacheTtlMs: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Settings are resolved lazily (on first use, not at import) so that `next build`
|
|
53
|
+
* succeeds in environments where runtime secrets are not present.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveConfig(config: NextPluggableConfig): () => ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { passwordAuth } from './auth/password.js';
|
|
2
|
+
import { warnOnce } from './globals.js';
|
|
3
|
+
import { fileStorage } from './storage/file.js';
|
|
4
|
+
function cleanPath(value, name) {
|
|
5
|
+
const p = `/${value}`.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
|
|
6
|
+
if (!/^(\/[A-Za-z0-9._~-]+)+$/.test(p))
|
|
7
|
+
throw new Error(`[nextpluggable] "${name}" must be a URL path like "/admin/plugins" (got ${JSON.stringify(value)}).`);
|
|
8
|
+
return p;
|
|
9
|
+
}
|
|
10
|
+
function defaultSiteUrl() {
|
|
11
|
+
const explicit = process.env.NEXT_PUBLIC_SITE_URL ?? process.env.SITE_URL;
|
|
12
|
+
if (explicit)
|
|
13
|
+
return explicit;
|
|
14
|
+
if (process.env.VERCEL_PROJECT_PRODUCTION_URL)
|
|
15
|
+
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
|
|
16
|
+
if (process.env.VERCEL_URL)
|
|
17
|
+
return `https://${process.env.VERCEL_URL}`;
|
|
18
|
+
return `http://localhost:${process.env.PORT ?? 3000}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Settings are resolved lazily (on first use, not at import) so that `next build`
|
|
22
|
+
* succeeds in environments where runtime secrets are not present.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveConfig(config) {
|
|
25
|
+
let resolved;
|
|
26
|
+
return () => {
|
|
27
|
+
if (resolved)
|
|
28
|
+
return resolved;
|
|
29
|
+
const production = process.env.NODE_ENV === 'production';
|
|
30
|
+
let secret = config.secret ?? process.env.NEXTPLUGGABLE_SECRET;
|
|
31
|
+
if (!secret) {
|
|
32
|
+
if (production)
|
|
33
|
+
throw new Error('[nextpluggable] NEXTPLUGGABLE_SECRET is not set. Add a long random value to your environment (for example the output of `openssl rand -base64 32`).');
|
|
34
|
+
warnOnce('dev-secret', 'NEXTPLUGGABLE_SECRET is not set; using an insecure development secret.');
|
|
35
|
+
secret = 'nextpluggable-insecure-development-secret';
|
|
36
|
+
}
|
|
37
|
+
else if (secret.length < 16) {
|
|
38
|
+
throw new Error('[nextpluggable] NEXTPLUGGABLE_SECRET is too short. Use at least 16 characters.');
|
|
39
|
+
}
|
|
40
|
+
resolved = {
|
|
41
|
+
auth: config.auth ?? passwordAuth(),
|
|
42
|
+
storage: config.storage ?? fileStorage(),
|
|
43
|
+
secret,
|
|
44
|
+
dashboardPath: cleanPath(config.dashboardPath ?? '/admin/plugins', 'dashboardPath'),
|
|
45
|
+
apiPath: cleanPath(config.apiPath ?? '/api/nextpluggable', 'apiPath'),
|
|
46
|
+
siteUrl: (config.siteUrl ?? defaultSiteUrl()).replace(/\/+$/, ''),
|
|
47
|
+
siteName: config.siteName ?? 'Your site',
|
|
48
|
+
publicSiteName: config.siteName?.trim() || undefined,
|
|
49
|
+
inlineStyles: config.inlineStyles ?? true,
|
|
50
|
+
githubApiUrl: (config.github?.apiUrl ?? process.env.NEXTPLUGGABLE_GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, ''),
|
|
51
|
+
githubToken: config.github?.token ?? process.env.NEXTPLUGGABLE_GITHUB_TOKEN ?? undefined,
|
|
52
|
+
allowLocalSources: config.allowLocalSources ?? (!production || process.env.NEXTPLUGGABLE_ALLOW_LOCAL_SOURCES === '1'),
|
|
53
|
+
cacheTtlMs: config.cacheTtlMs ?? (production ? 5000 : 0),
|
|
54
|
+
};
|
|
55
|
+
return resolved;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PluginManifest } from '@nextpluggable/plugin/manifest';
|
|
2
|
+
import type { ResolvedConfig } from '../config.js';
|
|
3
|
+
import type { Catalog, InstallRecord, SourceRecord } from './types.js';
|
|
4
|
+
/** True while a required setting has no value — the plugin stays paused until the owner fills it in. */
|
|
5
|
+
export declare function needsSetup(manifest: PluginManifest, config: InstallRecord['config']): boolean;
|
|
6
|
+
export declare function addSource(cfg: ResolvedConfig, input: {
|
|
7
|
+
url?: unknown;
|
|
8
|
+
token?: unknown;
|
|
9
|
+
}): Promise<SourceRecord>;
|
|
10
|
+
export declare function refreshSource(cfg: ResolvedConfig, source: SourceRecord): Promise<Catalog>;
|
|
11
|
+
/** Refresh every source; one failing source does not stop the others. */
|
|
12
|
+
export declare function refreshAllSources(cfg: ResolvedConfig): Promise<{
|
|
13
|
+
label: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}[]>;
|
|
16
|
+
export declare function removeSource(cfg: ResolvedConfig, sourceId: string): Promise<void>;
|
|
17
|
+
export declare function installPlugin(cfg: ResolvedConfig, input: {
|
|
18
|
+
sourceId?: unknown;
|
|
19
|
+
path?: unknown;
|
|
20
|
+
}): Promise<InstallRecord>;
|
|
21
|
+
export declare function updatePlugin(cfg: ResolvedConfig, pluginId: string): Promise<InstallRecord>;
|
|
22
|
+
export interface PluginPatch {
|
|
23
|
+
enabled?: unknown;
|
|
24
|
+
config?: unknown;
|
|
25
|
+
paths?: unknown;
|
|
26
|
+
}
|
|
27
|
+
export declare function changePlugin(cfg: ResolvedConfig, pluginId: string, patch: PluginPatch): Promise<InstallRecord>;
|
|
28
|
+
export declare function uninstallPlugin(cfg: ResolvedConfig, pluginId: string, options?: {
|
|
29
|
+
deleteData?: boolean;
|
|
30
|
+
}): Promise<void>;
|