nextpluggable 0.0.0-stage → 0.1.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.
Files changed (71) hide show
  1. package/bin/nextpluggable.js +10 -0
  2. package/dist/api/handler.d.ts +10 -0
  3. package/dist/api/handler.js +188 -0
  4. package/dist/auth/custom.d.ts +13 -0
  5. package/dist/auth/custom.js +12 -0
  6. package/dist/auth/password.d.ts +10 -0
  7. package/dist/auth/password.js +66 -0
  8. package/dist/auth/types.d.ts +23 -0
  9. package/dist/auth/types.js +1 -0
  10. package/dist/cli.d.ts +9 -0
  11. package/dist/cli.js +119 -0
  12. package/dist/client/host-runtime.d.ts +9 -0
  13. package/dist/client/host-runtime.js +71 -0
  14. package/dist/client/island.d.ts +28 -0
  15. package/dist/client/island.js +177 -0
  16. package/dist/client/ssr-guard.d.ts +11 -0
  17. package/dist/client/ssr-guard.js +100 -0
  18. package/dist/config.d.ts +46 -0
  19. package/dist/config.js +55 -0
  20. package/dist/core/installer.d.ts +30 -0
  21. package/dist/core/installer.js +340 -0
  22. package/dist/core/routes.d.ts +20 -0
  23. package/dist/core/routes.js +95 -0
  24. package/dist/core/runtime.d.ts +32 -0
  25. package/dist/core/runtime.js +190 -0
  26. package/dist/core/state.d.ts +13 -0
  27. package/dist/core/state.js +23 -0
  28. package/dist/core/store.d.ts +17 -0
  29. package/dist/core/store.js +58 -0
  30. package/dist/core/types.d.ts +69 -0
  31. package/dist/core/types.js +1 -0
  32. package/dist/crypto.d.ts +14 -0
  33. package/dist/crypto.js +61 -0
  34. package/dist/dashboard/Dashboard.d.ts +11 -0
  35. package/dist/dashboard/Dashboard.js +324 -0
  36. package/dist/dashboard/styles.d.ts +2 -0
  37. package/dist/dashboard/styles.js +129 -0
  38. package/dist/dashboard/ui.d.ts +61 -0
  39. package/dist/dashboard/ui.js +135 -0
  40. package/dist/errors.d.ts +15 -0
  41. package/dist/errors.js +29 -0
  42. package/dist/globals.d.ts +24 -0
  43. package/dist/globals.js +24 -0
  44. package/dist/index.d.ts +40 -0
  45. package/dist/index.js +48 -0
  46. package/dist/next/dashboard-page.d.ts +5 -0
  47. package/dist/next/dashboard-page.js +23 -0
  48. package/dist/next/plugin-page.d.ts +16 -0
  49. package/dist/next/plugin-page.js +102 -0
  50. package/dist/next/slot.d.ts +7 -0
  51. package/dist/next/slot.js +42 -0
  52. package/dist/sources/github.d.ts +19 -0
  53. package/dist/sources/github.js +109 -0
  54. package/dist/sources/local.d.ts +3 -0
  55. package/dist/sources/local.js +22 -0
  56. package/dist/sources/reader.d.ts +17 -0
  57. package/dist/sources/reader.js +78 -0
  58. package/dist/storage/file.d.ts +12 -0
  59. package/dist/storage/file.js +61 -0
  60. package/dist/storage/memory.d.ts +3 -0
  61. package/dist/storage/memory.js +22 -0
  62. package/dist/storage/postgres.d.ts +15 -0
  63. package/dist/storage/postgres.js +39 -0
  64. package/dist/storage/types.d.ts +18 -0
  65. package/dist/storage/types.js +15 -0
  66. package/dist/storage/upstash.d.ts +15 -0
  67. package/dist/storage/upstash.js +61 -0
  68. package/dist/views.d.ts +112 -0
  69. package/dist/views.js +124 -0
  70. package/package.json +48 -3
  71. package/README.md +0 -3
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/cli.js')
3
+ .then((cli) => cli.main())
4
+ .then(
5
+ (code) => process.exit(code),
6
+ (err) => {
7
+ console.error(`✗ ${err instanceof Error ? err.message : String(err)}`);
8
+ process.exit(1);
9
+ },
10
+ );
@@ -0,0 +1,10 @@
1
+ import type { ResolvedConfig } from '../config.js';
2
+ import { type SessionView } from '../views.js';
3
+ export interface HandlerHooks {
4
+ /** Called after anything that changes what the public site renders. */
5
+ onSiteChange?: () => void | Promise<void>;
6
+ }
7
+ /** Header every dashboard/plugin write must carry. Browsers will not send it cross-site without a CORS preflight, which we never grant. */
8
+ export declare const CSRF_HEADER = "x-nextpluggable";
9
+ export declare function sessionView(cfg: ResolvedConfig, request: Request): Promise<SessionView>;
10
+ export declare function createHandler(getConfig: () => ResolvedConfig, hooks?: HandlerHooks): (request: Request) => Promise<Response>;
@@ -0,0 +1,188 @@
1
+ import { addSource, changePlugin, installPlugin, refreshAllSources, removeSource, uninstallPlugin, updatePlugin } from '../core/installer.js';
2
+ import { handlePluginApi } from '../core/runtime.js';
3
+ import { getInstalledPlugins } from '../core/state.js';
4
+ import { getFile, getInstalls } from '../core/store.js';
5
+ import { HttpError, jsonResponse } from '../errors.js';
6
+ import { warnOnce } from '../globals.js';
7
+ import { buildDashboardState } from '../views.js';
8
+ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
9
+ /** Header every dashboard/plugin write must carry. Browsers will not send it cross-site without a CORS preflight, which we never grant. */
10
+ export const CSRF_HEADER = 'x-nextpluggable';
11
+ const hostOf = (value) => {
12
+ if (!value)
13
+ return null;
14
+ try {
15
+ return new URL(value.includes('://') ? value : `http://${value}`).host.toLowerCase();
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ };
21
+ /**
22
+ * A browser states where a write came from in its Origin header. It must be this site: the host
23
+ * the request was addressed to, the host a proxy says it forwarded, or the site's configured
24
+ * public address (for proxies that rewrite the host and forward nothing).
25
+ */
26
+ function sameOrigin(request, cfg) {
27
+ const origin = request.headers.get('origin');
28
+ if (!origin)
29
+ return true; // non-browser clients; the custom header check still applies
30
+ const from = hostOf(origin);
31
+ if (!from)
32
+ return false;
33
+ const here = [request.headers.get('x-forwarded-host')?.split(',')[0]?.trim(), request.headers.get('host'), new URL(request.url).host, cfg.siteUrl].map(hostOf);
34
+ if (here.includes(from))
35
+ return true;
36
+ warnOnce(`origin:${from}`, `Blocked a write from ${origin}: it does not match this site (addressed to ${request.headers.get('host') ?? 'unknown'}, forwarded host ${request.headers.get('x-forwarded-host') ?? 'none'}, configured address ${cfg.siteUrl}). If the site sits behind a proxy, set NEXT_PUBLIC_SITE_URL (or siteUrl) to its public address.`);
37
+ return false;
38
+ }
39
+ /** Look a plugin up by an id that came from a URL, ignoring inherited object properties. */
40
+ const own = (map, key) => (Object.hasOwn(map, key) ? map[key] : undefined);
41
+ const csrfOk = (request, cfg) => request.headers.get(CSRF_HEADER) === '1' && sameOrigin(request, cfg);
42
+ async function readBody(request) {
43
+ const text = await request.text();
44
+ if (text.length > 200_000)
45
+ throw new HttpError(413, 'That request is too large.');
46
+ if (!text)
47
+ return {};
48
+ try {
49
+ const body = JSON.parse(text);
50
+ return typeof body === 'object' && body !== null && !Array.isArray(body) ? body : {};
51
+ }
52
+ catch {
53
+ throw new HttpError(400, 'Invalid request.');
54
+ }
55
+ }
56
+ export async function sessionView(cfg, request) {
57
+ let user = null;
58
+ try {
59
+ user = await cfg.auth.getUser(request, { secret: cfg.secret });
60
+ }
61
+ catch (e) {
62
+ console.error('[nextpluggable] auth.getUser failed:', e);
63
+ }
64
+ return { user, mode: cfg.auth.kind, configured: cfg.auth.configured, loginUrl: cfg.auth.loginUrl };
65
+ }
66
+ const ASSET_TYPES = {
67
+ 'client.js': { kind: 'client', type: 'text/javascript; charset=utf-8' },
68
+ 'style.css': { kind: 'style', type: 'text/css; charset=utf-8' },
69
+ };
70
+ async function serveAsset(cfg, pluginId, hash, name) {
71
+ const asset = ASSET_TYPES[name];
72
+ if (!asset || !/^[0-9a-f]{16}$/.test(hash))
73
+ return jsonResponse({ error: 'Not found.' }, 404);
74
+ const install = own(await getInstalledPlugins(cfg), pluginId) ?? own(await getInstalls(cfg.storage), pluginId);
75
+ // The previous version's files stay available so pages cached before an update still load.
76
+ const file = [install?.files[asset.kind], install?.previousFiles?.[asset.kind]].find((f) => f?.sha256.startsWith(hash));
77
+ const content = file ? await getFile(cfg.storage, pluginId, asset.kind, file.sha256) : null;
78
+ if (content === null)
79
+ return jsonResponse({ error: 'Not found.' }, 404);
80
+ const body = asset.kind === 'client'
81
+ ? // One artifact, two consumers: the server evaluates the module body directly; browsers get it
82
+ // wrapped in a registration call, so no eval (and no `unsafe-eval` CSP) is needed client-side.
83
+ `(self.__NP_Q__=self.__NP_Q__||[]).push([${JSON.stringify(`${pluginId}@${hash}`)},function(require,module,exports){${content}\n}]);`
84
+ : content;
85
+ return new Response(body, {
86
+ headers: {
87
+ 'content-type': asset.type,
88
+ 'cache-control': 'public, max-age=31536000, immutable',
89
+ 'x-content-type-options': 'nosniff',
90
+ },
91
+ });
92
+ }
93
+ export function createHandler(getConfig, hooks = {}) {
94
+ async function route(request) {
95
+ const cfg = getConfig();
96
+ const pathname = new URL(request.url).pathname;
97
+ const at = pathname.indexOf(cfg.apiPath);
98
+ const sub = at === -1 ? pathname : pathname.slice(at + cfg.apiPath.length);
99
+ const seg = sub.split('/').filter(Boolean).map((s) => decodeURIComponent(s));
100
+ const method = request.method.toUpperCase();
101
+ const env = { secret: cfg.secret };
102
+ // ---- public: plugin assets
103
+ if (seg[0] === 'assets' && seg.length === 4 && (method === 'GET' || method === 'HEAD'))
104
+ return serveAsset(cfg, seg[1], seg[2], seg[3]);
105
+ // ---- public: plugin APIs
106
+ if (seg[0] === 'p' && seg[1]) {
107
+ const install = own(await getInstalledPlugins(cfg), seg[1]);
108
+ if (!install || !install.enabled)
109
+ return jsonResponse({ error: 'Not found.' }, 404);
110
+ // A plugin only sees the dashboard user when the request provably came from this site's own pages.
111
+ const admin = !MUTATING.has(method) || csrfOk(request, cfg) ? await cfg.auth.getUser(request, env).catch(() => null) : null;
112
+ const res = await handlePluginApi(cfg, install, request, `/${seg.slice(2).join('/')}`, admin);
113
+ if (MUTATING.has(method) && admin)
114
+ await hooks.onSiteChange?.();
115
+ return res;
116
+ }
117
+ // ---- session
118
+ if (seg[0] === 'session' && method === 'GET')
119
+ return jsonResponse(await sessionView(cfg, request));
120
+ if (MUTATING.has(method) && !csrfOk(request, cfg))
121
+ throw new HttpError(403, 'This request was blocked for security reasons. Reload the page and try again.');
122
+ if (seg[0] === 'login' && method === 'POST') {
123
+ if (!cfg.auth.login)
124
+ throw new HttpError(404, 'This site signs in a different way.');
125
+ return cfg.auth.login(request, env);
126
+ }
127
+ if (seg[0] === 'logout' && method === 'POST')
128
+ return cfg.auth.logout ? cfg.auth.logout(request, env) : jsonResponse({ ok: true });
129
+ // ---- dashboard (signed-in only)
130
+ if (seg[0] !== 'admin')
131
+ throw new HttpError(404, 'Not found.');
132
+ const user = await cfg.auth.getUser(request, env);
133
+ if (!user)
134
+ throw new HttpError(401, 'Please sign in again.');
135
+ const state = async (extra) => jsonResponse({ ...(await buildDashboardState(cfg)), ...extra });
136
+ const changed = async (extra) => {
137
+ await hooks.onSiteChange?.();
138
+ return state(extra);
139
+ };
140
+ if (seg[1] === 'state' && method === 'GET')
141
+ return state();
142
+ if (seg[1] === 'refresh' && method === 'POST')
143
+ return state({ failures: await refreshAllSources(cfg) });
144
+ if (seg[1] === 'sources') {
145
+ if (method === 'POST' && seg.length === 2) {
146
+ const source = await addSource(cfg, await readBody(request));
147
+ return state({ added: source.id });
148
+ }
149
+ if (method === 'DELETE' && seg[2]) {
150
+ await removeSource(cfg, seg[2]);
151
+ return state();
152
+ }
153
+ }
154
+ if (seg[1] === 'install' && method === 'POST') {
155
+ const record = await installPlugin(cfg, await readBody(request));
156
+ return changed({ installed: record.id });
157
+ }
158
+ if (seg[1] === 'plugins' && seg[2]) {
159
+ const id = seg[2];
160
+ if (seg[3] === 'update' && method === 'POST') {
161
+ await updatePlugin(cfg, id);
162
+ return changed();
163
+ }
164
+ if (seg.length === 3 && method === 'PATCH') {
165
+ await changePlugin(cfg, id, await readBody(request));
166
+ return changed();
167
+ }
168
+ if (seg.length === 3 && method === 'DELETE') {
169
+ const body = await readBody(request);
170
+ await uninstallPlugin(cfg, id, { deleteData: body.deleteData === true });
171
+ return changed();
172
+ }
173
+ }
174
+ throw new HttpError(404, 'Not found.');
175
+ }
176
+ return async function handle(request) {
177
+ try {
178
+ return await route(request);
179
+ }
180
+ catch (e) {
181
+ if (e instanceof HttpError)
182
+ return jsonResponse({ error: e.message, ...(e.fields ? { fields: e.fields } : {}) }, e.status);
183
+ console.error('[nextpluggable] Unexpected error:', e);
184
+ const message = e instanceof Error && e.message.startsWith('[nextpluggable]') ? e.message.replace('[nextpluggable] ', '') : 'Something went wrong on the server. Please try again.';
185
+ return jsonResponse({ error: message }, 500);
186
+ }
187
+ };
188
+ }
@@ -0,0 +1,13 @@
1
+ import type { AdminUser, AuthAdapter } from './types.js';
2
+ export interface CustomAuthOptions {
3
+ /**
4
+ * Return the signed-in administrator for this request, or null. Called for the
5
+ * dashboard page and for every dashboard API request, with the visitor's
6
+ * cookies and headers on `request`.
7
+ */
8
+ getUser: (request: Request) => Promise<AdminUser | null> | AdminUser | null;
9
+ /** Where to send people who are not signed in, e.g. `/login`. */
10
+ loginUrl?: string;
11
+ }
12
+ /** Use the site's existing login system (Auth.js, Clerk, Supabase, your own) to protect the dashboard. */
13
+ export declare function customAuth(options: CustomAuthOptions): AuthAdapter;
@@ -0,0 +1,12 @@
1
+ /** Use the site's existing login system (Auth.js, Clerk, Supabase, your own) to protect the dashboard. */
2
+ export function customAuth(options) {
3
+ return {
4
+ kind: 'custom',
5
+ configured: true,
6
+ loginUrl: options.loginUrl,
7
+ async getUser(request) {
8
+ const user = await options.getUser(request);
9
+ return user && typeof user.id === 'string' ? { id: user.id, name: user.name } : null;
10
+ },
11
+ };
12
+ }
@@ -0,0 +1,10 @@
1
+ import type { AuthAdapter } from './types.js';
2
+ export interface PasswordAuthOptions {
3
+ /** The dashboard password. Default: the `NEXTPLUGGABLE_PASSWORD` environment variable. */
4
+ password?: string;
5
+ /** How long a sign-in lasts. Default 7 days. */
6
+ sessionDays?: number;
7
+ cookieName?: string;
8
+ }
9
+ /** One shared password for the dashboard, with a signed, http-only session cookie. */
10
+ export declare function passwordAuth(options?: PasswordAuthOptions): AuthAdapter;
@@ -0,0 +1,66 @@
1
+ import { hmac, safeEqual, signToken, verifyToken } from '../crypto.js';
2
+ import { jsonResponse } from '../errors.js';
3
+ import { globals } from '../globals.js';
4
+ const WINDOW_MS = 10 * 60_000;
5
+ const MAX_ATTEMPTS = 8;
6
+ function clientAddress(request) {
7
+ return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || 'unknown';
8
+ }
9
+ function readCookie(request, name) {
10
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
11
+ const eq = part.indexOf('=');
12
+ if (eq !== -1 && part.slice(0, eq).trim() === name)
13
+ return decodeURIComponent(part.slice(eq + 1).trim());
14
+ }
15
+ return undefined;
16
+ }
17
+ function isHttps(request) {
18
+ const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();
19
+ return proto ? proto === 'https' : new URL(request.url).protocol === 'https:';
20
+ }
21
+ /** One shared password for the dashboard, with a signed, http-only session cookie. */
22
+ export function passwordAuth(options = {}) {
23
+ const cookieName = options.cookieName ?? 'np_session';
24
+ const days = options.sessionDays ?? 7;
25
+ const password = () => options.password ?? process.env.NEXTPLUGGABLE_PASSWORD ?? '';
26
+ // Sessions carry a fingerprint of the password, so changing it signs everyone out.
27
+ const fingerprint = (secret) => hmac(secret, `pw:${password()}`).slice(0, 16);
28
+ const cookie = (request, value, maxAge) => `${cookieName}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${isHttps(request) ? '; Secure' : ''}`;
29
+ return {
30
+ kind: 'password',
31
+ get configured() {
32
+ return password().length > 0;
33
+ },
34
+ async getUser(request, env) {
35
+ if (!password())
36
+ return null;
37
+ const session = verifyToken(env.secret, readCookie(request, cookieName));
38
+ return session && session.fp === fingerprint(env.secret) ? { id: 'admin', name: 'Site owner' } : null;
39
+ },
40
+ async login(request, env) {
41
+ if (!password())
42
+ return jsonResponse({ error: 'No dashboard password has been set up yet.' }, 503);
43
+ const attempts = globals().loginAttempts;
44
+ const who = clientAddress(request);
45
+ const now = Date.now();
46
+ const recent = (attempts.get(who) ?? []).filter((t) => now - t < WINDOW_MS);
47
+ if (recent.length >= MAX_ATTEMPTS) {
48
+ attempts.set(who, recent);
49
+ return jsonResponse({ error: 'Too many attempts. Please wait a few minutes and try again.' }, 429);
50
+ }
51
+ const body = (await request.json().catch(() => null));
52
+ const given = typeof body?.password === 'string' ? body.password : '';
53
+ if (!given || !safeEqual(given, password())) {
54
+ recent.push(now);
55
+ attempts.set(who, recent);
56
+ return jsonResponse({ error: 'That password is not right.' }, 401);
57
+ }
58
+ attempts.delete(who);
59
+ const token = signToken(env.secret, { exp: now + days * 86_400_000, fp: fingerprint(env.secret) });
60
+ return jsonResponse({ user: { id: 'admin', name: 'Site owner' } }, 200, { 'set-cookie': cookie(request, token, days * 86_400) });
61
+ },
62
+ async logout(request) {
63
+ return jsonResponse({ ok: true }, 200, { 'set-cookie': cookie(request, '', 0) });
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,23 @@
1
+ export interface AdminUser {
2
+ id: string;
3
+ name?: string;
4
+ }
5
+ export interface AuthEnv {
6
+ /** The site secret, for signing sessions. */
7
+ secret: string;
8
+ }
9
+ /**
10
+ * Decides who may use the dashboard. Use `passwordAuth()` for a single shared
11
+ * password, or `customAuth()` to defer to the site's own login (Auth.js, Clerk, …).
12
+ */
13
+ export interface AuthAdapter {
14
+ kind: 'password' | 'custom';
15
+ /** False when the adapter cannot work yet (e.g. no password set); the dashboard then shows setup steps. */
16
+ configured: boolean;
17
+ getUser(request: Request, env: AuthEnv): Promise<AdminUser | null>;
18
+ /** Password adapters: handle a sign-in attempt. */
19
+ login?(request: Request, env: AuthEnv): Promise<Response>;
20
+ logout?(request: Request, env: AuthEnv): Promise<Response>;
21
+ /** Custom adapters: where to send someone who is not signed in. */
22
+ loginUrl?: string;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface InitResult {
2
+ created: string[];
3
+ skipped: string[];
4
+ notes: string[];
5
+ /** Set when a dashboard password was generated. */
6
+ password?: string;
7
+ }
8
+ export declare function init(projectDir: string): Promise<InitResult>;
9
+ export declare function main(): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,119 @@
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
+ // Secrets for local development.
70
+ const envFile = path.join(root, '.env.local');
71
+ const env = existsSync(envFile) ? await readFile(envFile, 'utf8') : '';
72
+ const additions = [];
73
+ if (!/^NEXTPLUGGABLE_PASSWORD=/m.test(env)) {
74
+ result.password = randomBytes(9).toString('base64url');
75
+ additions.push(`NEXTPLUGGABLE_PASSWORD=${result.password}`);
76
+ }
77
+ if (!/^NEXTPLUGGABLE_SECRET=/m.test(env))
78
+ additions.push(`NEXTPLUGGABLE_SECRET=${randomBytes(32).toString('base64url')}`);
79
+ if (additions.length) {
80
+ await appendFile(envFile, `${env && !env.endsWith('\n') ? '\n' : ''}\n# NextPluggable (plugin dashboard)\n${additions.join('\n')}\n`);
81
+ result.created.push('.env.local (added NextPluggable settings)');
82
+ }
83
+ const gitignore = path.join(root, '.gitignore');
84
+ if (existsSync(gitignore)) {
85
+ const current = await readFile(gitignore, 'utf8');
86
+ if (!/^\/?\.nextpluggable\/?$/m.test(current))
87
+ await appendFile(gitignore, `${current.endsWith('\n') ? '' : '\n'}\n# NextPluggable local data\n.nextpluggable/\n`);
88
+ if (!/^\.env(\.local|\*|\.\*)?$/m.test(current))
89
+ result.notes.push('Make sure .env.local is not committed: it now holds your dashboard password.');
90
+ }
91
+ return result;
92
+ }
93
+ const HELP = `nextpluggable — WordPress-style plugins for Next.js
94
+
95
+ Usage:
96
+ npx nextpluggable init [dir] Add the dashboard, API route and plugin page route to a Next.js site
97
+ `;
98
+ export async function main() {
99
+ const [command, dir] = process.argv.slice(2);
100
+ if (command !== 'init') {
101
+ console.log(HELP);
102
+ return command && command !== 'help' && command !== '--help' ? 1 : 0;
103
+ }
104
+ const result = await init(dir ?? '.');
105
+ for (const f of result.created)
106
+ console.log(` created ${f}`);
107
+ for (const f of result.skipped)
108
+ console.log(` kept ${f} (already exists)`);
109
+ console.log(`
110
+ Next steps
111
+ 1. Start your site and open /admin/plugins${result.password ? `\n Dashboard password: ${result.password} (saved in .env.local)` : ''}
112
+ 2. Optional: add <np.Slot name="header.actions" />, <np.Slot name="footer" /> and
113
+ <np.Slot name="body.end" /> to your root layout so plugins can add buttons and widgets there.
114
+ 3. Before going live, set NEXTPLUGGABLE_PASSWORD and NEXTPLUGGABLE_SECRET on your host,
115
+ and choose a storage adapter if the host has no persistent disk.`);
116
+ for (const note of result.notes)
117
+ console.log(`\nNote: ${note}`);
118
+ return 0;
119
+ }
@@ -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;