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.
- 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 +119 -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 +46 -0
- package/dist/config.js +55 -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 +32 -0
- package/dist/core/runtime.js +190 -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 +40 -0
- package/dist/index.js +48 -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 +102 -0
- package/dist/next/slot.d.ts +7 -0
- package/dist/next/slot.js +42 -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/README.md +0 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { notFound, redirect } from 'next/navigation';
|
|
3
|
+
import { cache } from 'react';
|
|
4
|
+
import { PluginIsland } from '../client/island.js';
|
|
5
|
+
import { effectivePaths, findPage, normalizePathname } from '../core/routes.js';
|
|
6
|
+
import { prepareClient, publicConfig, runPageLoader } from '../core/runtime.js';
|
|
7
|
+
import { getInstalledPlugins, invalidateInstalledPlugins } from '../core/state.js';
|
|
8
|
+
import { PluginGoneError } from '../errors.js';
|
|
9
|
+
/** The pathname a catch-all route was called with, whatever the segment is named. */
|
|
10
|
+
async function pathnameOf(params) {
|
|
11
|
+
const values = Object.values(await params);
|
|
12
|
+
const catchAll = values.find(Array.isArray);
|
|
13
|
+
if (catchAll)
|
|
14
|
+
return normalizePathname(catchAll.join('/'));
|
|
15
|
+
return normalizePathname(typeof values[0] === 'string' ? values[0] : '/');
|
|
16
|
+
}
|
|
17
|
+
export function pluginInfo(cfg, install, page) {
|
|
18
|
+
return { id: install.id, version: install.manifest.version, apiBase: `${cfg.apiPath}/p/${install.id}`, paths: effectivePaths(install), page };
|
|
19
|
+
}
|
|
20
|
+
export function createPluginPage(getConfig) {
|
|
21
|
+
// `cache` de-duplicates the work between generateMetadata and the page within one request.
|
|
22
|
+
const run = cache(async (pathname) => {
|
|
23
|
+
const attempt = async () => {
|
|
24
|
+
const cfg = getConfig();
|
|
25
|
+
const match = findPage(await getInstalledPlugins(cfg), pathname);
|
|
26
|
+
if (!match)
|
|
27
|
+
return null;
|
|
28
|
+
const result = await runPageLoader(cfg, match.install, match.page.id, { path: pathname, params: match.params });
|
|
29
|
+
return { pathname, match, result, assets: await prepareClient(cfg, match.install) };
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
return await attempt();
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (!(e instanceof PluginGoneError))
|
|
36
|
+
throw e;
|
|
37
|
+
// This instance's list of plugins was stale (the plugin was just removed or updated elsewhere): look again.
|
|
38
|
+
invalidateInstalledPlugins(getConfig());
|
|
39
|
+
try {
|
|
40
|
+
return await attempt();
|
|
41
|
+
}
|
|
42
|
+
catch (again) {
|
|
43
|
+
if (again instanceof PluginGoneError)
|
|
44
|
+
return null;
|
|
45
|
+
throw again;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
// Only `params` is read, never the query string, cookies or headers, so this
|
|
50
|
+
// route can be statically cached as well as rendered per request.
|
|
51
|
+
const resolveRequest = async (props) => run(await pathnameOf(props.params));
|
|
52
|
+
async function generatePluginMetadata(props) {
|
|
53
|
+
const loaded = await resolveRequest(props);
|
|
54
|
+
if (!loaded || loaded.result.notFound || loaded.result.redirect)
|
|
55
|
+
return {};
|
|
56
|
+
const cfg = getConfig();
|
|
57
|
+
const meta = loaded.result.metadata ?? {};
|
|
58
|
+
const title = meta.title ?? loaded.match.page.title;
|
|
59
|
+
const description = meta.description ?? loaded.match.page.description;
|
|
60
|
+
const absolute = (value) => {
|
|
61
|
+
try {
|
|
62
|
+
return new URL(value, cfg.siteUrl).toString();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const canonical = absolute(meta.canonical ?? loaded.pathname);
|
|
69
|
+
const image = meta.image ? absolute(meta.image) : undefined;
|
|
70
|
+
return {
|
|
71
|
+
title,
|
|
72
|
+
description,
|
|
73
|
+
alternates: canonical ? { canonical } : undefined,
|
|
74
|
+
openGraph: { title, description, url: canonical, type: 'website', images: image ? [image] : undefined },
|
|
75
|
+
robots: meta.noindex ? { index: false, follow: false } : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Server component for a root catch-all route. Renders the plugin page that
|
|
80
|
+
* owns the requested address, or the site's 404 when none does.
|
|
81
|
+
*/
|
|
82
|
+
async function PluginPage(props) {
|
|
83
|
+
const loaded = await resolveRequest(props);
|
|
84
|
+
if (!loaded || loaded.result.notFound || !loaded.assets)
|
|
85
|
+
notFound();
|
|
86
|
+
const { match, result, pathname, assets } = loaded;
|
|
87
|
+
if (result.redirect)
|
|
88
|
+
redirect(result.redirect);
|
|
89
|
+
const cfg = getConfig();
|
|
90
|
+
const jsonLd = result.jsonLd ? (Array.isArray(result.jsonLd) ? result.jsonLd : [result.jsonLd]) : [];
|
|
91
|
+
return (_jsxs(_Fragment, { children: [assets.styleHref ? _jsx("link", { rel: "stylesheet", href: assets.styleHref, precedence: "nextpluggable" }) : null, jsonLd.map((item, i) => (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: JSON.stringify(item).replace(/</g, '\\u003c') } }, i))), _jsx(PluginIsland, { moduleKey: assets.moduleKey, src: assets.scriptSrc, component: match.page.component, info: pluginInfo(cfg, match.install, { id: match.page.id, path: pathname, params: match.params }), config: publicConfig(match.install), props: { data: result.data ?? {}, params: match.params }, errorFallback: _jsx("p", { style: { padding: '48px 16px', textAlign: 'center' }, children: "This page could not be displayed. Please try again later." }) })] }));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether an installed, switched-on plugin serves a page at this address. For sites
|
|
95
|
+
* whose own catch-all route has to choose between a plugin page and something of its
|
|
96
|
+
* own: unlike `PluginPage`, it never triggers the site's 404.
|
|
97
|
+
*/
|
|
98
|
+
async function hasPluginPage(pathname) {
|
|
99
|
+
return findPage(await getInstalledPlugins(getConfig()), normalizePathname(pathname)) !== null;
|
|
100
|
+
}
|
|
101
|
+
return { PluginPage, generatePluginMetadata, hasPluginPage };
|
|
102
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ResolvedConfig } from '../config.js';
|
|
3
|
+
export interface SlotProps {
|
|
4
|
+
/** Slot name, e.g. `header.actions`, `footer`, `body.end`. Plugins declare which slots they fill. */
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function createSlot(getConfig: () => ResolvedConfig): ({ name }: SlotProps) => Promise<ReactNode>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment } from 'react';
|
|
3
|
+
import { PluginIsland } from '../client/island.js';
|
|
4
|
+
import { prepareClient, publicConfig } from '../core/runtime.js';
|
|
5
|
+
import { getInstalledPlugins } from '../core/state.js';
|
|
6
|
+
import { PluginGoneError } from '../errors.js';
|
|
7
|
+
import { pluginInfo } from './plugin-page.js';
|
|
8
|
+
export function createSlot(getConfig) {
|
|
9
|
+
/**
|
|
10
|
+
* A place in the site's layout where installed plugins may render something
|
|
11
|
+
* (a cart button, a chat bubble, a banner). Renders nothing when no plugin fills it.
|
|
12
|
+
*/
|
|
13
|
+
return async function Slot({ name }) {
|
|
14
|
+
try {
|
|
15
|
+
const cfg = getConfig();
|
|
16
|
+
const installs = await getInstalledPlugins(cfg);
|
|
17
|
+
const items = Object.values(installs)
|
|
18
|
+
.filter((i) => i.enabled)
|
|
19
|
+
.flatMap((install) => (install.manifest.slots ?? []).filter((s) => s.slot === name).map((slot) => ({ install, slot })))
|
|
20
|
+
.sort((a, b) => (a.slot.order ?? 0) - (b.slot.order ?? 0) || a.install.id.localeCompare(b.install.id));
|
|
21
|
+
if (!items.length)
|
|
22
|
+
return null;
|
|
23
|
+
const rendered = await Promise.all(items.map(async ({ install, slot }) => {
|
|
24
|
+
// A plugin removed moments ago on another instance simply stops showing; it must not break the layout.
|
|
25
|
+
const assets = await prepareClient(cfg, install).catch((e) => {
|
|
26
|
+
if (e instanceof PluginGoneError)
|
|
27
|
+
return null;
|
|
28
|
+
throw e;
|
|
29
|
+
});
|
|
30
|
+
if (!assets)
|
|
31
|
+
return null;
|
|
32
|
+
return (_jsxs(Fragment, { children: [assets.styleHref ? _jsx("link", { rel: "stylesheet", href: assets.styleHref, precedence: "nextpluggable" }) : null, _jsx(PluginIsland, { moduleKey: assets.moduleKey, src: assets.scriptSrc, component: slot.component, info: pluginInfo(cfg, install), config: publicConfig(install) })] }, `${install.id}:${slot.component}`));
|
|
33
|
+
}));
|
|
34
|
+
return _jsx(_Fragment, { children: rendered });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
// A slot sits in the site's layout: it must never take the whole site down.
|
|
38
|
+
console.error(`[nextpluggable] Slot "${name}" could not be rendered:`, e);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SourceReader } from './reader.js';
|
|
2
|
+
export interface GitHubLocation {
|
|
3
|
+
owner: string;
|
|
4
|
+
repo: string;
|
|
5
|
+
ref?: string;
|
|
6
|
+
dir?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Understand whatever a person pastes for a repository: `owner/repo`, a
|
|
10
|
+
* browser URL (optionally `/tree/<branch>/<folder>`), or a git clone address.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseGitHubUrl(input: string): GitHubLocation | null;
|
|
13
|
+
export interface GitHubReaderOptions {
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
}
|
|
18
|
+
/** Reads files from a GitHub repository at an exact commit, via the REST API. */
|
|
19
|
+
export declare function githubReader(location: GitHubLocation, options: GitHubReaderOptions): SourceReader;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { HttpError } from '../errors.js';
|
|
2
|
+
const NAME = /^[A-Za-z0-9._-]+$/;
|
|
3
|
+
/**
|
|
4
|
+
* Understand whatever a person pastes for a repository: `owner/repo`, a
|
|
5
|
+
* browser URL (optionally `/tree/<branch>/<folder>`), or a git clone address.
|
|
6
|
+
*/
|
|
7
|
+
export function parseGitHubUrl(input) {
|
|
8
|
+
let s = input.trim();
|
|
9
|
+
if (!s)
|
|
10
|
+
return null;
|
|
11
|
+
s = s.replace(/^git@github\.com:/i, 'https://github.com/').replace(/^(?:www\.)?github\.com\//i, 'https://github.com/');
|
|
12
|
+
let parts;
|
|
13
|
+
if (/^https?:\/\//i.test(s)) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(s);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (!/^(www\.)?github\.com$/i.test(url.hostname))
|
|
22
|
+
return null;
|
|
23
|
+
parts = url.pathname.split('/').filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
// Shorthand must be exactly `owner/repo`; anything path-like (`/srv/plugins`, `./plugins`) is not GitHub.
|
|
27
|
+
if (!/^[A-Za-z0-9_-][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/.test(s))
|
|
28
|
+
return null;
|
|
29
|
+
parts = s.split('/');
|
|
30
|
+
}
|
|
31
|
+
const [owner, repoRaw, kind, ref, ...rest] = parts;
|
|
32
|
+
if (!owner || !repoRaw)
|
|
33
|
+
return null;
|
|
34
|
+
const repo = repoRaw.replace(/\.git$/i, '');
|
|
35
|
+
if (!NAME.test(owner) || !NAME.test(repo))
|
|
36
|
+
return null;
|
|
37
|
+
const location = { owner, repo };
|
|
38
|
+
if (kind === 'tree' && ref) {
|
|
39
|
+
location.ref = decodeURIComponent(ref);
|
|
40
|
+
const dir = rest.map(decodeURIComponent).join('/');
|
|
41
|
+
if (dir) {
|
|
42
|
+
if (dir.split('/').some((p) => p === '..' || p === '.'))
|
|
43
|
+
return null;
|
|
44
|
+
location.dir = dir;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return location;
|
|
48
|
+
}
|
|
49
|
+
const encodePath = (p) => p.split('/').map(encodeURIComponent).join('/');
|
|
50
|
+
/** Reads files from a GitHub repository at an exact commit, via the REST API. */
|
|
51
|
+
export function githubReader(location, options) {
|
|
52
|
+
const doFetch = options.fetch ?? fetch;
|
|
53
|
+
const repoUrl = `${options.apiUrl}/repos/${encodeURIComponent(location.owner)}/${encodeURIComponent(location.repo)}`;
|
|
54
|
+
const name = `${location.owner}/${location.repo}`;
|
|
55
|
+
async function call(url, accept) {
|
|
56
|
+
let res;
|
|
57
|
+
try {
|
|
58
|
+
res = await doFetch(url, {
|
|
59
|
+
headers: {
|
|
60
|
+
accept,
|
|
61
|
+
'user-agent': 'nextpluggable',
|
|
62
|
+
'x-github-api-version': '2022-11-28',
|
|
63
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
64
|
+
},
|
|
65
|
+
cache: 'no-store',
|
|
66
|
+
redirect: 'follow',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new HttpError(502, 'Could not reach GitHub. Check the internet connection of the server and try again.');
|
|
71
|
+
}
|
|
72
|
+
if (res.status === 401)
|
|
73
|
+
throw new HttpError(400, `GitHub did not accept the access token for ${name}. It may have expired — create a new one and reconnect the source.`);
|
|
74
|
+
if (res.status === 403 || res.status === 429) {
|
|
75
|
+
if (res.headers.get('x-ratelimit-remaining') === '0' || res.status === 429) {
|
|
76
|
+
const reset = Number(res.headers.get('x-ratelimit-reset')) * 1000;
|
|
77
|
+
const mins = Number.isFinite(reset) && reset > Date.now() ? Math.max(1, Math.ceil((reset - Date.now()) / 60_000)) : null;
|
|
78
|
+
throw new HttpError(429, `GitHub is limiting requests right now${mins ? ` — try again in about ${mins} minute${mins === 1 ? '' : 's'}` : ''}.${options.token ? '' : ' Adding an access token raises the limit.'}`);
|
|
79
|
+
}
|
|
80
|
+
throw new HttpError(400, options.token
|
|
81
|
+
? `The access token does not have permission to read ${name}. It needs read access to the repository's contents.`
|
|
82
|
+
: `GitHub refused access to ${name}. If the repository is private, add an access token.`);
|
|
83
|
+
}
|
|
84
|
+
return res;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
async resolveRevision() {
|
|
88
|
+
const res = await call(`${repoUrl}/commits/${encodePath(location.ref ?? 'HEAD')}`, 'application/vnd.github.sha');
|
|
89
|
+
if (res.status === 404 || res.status === 422 || res.status === 409)
|
|
90
|
+
throw new HttpError(404, location.ref
|
|
91
|
+
? `We could not find "${location.ref}" in ${name}. Check the address${options.token ? '' : ' — if the repository is private, add an access token'}.`
|
|
92
|
+
: `We could not find the repository ${name}, or it is empty. Check the address${options.token ? ' and that the access token can read it' : ' — if the repository is private, add an access token'}.`);
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
throw new HttpError(502, `GitHub returned an unexpected response (${res.status}).`);
|
|
95
|
+
const sha = (await res.text()).trim();
|
|
96
|
+
if (!/^[0-9a-f]{40,64}$/.test(sha))
|
|
97
|
+
throw new HttpError(502, 'GitHub returned an unexpected response.');
|
|
98
|
+
return sha;
|
|
99
|
+
},
|
|
100
|
+
async read(path, revision) {
|
|
101
|
+
const res = await call(`${repoUrl}/contents/${encodePath(path)}?ref=${encodeURIComponent(revision)}`, 'application/vnd.github.raw+json');
|
|
102
|
+
if (res.status === 404)
|
|
103
|
+
return null;
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
throw new HttpError(502, `GitHub returned an unexpected response (${res.status}) for ${path}.`);
|
|
106
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Reads plugins from a folder on the server. For plugin development. */
|
|
4
|
+
export function localReader(dir) {
|
|
5
|
+
const root = path.resolve(/* turbopackIgnore: true */ dir);
|
|
6
|
+
return {
|
|
7
|
+
async resolveRevision() {
|
|
8
|
+
return 'local';
|
|
9
|
+
},
|
|
10
|
+
async read(file) {
|
|
11
|
+
const target = path.resolve(/* turbopackIgnore: true */ root, file);
|
|
12
|
+
if (target !== root && !target.startsWith(root + path.sep))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return new Uint8Array(await readFile(target));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config.js';
|
|
2
|
+
import type { Catalog, SourceRecord } from '../core/types.js';
|
|
3
|
+
/** Read-only access to the files of a plugin source at a fixed revision. */
|
|
4
|
+
export interface SourceReader {
|
|
5
|
+
/** The exact revision (commit SHA) the source currently points at. */
|
|
6
|
+
resolveRevision(): Promise<string>;
|
|
7
|
+
/** File contents, or null when the file does not exist. */
|
|
8
|
+
read(path: string, revision: string): Promise<Uint8Array | null>;
|
|
9
|
+
}
|
|
10
|
+
export declare const joinPath: (...parts: (string | undefined)[]) => string;
|
|
11
|
+
export declare function readerFor(cfg: ResolvedConfig, source: SourceRecord): {
|
|
12
|
+
reader: SourceReader;
|
|
13
|
+
baseDir: string;
|
|
14
|
+
};
|
|
15
|
+
export declare const decodeText: (bytes: Uint8Array) => string;
|
|
16
|
+
/** List the plugins a source offers, at its current revision. */
|
|
17
|
+
export declare function loadCatalog(source: SourceRecord, reader: SourceReader, baseDir: string): Promise<Catalog>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ManifestError, validateBuild, validateRepositoryIndex } from '@nextpluggable/plugin/manifest';
|
|
2
|
+
import { decrypt, sha256Hex } from '../crypto.js';
|
|
3
|
+
import { HttpError } from '../errors.js';
|
|
4
|
+
import { githubReader } from './github.js';
|
|
5
|
+
import { localReader } from './local.js';
|
|
6
|
+
export const joinPath = (...parts) => parts.filter(Boolean).join('/');
|
|
7
|
+
export function readerFor(cfg, source) {
|
|
8
|
+
if (source.type === 'local' && source.local) {
|
|
9
|
+
if (!cfg.allowLocalSources)
|
|
10
|
+
throw new HttpError(400, 'Folder sources are turned off on this site.');
|
|
11
|
+
return { reader: localReader(source.local.dir), baseDir: '' };
|
|
12
|
+
}
|
|
13
|
+
if (source.type === 'github' && source.github) {
|
|
14
|
+
const own = source.github.token ? decrypt(cfg.secret, source.github.token) : null;
|
|
15
|
+
if (source.github.token && own === null)
|
|
16
|
+
throw new HttpError(400, `The saved access token for ${source.label} can no longer be read (the site secret changed). Remove the source and connect it again.`);
|
|
17
|
+
return {
|
|
18
|
+
reader: githubReader(source.github, { apiUrl: cfg.githubApiUrl, token: own ?? cfg.githubToken }),
|
|
19
|
+
baseDir: source.github.dir ?? '',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
throw new HttpError(500, 'Unknown source type.');
|
|
23
|
+
}
|
|
24
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
25
|
+
export const decodeText = (bytes) => decoder.decode(bytes);
|
|
26
|
+
/** List the plugins a source offers, at its current revision. */
|
|
27
|
+
export async function loadCatalog(source, reader, baseDir) {
|
|
28
|
+
const revision = await reader.resolveRevision();
|
|
29
|
+
const catalog = { sourceId: source.id, revision, fetchedAt: new Date().toISOString(), plugins: [], problems: [] };
|
|
30
|
+
const indexBytes = await reader.read(joinPath(baseDir, 'nextpluggable.json'), revision);
|
|
31
|
+
let dirs;
|
|
32
|
+
if (indexBytes) {
|
|
33
|
+
try {
|
|
34
|
+
const index = validateRepositoryIndex(JSON.parse(decodeText(indexBytes)));
|
|
35
|
+
catalog.name = index.name;
|
|
36
|
+
catalog.description = index.description;
|
|
37
|
+
dirs = [...new Set(index.plugins)];
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
throw new HttpError(422, `The file nextpluggable.json in ${source.label} could not be read: ${e instanceof ManifestError ? e.problems.join('; ') : 'it is not valid JSON'}.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
dirs = [''];
|
|
45
|
+
}
|
|
46
|
+
if (dirs.length > 100)
|
|
47
|
+
throw new HttpError(422, `${source.label} lists more than 100 plugins.`);
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const results = await Promise.all(dirs.map(async (dir) => {
|
|
50
|
+
const bytes = await reader.read(joinPath(baseDir, dir, 'dist/build.json'), revision);
|
|
51
|
+
if (!bytes) {
|
|
52
|
+
if (!indexBytes)
|
|
53
|
+
return null;
|
|
54
|
+
return { path: dir, message: 'This plugin has not been built yet (dist/build.json is missing).' };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return { path: dir, build: validateBuild(JSON.parse(decodeText(bytes))), buildHash: sha256Hex(bytes) };
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
return { path: dir, message: e instanceof ManifestError ? e.problems.join('; ') : 'dist/build.json is not valid JSON.' };
|
|
61
|
+
}
|
|
62
|
+
}));
|
|
63
|
+
for (const r of results) {
|
|
64
|
+
if (!r)
|
|
65
|
+
continue;
|
|
66
|
+
if ('message' in r)
|
|
67
|
+
catalog.problems.push(r);
|
|
68
|
+
else if (seen.has(r.build.manifest.id))
|
|
69
|
+
catalog.problems.push({ path: r.path, message: `Another plugin in this source already uses the id "${r.build.manifest.id}".` });
|
|
70
|
+
else {
|
|
71
|
+
seen.add(r.build.manifest.id);
|
|
72
|
+
catalog.plugins.push(r);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!indexBytes && catalog.plugins.length === 0 && catalog.problems.length === 0)
|
|
76
|
+
throw new HttpError(422, `${source.label} does not look like a plugin source: it has no nextpluggable.json and no built plugin at its top level.`);
|
|
77
|
+
return catalog;
|
|
78
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type StorageAdapter } from './types.js';
|
|
2
|
+
export interface FileStorageOptions {
|
|
3
|
+
/** Directory to store data in. Default: `NEXTPLUGGABLE_DATA_DIR`, else `.nextpluggable` in the project root. */
|
|
4
|
+
dir?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Stores everything as files in a directory. Right for local development and
|
|
8
|
+
* for a single long-running server (VPS, Docker with a volume). Not for
|
|
9
|
+
* serverless hosts such as Vercel, whose filesystem is read-only and not shared
|
|
10
|
+
* between instances — use a database adapter there.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fileStorage(options?: FileStorageOptions): StorageAdapter;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { assertKey } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Stores everything as files in a directory. Right for local development and
|
|
6
|
+
* for a single long-running server (VPS, Docker with a volume). Not for
|
|
7
|
+
* serverless hosts such as Vercel, whose filesystem is read-only and not shared
|
|
8
|
+
* between instances — use a database adapter there.
|
|
9
|
+
*/
|
|
10
|
+
export function fileStorage(options = {}) {
|
|
11
|
+
const root = path.resolve(/* turbopackIgnore: true */ options.dir ?? process.env.NEXTPLUGGABLE_DATA_DIR ?? path.join(/* turbopackIgnore: true */ process.cwd(), '.nextpluggable'));
|
|
12
|
+
const file = (key) => {
|
|
13
|
+
assertKey(key);
|
|
14
|
+
return path.join(root, ...key.split('/'));
|
|
15
|
+
};
|
|
16
|
+
let seq = 0;
|
|
17
|
+
async function walk(dir, prefix, out) {
|
|
18
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
19
|
+
for (const e of entries) {
|
|
20
|
+
if (e.name.endsWith('.tmp'))
|
|
21
|
+
continue;
|
|
22
|
+
const key = prefix ? `${prefix}/${e.name}` : e.name;
|
|
23
|
+
if (e.isDirectory())
|
|
24
|
+
await walk(path.join(dir, e.name), key, out);
|
|
25
|
+
else
|
|
26
|
+
out.push(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
async get(key) {
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(file(key), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (e.code === 'ENOENT' || e.code === 'ENOTDIR')
|
|
36
|
+
return null;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async set(key, value) {
|
|
41
|
+
const target = file(key);
|
|
42
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
43
|
+
// Write-then-rename so a crash never leaves a half-written record.
|
|
44
|
+
const tmp = `${target}.${process.pid}.${++seq}.tmp`;
|
|
45
|
+
await writeFile(tmp, value, 'utf8');
|
|
46
|
+
await rename(tmp, target);
|
|
47
|
+
},
|
|
48
|
+
async delete(key) {
|
|
49
|
+
await rm(file(key), { force: true });
|
|
50
|
+
},
|
|
51
|
+
async list(prefix) {
|
|
52
|
+
const slash = prefix.lastIndexOf('/');
|
|
53
|
+
const dirKey = slash === -1 ? '' : prefix.slice(0, slash);
|
|
54
|
+
if (dirKey)
|
|
55
|
+
assertKey(dirKey);
|
|
56
|
+
const out = [];
|
|
57
|
+
await walk(dirKey ? path.join(root, ...dirKey.split('/')) : root, dirKey, out);
|
|
58
|
+
return out.filter((k) => k.startsWith(prefix)).sort();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { assertKey } from './types.js';
|
|
2
|
+
/** Keeps everything in memory. For tests and demos — data is lost on restart. */
|
|
3
|
+
export function memoryStorage() {
|
|
4
|
+
const map = new Map();
|
|
5
|
+
return {
|
|
6
|
+
async get(key) {
|
|
7
|
+
assertKey(key);
|
|
8
|
+
return map.get(key) ?? null;
|
|
9
|
+
},
|
|
10
|
+
async set(key, value) {
|
|
11
|
+
assertKey(key);
|
|
12
|
+
map.set(key, value);
|
|
13
|
+
},
|
|
14
|
+
async delete(key) {
|
|
15
|
+
assertKey(key);
|
|
16
|
+
map.delete(key);
|
|
17
|
+
},
|
|
18
|
+
async list(prefix) {
|
|
19
|
+
return [...map.keys()].filter((k) => k.startsWith(prefix)).sort();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type StorageAdapter } from './types.js';
|
|
2
|
+
export interface PostgresStorageOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Runs a parameterised query (`$1`, `$2`, …) and resolves to the rows, or to an
|
|
5
|
+
* object with a `rows` array. Matches `pg`'s `pool.query`, Neon's `sql.query`,
|
|
6
|
+
* and is a one-liner to adapt for other clients.
|
|
7
|
+
*/
|
|
8
|
+
query: (text: string, params: unknown[]) => Promise<{
|
|
9
|
+
rows: unknown[];
|
|
10
|
+
} | unknown[]>;
|
|
11
|
+
/** Table name. Default `nextpluggable_kv`. Created automatically. */
|
|
12
|
+
table?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Stores everything in one Postgres table. Works with any client you already use. */
|
|
15
|
+
export declare function postgresStorage(options: PostgresStorageOptions): StorageAdapter;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { assertKey } from './types.js';
|
|
2
|
+
/** Stores everything in one Postgres table. Works with any client you already use. */
|
|
3
|
+
export function postgresStorage(options) {
|
|
4
|
+
const table = options.table ?? 'nextpluggable_kv';
|
|
5
|
+
if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(table))
|
|
6
|
+
throw new Error(`Invalid table name: ${table}`);
|
|
7
|
+
let ready;
|
|
8
|
+
async function run(text, params = []) {
|
|
9
|
+
ready ??= options
|
|
10
|
+
.query(`CREATE TABLE IF NOT EXISTS ${table} (key text PRIMARY KEY, value text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())`, [])
|
|
11
|
+
.catch((e) => {
|
|
12
|
+
ready = undefined;
|
|
13
|
+
throw e;
|
|
14
|
+
});
|
|
15
|
+
await ready;
|
|
16
|
+
const result = await options.query(text, params);
|
|
17
|
+
return (Array.isArray(result) ? result : result.rows);
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
async get(key) {
|
|
21
|
+
assertKey(key);
|
|
22
|
+
const rows = await run(`SELECT value FROM ${table} WHERE key = $1`, [key]);
|
|
23
|
+
return rows.length ? String(rows[0].value) : null;
|
|
24
|
+
},
|
|
25
|
+
async set(key, value) {
|
|
26
|
+
assertKey(key);
|
|
27
|
+
await run(`INSERT INTO ${table} (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, [key, value]);
|
|
28
|
+
},
|
|
29
|
+
async delete(key) {
|
|
30
|
+
assertKey(key);
|
|
31
|
+
await run(`DELETE FROM ${table} WHERE key = $1`, [key]);
|
|
32
|
+
},
|
|
33
|
+
async list(prefix) {
|
|
34
|
+
const pattern = prefix.replace(/[\\%_]/g, '\\$&') + '%';
|
|
35
|
+
const rows = await run(`SELECT key FROM ${table} WHERE key LIKE $1 ESCAPE '\\' ORDER BY key COLLATE "C"`, [pattern]);
|
|
36
|
+
return rows.map((r) => String(r.key));
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where NextPluggable keeps everything: connected sources, installed plugins,
|
|
3
|
+
* their bundles, settings and data. A flat string key-value store is all it
|
|
4
|
+
* needs, so any database can back it.
|
|
5
|
+
*
|
|
6
|
+
* Keys are `/`-separated paths whose segments use only `A-Z a-z 0-9 . _ @ % ~ -`.
|
|
7
|
+
*/
|
|
8
|
+
export interface StorageAdapter {
|
|
9
|
+
get(key: string): Promise<string | null>;
|
|
10
|
+
set(key: string, value: string): Promise<void>;
|
|
11
|
+
delete(key: string): Promise<void>;
|
|
12
|
+
/** All keys that start with `prefix`, sorted. */
|
|
13
|
+
list(prefix: string): Promise<string[]>;
|
|
14
|
+
}
|
|
15
|
+
export declare function assertKey(key: string): void;
|
|
16
|
+
/** Encode an arbitrary string as a single safe key segment. */
|
|
17
|
+
export declare function encodeSegment(value: string): string;
|
|
18
|
+
export declare const decodeSegment: (segment: string) => string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const SEGMENT = /^[A-Za-z0-9_@%~-][A-Za-z0-9._@%~-]*$/;
|
|
2
|
+
export function assertKey(key) {
|
|
3
|
+
if (!key || key.length > 900 || !key.split('/').every((s) => SEGMENT.test(s)))
|
|
4
|
+
throw new Error(`Invalid storage key: ${JSON.stringify(key)}`);
|
|
5
|
+
}
|
|
6
|
+
/** Encode an arbitrary string as a single safe key segment. */
|
|
7
|
+
export function encodeSegment(value) {
|
|
8
|
+
const out = encodeURIComponent(value)
|
|
9
|
+
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
10
|
+
.replace(/^\./, '%2E');
|
|
11
|
+
if (!out)
|
|
12
|
+
throw new Error('Storage keys cannot be empty');
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
export const decodeSegment = (segment) => decodeURIComponent(segment);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type StorageAdapter } from './types.js';
|
|
2
|
+
export interface UpstashStorageOptions {
|
|
3
|
+
/** REST URL. Default: `UPSTASH_REDIS_REST_URL` or `KV_REST_API_URL`. */
|
|
4
|
+
url?: string;
|
|
5
|
+
/** REST token. Default: `UPSTASH_REDIS_REST_TOKEN` or `KV_REST_API_TOKEN`. */
|
|
6
|
+
token?: string;
|
|
7
|
+
/** Prefix for every key, so one database can be shared. Default `np:`. */
|
|
8
|
+
prefix?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Stores everything in Upstash Redis over its REST API (also what Vercel's
|
|
12
|
+
* "Upstash for Redis" marketplace integration provisions). Works on serverless
|
|
13
|
+
* hosts and needs no extra dependency.
|
|
14
|
+
*/
|
|
15
|
+
export declare function upstashStorage(options?: UpstashStorageOptions): StorageAdapter;
|