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.
Files changed (77) hide show
  1. package/README.md +288 -2
  2. package/bin/nextpluggable.js +10 -0
  3. package/dist/api/handler.d.ts +10 -0
  4. package/dist/api/handler.js +188 -0
  5. package/dist/auth/custom.d.ts +13 -0
  6. package/dist/auth/custom.js +12 -0
  7. package/dist/auth/password.d.ts +10 -0
  8. package/dist/auth/password.js +66 -0
  9. package/dist/auth/types.d.ts +23 -0
  10. package/dist/auth/types.js +1 -0
  11. package/dist/cli.d.ts +9 -0
  12. package/dist/cli.js +123 -0
  13. package/dist/client/host-runtime.d.ts +9 -0
  14. package/dist/client/host-runtime.js +71 -0
  15. package/dist/client/island.d.ts +28 -0
  16. package/dist/client/island.js +177 -0
  17. package/dist/client/ssr-guard.d.ts +11 -0
  18. package/dist/client/ssr-guard.js +100 -0
  19. package/dist/config.d.ts +55 -0
  20. package/dist/config.js +57 -0
  21. package/dist/core/installer.d.ts +30 -0
  22. package/dist/core/installer.js +340 -0
  23. package/dist/core/routes.d.ts +20 -0
  24. package/dist/core/routes.js +95 -0
  25. package/dist/core/runtime.d.ts +34 -0
  26. package/dist/core/runtime.js +195 -0
  27. package/dist/core/sitemap.d.ts +12 -0
  28. package/dist/core/sitemap.js +83 -0
  29. package/dist/core/state.d.ts +13 -0
  30. package/dist/core/state.js +23 -0
  31. package/dist/core/store.d.ts +17 -0
  32. package/dist/core/store.js +58 -0
  33. package/dist/core/types.d.ts +69 -0
  34. package/dist/core/types.js +1 -0
  35. package/dist/crypto.d.ts +14 -0
  36. package/dist/crypto.js +61 -0
  37. package/dist/dashboard/Dashboard.d.ts +11 -0
  38. package/dist/dashboard/Dashboard.js +324 -0
  39. package/dist/dashboard/styles.d.ts +2 -0
  40. package/dist/dashboard/styles.js +129 -0
  41. package/dist/dashboard/ui.d.ts +61 -0
  42. package/dist/dashboard/ui.js +135 -0
  43. package/dist/errors.d.ts +15 -0
  44. package/dist/errors.js +29 -0
  45. package/dist/globals.d.ts +24 -0
  46. package/dist/globals.js +24 -0
  47. package/dist/index.d.ts +47 -0
  48. package/dist/index.js +65 -0
  49. package/dist/next/dashboard-page.d.ts +5 -0
  50. package/dist/next/dashboard-page.js +23 -0
  51. package/dist/next/plugin-page.d.ts +16 -0
  52. package/dist/next/plugin-page.js +91 -0
  53. package/dist/next/seo.d.ts +32 -0
  54. package/dist/next/seo.js +86 -0
  55. package/dist/next/slot.d.ts +7 -0
  56. package/dist/next/slot.js +43 -0
  57. package/dist/next/style.d.ts +10 -0
  58. package/dist/next/style.js +13 -0
  59. package/dist/sources/github.d.ts +19 -0
  60. package/dist/sources/github.js +109 -0
  61. package/dist/sources/local.d.ts +3 -0
  62. package/dist/sources/local.js +22 -0
  63. package/dist/sources/reader.d.ts +17 -0
  64. package/dist/sources/reader.js +78 -0
  65. package/dist/storage/file.d.ts +12 -0
  66. package/dist/storage/file.js +61 -0
  67. package/dist/storage/memory.d.ts +3 -0
  68. package/dist/storage/memory.js +22 -0
  69. package/dist/storage/postgres.d.ts +15 -0
  70. package/dist/storage/postgres.js +39 -0
  71. package/dist/storage/types.d.ts +18 -0
  72. package/dist/storage/types.js +15 -0
  73. package/dist/storage/upstash.d.ts +15 -0
  74. package/dist/storage/upstash.js +61 -0
  75. package/dist/views.d.ts +112 -0
  76. package/dist/views.js +124 -0
  77. package/package.json +48 -3
@@ -0,0 +1,43 @@
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
+ import { PluginStyle } from './style.js';
9
+ export function createSlot(getConfig) {
10
+ /**
11
+ * A place in the site's layout where installed plugins may render something
12
+ * (a cart button, a chat bubble, a banner). Renders nothing when no plugin fills it.
13
+ */
14
+ return async function Slot({ name }) {
15
+ try {
16
+ const cfg = getConfig();
17
+ const installs = await getInstalledPlugins(cfg);
18
+ const items = Object.values(installs)
19
+ .filter((i) => i.enabled)
20
+ .flatMap((install) => (install.manifest.slots ?? []).filter((s) => s.slot === name).map((slot) => ({ install, slot })))
21
+ .sort((a, b) => (a.slot.order ?? 0) - (b.slot.order ?? 0) || a.install.id.localeCompare(b.install.id));
22
+ if (!items.length)
23
+ return null;
24
+ const rendered = await Promise.all(items.map(async ({ install, slot }) => {
25
+ // A plugin removed moments ago on another instance simply stops showing; it must not break the layout.
26
+ const assets = await prepareClient(cfg, install).catch((e) => {
27
+ if (e instanceof PluginGoneError)
28
+ return null;
29
+ throw e;
30
+ });
31
+ if (!assets)
32
+ return null;
33
+ return (_jsxs(Fragment, { children: [_jsx(PluginStyle, { assets: assets }), _jsx(PluginIsland, { moduleKey: assets.moduleKey, src: assets.scriptSrc, component: slot.component, info: pluginInfo(cfg, install), config: publicConfig(install) })] }, `${install.id}:${slot.component}`));
34
+ }));
35
+ return _jsx(_Fragment, { children: rendered });
36
+ }
37
+ catch (e) {
38
+ // A slot sits in the site's layout: it must never take the whole site down.
39
+ console.error(`[nextpluggable] Slot "${name}" could not be rendered:`, e);
40
+ return null;
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ClientAssets } from '../core/runtime.js';
3
+ /**
4
+ * A plugin's CSS. Small stylesheets are written into the page itself, so a plugin
5
+ * never adds a request the browser must wait for before it can paint. React keeps
6
+ * one copy per page however many of the plugin's components are on it.
7
+ */
8
+ export declare function PluginStyle({ assets }: {
9
+ assets: ClientAssets;
10
+ }): ReactNode;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * A plugin's CSS. Small stylesheets are written into the page itself, so a plugin
4
+ * never adds a request the browser must wait for before it can paint. React keeps
5
+ * one copy per page however many of the plugin's components are on it.
6
+ */
7
+ export function PluginStyle({ assets }) {
8
+ if (!assets.styleHref)
9
+ return null;
10
+ if (assets.styleCss !== undefined)
11
+ return (_jsx("style", { href: assets.styleHref, precedence: "nextpluggable", children: assets.styleCss }));
12
+ return _jsx("link", { rel: "stylesheet", href: assets.styleHref, precedence: "nextpluggable" });
13
+ }
@@ -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,3 @@
1
+ import type { SourceReader } from './reader.js';
2
+ /** Reads plugins from a folder on the server. For plugin development. */
3
+ export declare function localReader(dir: string): SourceReader;
@@ -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,3 @@
1
+ import { type StorageAdapter } from './types.js';
2
+ /** Keeps everything in memory. For tests and demos — data is lost on restart. */
3
+ export declare function memoryStorage(): StorageAdapter;
@@ -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;
@@ -0,0 +1,61 @@
1
+ import { assertKey } from './types.js';
2
+ /**
3
+ * Stores everything in Upstash Redis over its REST API (also what Vercel's
4
+ * "Upstash for Redis" marketplace integration provisions). Works on serverless
5
+ * hosts and needs no extra dependency.
6
+ */
7
+ export function upstashStorage(options = {}) {
8
+ const prefix = options.prefix ?? 'np:';
9
+ const creds = () => {
10
+ const url = options.url ?? process.env.UPSTASH_REDIS_REST_URL ?? process.env.KV_REST_API_URL;
11
+ const token = options.token ?? process.env.UPSTASH_REDIS_REST_TOKEN ?? process.env.KV_REST_API_TOKEN;
12
+ if (!url || !token)
13
+ throw new Error('upstashStorage needs a REST URL and token (set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN).');
14
+ return { url: url.replace(/\/+$/, ''), token };
15
+ };
16
+ async function command(...args) {
17
+ const { url, token } = creds();
18
+ // Next.js wraps `fetch`, and an uncached request through that wrapper makes it refuse to
19
+ // prerender the page being rendered: every static page with a plugin slot in its layout
20
+ // would fail to build or turn dynamic. Storage reads are neither cached by Next.js nor a
21
+ // reason to render per request, so they go through the fetch that Next.js wrapped.
22
+ const send = globalThis.fetch._nextOriginalFetch ?? globalThis.fetch;
23
+ const res = await send(url, {
24
+ method: 'POST',
25
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
26
+ body: JSON.stringify(args),
27
+ cache: 'no-store',
28
+ });
29
+ const body = (await res.json().catch(() => null));
30
+ if (!res.ok || !body || body.error)
31
+ throw new Error(`Upstash ${String(args[0])} failed: ${body?.error ?? `HTTP ${res.status}`}`);
32
+ return body.result;
33
+ }
34
+ // Redis glob characters in a prefix must be escaped for SCAN MATCH.
35
+ const escapeGlob = (s) => s.replace(/[\\*?[\]]/g, '\\$&');
36
+ return {
37
+ async get(key) {
38
+ assertKey(key);
39
+ return command('GET', prefix + key);
40
+ },
41
+ async set(key, value) {
42
+ assertKey(key);
43
+ await command('SET', prefix + key, value);
44
+ },
45
+ async delete(key) {
46
+ assertKey(key);
47
+ await command('DEL', prefix + key);
48
+ },
49
+ async list(keyPrefix) {
50
+ const out = new Set();
51
+ let cursor = '0';
52
+ do {
53
+ const [next, keys] = await command('SCAN', cursor, 'MATCH', `${escapeGlob(prefix + keyPrefix)}*`, 'COUNT', 1000);
54
+ for (const k of keys)
55
+ out.add(k.slice(prefix.length));
56
+ cursor = next;
57
+ } while (cursor !== '0');
58
+ return [...out].sort();
59
+ },
60
+ };
61
+ }