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,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
+ }
@@ -0,0 +1,46 @@
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
+ export interface ResolvedConfig {
30
+ auth: AuthAdapter;
31
+ storage: StorageAdapter;
32
+ secret: string;
33
+ dashboardPath: string;
34
+ apiPath: string;
35
+ siteUrl: string;
36
+ siteName: string;
37
+ githubApiUrl: string;
38
+ githubToken: string | undefined;
39
+ allowLocalSources: boolean;
40
+ cacheTtlMs: number;
41
+ }
42
+ /**
43
+ * Settings are resolved lazily (on first use, not at import) so that `next build`
44
+ * succeeds in environments where runtime secrets are not present.
45
+ */
46
+ export declare function resolveConfig(config: NextPluggableConfig): () => ResolvedConfig;
package/dist/config.js ADDED
@@ -0,0 +1,55 @@
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
+ githubApiUrl: (config.github?.apiUrl ?? process.env.NEXTPLUGGABLE_GITHUB_API_URL ?? 'https://api.github.com').replace(/\/+$/, ''),
49
+ githubToken: config.github?.token ?? process.env.NEXTPLUGGABLE_GITHUB_TOKEN ?? undefined,
50
+ allowLocalSources: config.allowLocalSources ?? (!production || process.env.NEXTPLUGGABLE_ALLOW_LOCAL_SOURCES === '1'),
51
+ cacheTtlMs: config.cacheTtlMs ?? (production ? 5000 : 0),
52
+ };
53
+ return resolved;
54
+ };
55
+ }
@@ -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>;