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,340 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { checkPagePath, defaultConfig, isStaticPath, validateBuild, validateSettings, ManifestError } from '@nextpluggable/plugin/manifest';
4
+ import { decrypt, encrypt, isEncrypted, sha256Hex } from '../crypto.js';
5
+ import { HttpError } from '../errors.js';
6
+ import { globals } from '../globals.js';
7
+ import { parseGitHubUrl } from '../sources/github.js';
8
+ import { decodeText, joinPath, loadCatalog, readerFor } from '../sources/reader.js';
9
+ import { effectivePaths, pathProblem } from './routes.js';
10
+ import { evaluateServer, loadServer, pluginContext } from './runtime.js';
11
+ import { invalidateInstalledPlugins } from './state.js';
12
+ import { deleteCatalog, deletePluginData, getInstalls, getSources, pruneFiles, putFile, saveCatalog, saveInstalls, saveSources, } from './store.js';
13
+ const MAX_BYTES = { client: 3_000_000, server: 5_000_000, style: 1_000_000 };
14
+ const KINDS = ['client', 'server', 'style'];
15
+ const reservedPaths = (cfg) => [cfg.dashboardPath, cfg.apiPath, '/_next', '/api'];
16
+ /** True while a required setting has no value — the plugin stays paused until the owner fills it in. */
17
+ export function needsSetup(manifest, config) {
18
+ return (manifest.settings ?? []).some((f) => f.required && (config[f.key] === undefined || config[f.key] === ''));
19
+ }
20
+ // ---------------------------------------------------------------- sources
21
+ export async function addSource(cfg, input) {
22
+ const url = typeof input.url === 'string' ? input.url.trim() : '';
23
+ const token = typeof input.token === 'string' ? input.token.trim() : '';
24
+ if (!url)
25
+ throw new HttpError(400, 'Paste the address of the GitHub repository that holds the plugins.');
26
+ const sources = await getSources(cfg.storage);
27
+ const id = `s${randomBytes(5).toString('hex')}`;
28
+ const addedAt = new Date().toISOString();
29
+ let record;
30
+ const gh = parseGitHubUrl(url);
31
+ if (gh) {
32
+ const same = sources.find((s) => s.github &&
33
+ s.github.owner.toLowerCase() === gh.owner.toLowerCase() &&
34
+ s.github.repo.toLowerCase() === gh.repo.toLowerCase() &&
35
+ (s.github.dir ?? '') === (gh.dir ?? '') &&
36
+ (s.github.ref ?? '') === (gh.ref ?? ''));
37
+ if (same)
38
+ throw new HttpError(409, `${same.label} is already connected.`);
39
+ record = {
40
+ id,
41
+ type: 'github',
42
+ label: `${gh.owner}/${gh.repo}${gh.dir ? `/${gh.dir}` : ''}`,
43
+ addedAt,
44
+ github: { ...gh, ...(token ? { token: encrypt(cfg.secret, token) } : {}) },
45
+ };
46
+ }
47
+ else if (cfg.allowLocalSources && /^(file:|\/|\.\.?\/|[A-Za-z]:[\\/])/.test(url)) {
48
+ const dir = path.resolve(/* turbopackIgnore: true */ url.replace(/^file:(\/\/)?/, ''));
49
+ if (sources.some((s) => s.local?.dir === dir))
50
+ throw new HttpError(409, 'That folder is already connected.');
51
+ record = { id, type: 'local', label: path.basename(dir) || dir, addedAt, local: { dir } };
52
+ }
53
+ else {
54
+ throw new HttpError(400, 'That does not look like a GitHub repository address. It should look like https://github.com/owner/repository.');
55
+ }
56
+ // Reading the catalog first proves the address and token work before anything is saved.
57
+ const { reader, baseDir } = readerFor(cfg, record);
58
+ const catalog = await loadCatalog(record, reader, baseDir);
59
+ await saveCatalog(cfg.storage, catalog);
60
+ await saveSources(cfg.storage, [...sources, record]);
61
+ return record;
62
+ }
63
+ export async function refreshSource(cfg, source) {
64
+ const { reader, baseDir } = readerFor(cfg, source);
65
+ const catalog = await loadCatalog(source, reader, baseDir);
66
+ await saveCatalog(cfg.storage, catalog);
67
+ return catalog;
68
+ }
69
+ /** Refresh every source; one failing source does not stop the others. */
70
+ export async function refreshAllSources(cfg) {
71
+ const failures = [];
72
+ await Promise.all((await getSources(cfg.storage)).map((source) => refreshSource(cfg, source).catch((e) => {
73
+ failures.push({ label: source.label, message: e instanceof HttpError ? e.message : 'Something went wrong reading this source.' });
74
+ if (!(e instanceof HttpError))
75
+ console.error('[nextpluggable] refresh failed', e);
76
+ })));
77
+ return failures;
78
+ }
79
+ export async function removeSource(cfg, sourceId) {
80
+ const sources = await getSources(cfg.storage);
81
+ if (!sources.some((s) => s.id === sourceId))
82
+ throw new HttpError(404, 'That source is no longer connected.');
83
+ await saveSources(cfg.storage, sources.filter((s) => s.id !== sourceId));
84
+ await deleteCatalog(cfg.storage, sourceId);
85
+ }
86
+ // ---------------------------------------------------------------- install / update
87
+ async function fetchBuild(cfg, source, pluginPath) {
88
+ const { reader, baseDir } = readerFor(cfg, source);
89
+ const revision = await reader.resolveRevision();
90
+ const dir = joinPath(baseDir, pluginPath);
91
+ const buildBytes = await reader.read(joinPath(dir, 'dist/build.json'), revision);
92
+ if (!buildBytes)
93
+ throw new HttpError(404, `That plugin is no longer available in ${source.label}.`);
94
+ let build;
95
+ try {
96
+ build = validateBuild(JSON.parse(decodeText(buildBytes)));
97
+ }
98
+ catch (e) {
99
+ throw new HttpError(422, `This plugin cannot be installed because its package is not valid: ${e instanceof ManifestError ? e.problems.join('; ') : 'unreadable build file'}.`);
100
+ }
101
+ const files = {};
102
+ await Promise.all(KINDS.map(async (kind) => {
103
+ const entry = build.files[kind];
104
+ if (!entry)
105
+ return;
106
+ if (entry.bytes > MAX_BYTES[kind])
107
+ throw new HttpError(422, `This plugin is too large to install (${kind} bundle is ${(entry.bytes / 1e6).toFixed(1)} MB).`);
108
+ const bytes = await reader.read(joinPath(dir, entry.path), revision);
109
+ if (!bytes)
110
+ throw new HttpError(422, `This plugin is incomplete: ${entry.path} is missing from ${source.label}.`);
111
+ // Integrity: what we store must be byte-for-byte what the plugin's build recorded.
112
+ if (sha256Hex(bytes) !== entry.sha256)
113
+ throw new HttpError(422, `This plugin failed its integrity check (${entry.path} does not match its recorded fingerprint). Ask the plugin author to rebuild it.`);
114
+ files[kind] = { meta: { sha256: entry.sha256, bytes: bytes.byteLength }, content: decodeText(bytes) };
115
+ }));
116
+ return { build, buildHash: sha256Hex(buildBytes), revision, files };
117
+ }
118
+ function carryConfig(manifest, previous) {
119
+ const config = { ...defaultConfig(manifest) };
120
+ if (!previous)
121
+ return config;
122
+ const plain = {};
123
+ for (const f of manifest.settings ?? []) {
124
+ const v = previous[f.key];
125
+ if (v === undefined)
126
+ continue;
127
+ if (f.type === 'secret') {
128
+ if (isEncrypted(v))
129
+ config[f.key] = v;
130
+ }
131
+ else
132
+ plain[f.key] = v;
133
+ }
134
+ // Keep earlier answers that are still valid under the new version's rules.
135
+ const { values } = validateSettings({ ...manifest, settings: (manifest.settings ?? []).filter((f) => f.type !== 'secret' && f.key in plain) }, plain);
136
+ return { ...config, ...values };
137
+ }
138
+ async function install(cfg, source, pluginPath, existing) {
139
+ const fetched = await fetchBuild(cfg, source, pluginPath);
140
+ const { manifest } = fetched.build;
141
+ const installs = await getInstalls(cfg.storage);
142
+ if (existing) {
143
+ if (manifest.id !== existing.id)
144
+ throw new HttpError(409, 'The plugin at this location now has a different identity and cannot be used as an update.');
145
+ }
146
+ else if (installs[manifest.id]) {
147
+ throw new HttpError(409, `${installs[manifest.id].manifest.name} is already installed.`);
148
+ }
149
+ // Page addresses: keep the owner's choices where the page still exists.
150
+ const paths = {};
151
+ for (const page of manifest.pages ?? []) {
152
+ const chosen = existing?.paths[page.id];
153
+ if (chosen && page.configurablePath !== false && isStaticPath(page.path))
154
+ paths[page.id] = chosen;
155
+ }
156
+ const others = { ...installs };
157
+ delete others[manifest.id];
158
+ const draft = { id: manifest.id, manifest, paths };
159
+ for (const page of manifest.pages ?? []) {
160
+ const problem = pathProblem({ ...others, [manifest.id]: draft }, manifest.id, page.id, effectivePaths(draft)[page.id], reservedPaths(cfg));
161
+ if (problem)
162
+ throw new HttpError(409, `${problem} Change that page's address first, then try again.`);
163
+ }
164
+ const config = carryConfig(manifest, existing?.config);
165
+ const now = new Date().toISOString();
166
+ const files = {};
167
+ for (const kind of KINDS) {
168
+ const f = fetched.files[kind];
169
+ if (!f)
170
+ continue;
171
+ await putFile(cfg.storage, manifest.id, kind, f.meta.sha256, f.content);
172
+ files[kind] = f.meta;
173
+ }
174
+ const incomplete = needsSetup(manifest, config);
175
+ const record = {
176
+ id: manifest.id,
177
+ manifest,
178
+ icon: fetched.build.icon,
179
+ buildHash: fetched.buildHash,
180
+ source: { id: source.id, label: source.label, path: pluginPath, revision: fetched.revision },
181
+ files,
182
+ previousFiles: existing?.files,
183
+ enabled: existing ? existing.enabled && !incomplete : !incomplete,
184
+ config,
185
+ paths,
186
+ installedAt: existing?.installedAt ?? now,
187
+ updatedAt: now,
188
+ };
189
+ try {
190
+ if (fetched.files.server) {
191
+ const server = evaluateServer(manifest.id, fetched.files.server.content);
192
+ if (!existing)
193
+ await server.onInstall?.(pluginContext(cfg, record));
194
+ }
195
+ }
196
+ catch (e) {
197
+ console.error(`[nextpluggable] Plugin "${manifest.id}" failed to start:`, e);
198
+ await pruneFiles(cfg.storage, manifest.id, existing ? [existing.files, existing.previousFiles ?? {}] : []);
199
+ throw new HttpError(422, `${manifest.name} could not be ${existing ? 'updated' : 'installed'} because it failed to start. Nothing was changed.`);
200
+ }
201
+ await saveInstalls(cfg.storage, { ...installs, [manifest.id]: record });
202
+ await pruneFiles(cfg.storage, manifest.id, [record.files, record.previousFiles ?? {}]);
203
+ invalidateInstalledPlugins(cfg);
204
+ return record;
205
+ }
206
+ export async function installPlugin(cfg, input) {
207
+ const source = (await getSources(cfg.storage)).find((s) => s.id === input.sourceId);
208
+ if (!source)
209
+ throw new HttpError(404, 'That source is no longer connected.');
210
+ const pluginPath = typeof input.path === 'string' ? input.path : '';
211
+ if (pluginPath.split('/').some((p) => p === '..'))
212
+ throw new HttpError(400, 'Invalid plugin location.');
213
+ return install(cfg, source, pluginPath, undefined);
214
+ }
215
+ export async function updatePlugin(cfg, pluginId) {
216
+ const all = await getInstalls(cfg.storage);
217
+ const existing = Object.hasOwn(all, pluginId) ? all[pluginId] : undefined;
218
+ if (!existing)
219
+ throw new HttpError(404, 'That plugin is not installed.');
220
+ const source = (await getSources(cfg.storage)).find((s) => s.id === existing.source.id);
221
+ if (!source)
222
+ throw new HttpError(409, `${existing.manifest.name} came from a source that is no longer connected, so it cannot be updated. Connect ${existing.source.label} again first.`);
223
+ const record = await install(cfg, source, existing.source.path, existing);
224
+ await refreshSource(cfg, source).catch(() => { });
225
+ return record;
226
+ }
227
+ export async function changePlugin(cfg, pluginId, patch) {
228
+ const installs = await getInstalls(cfg.storage);
229
+ const current = Object.hasOwn(installs, pluginId) ? installs[pluginId] : undefined;
230
+ if (!current)
231
+ throw new HttpError(404, 'That plugin is not installed.');
232
+ const next = { ...current, config: { ...current.config }, paths: { ...current.paths } };
233
+ const { manifest } = current;
234
+ // Every problem is collected and reported together, so the owner fixes them in one go.
235
+ const errors = {};
236
+ if (patch.config !== undefined) {
237
+ if (typeof patch.config !== 'object' || patch.config === null)
238
+ throw new HttpError(400, 'Invalid settings.');
239
+ const incoming = patch.config;
240
+ const candidate = {};
241
+ for (const f of manifest.settings ?? []) {
242
+ if (Object.hasOwn(incoming, f.key))
243
+ candidate[f.key] = incoming[f.key];
244
+ else if (f.type === 'secret') {
245
+ // Secrets are write-only in the dashboard: leaving the field untouched keeps the stored value.
246
+ const stored = current.config[f.key];
247
+ if (isEncrypted(stored))
248
+ candidate[f.key] = decrypt(cfg.secret, stored) ?? undefined;
249
+ }
250
+ else
251
+ candidate[f.key] = current.config[f.key];
252
+ }
253
+ const checked = validateSettings(manifest, candidate);
254
+ Object.assign(errors, checked.errors);
255
+ for (const f of manifest.settings ?? []) {
256
+ const v = checked.values[f.key];
257
+ if (f.type === 'secret' && typeof v === 'string')
258
+ checked.values[f.key] = encrypt(cfg.secret, v);
259
+ }
260
+ next.config = checked.values;
261
+ }
262
+ if (patch.paths !== undefined) {
263
+ if (typeof patch.paths !== 'object' || patch.paths === null)
264
+ throw new HttpError(400, 'Invalid page addresses.');
265
+ for (const [pageId, raw] of Object.entries(patch.paths)) {
266
+ const page = manifest.pages?.find((p) => p.id === pageId);
267
+ if (!page)
268
+ continue;
269
+ const field = `path:${pageId}`;
270
+ let value = typeof raw === 'string' ? raw.trim() : '';
271
+ if (value && !value.startsWith('/'))
272
+ value = `/${value}`;
273
+ value = value.replace(/\/+$/, '').toLowerCase();
274
+ if (!value || value === page.path) {
275
+ delete next.paths[pageId];
276
+ continue;
277
+ }
278
+ if (value === effectivePaths(current)[pageId])
279
+ continue;
280
+ if (page.configurablePath === false || !isStaticPath(page.path)) {
281
+ errors[field] = 'The address of this page cannot be changed.';
282
+ continue;
283
+ }
284
+ const problem = checkPagePath(value);
285
+ if (problem || !isStaticPath(value)) {
286
+ errors[field] = `The address ${problem ?? 'cannot contain ":" or "*"'}. Use something like /loan-calculator.`;
287
+ continue;
288
+ }
289
+ next.paths[pageId] = value;
290
+ }
291
+ for (const page of manifest.pages ?? []) {
292
+ const field = `path:${page.id}`;
293
+ if (errors[field])
294
+ continue;
295
+ const problem = pathProblem({ ...installs, [pluginId]: next }, pluginId, page.id, effectivePaths(next)[page.id], reservedPaths(cfg));
296
+ if (problem)
297
+ errors[field] = problem;
298
+ }
299
+ }
300
+ if (Object.keys(errors).length)
301
+ throw new HttpError(422, 'Some settings need your attention.', errors);
302
+ if (patch.enabled !== undefined) {
303
+ if (typeof patch.enabled !== 'boolean')
304
+ throw new HttpError(400, 'Invalid request.');
305
+ next.enabled = patch.enabled;
306
+ }
307
+ if (next.enabled && needsSetup(manifest, next.config)) {
308
+ if (patch.enabled === true)
309
+ throw new HttpError(422, `${manifest.name} needs a few settings filled in before it can be turned on.`);
310
+ next.enabled = false;
311
+ }
312
+ next.updatedAt = new Date().toISOString();
313
+ await saveInstalls(cfg.storage, { ...installs, [pluginId]: next });
314
+ invalidateInstalledPlugins(cfg);
315
+ return next;
316
+ }
317
+ export async function uninstallPlugin(cfg, pluginId, options = {}) {
318
+ const installs = await getInstalls(cfg.storage);
319
+ const current = Object.hasOwn(installs, pluginId) ? installs[pluginId] : undefined;
320
+ if (!current)
321
+ throw new HttpError(404, 'That plugin is not installed.');
322
+ try {
323
+ await (await loadServer(cfg, current))?.onUninstall?.(pluginContext(cfg, current));
324
+ }
325
+ catch (e) {
326
+ console.error(`[nextpluggable] Plugin "${pluginId}" failed in its uninstall step (continuing):`, e);
327
+ }
328
+ const rest = { ...installs };
329
+ delete rest[pluginId];
330
+ await saveInstalls(cfg.storage, rest);
331
+ invalidateInstalledPlugins(cfg);
332
+ await pruneFiles(cfg.storage, pluginId, []);
333
+ if (options.deleteData)
334
+ await deletePluginData(cfg.storage, pluginId);
335
+ const g = globals();
336
+ for (const cache of [g.serverModules, g.ssrCode])
337
+ for (const k of cache.keys())
338
+ if (k.startsWith(`${pluginId}@`))
339
+ cache.delete(k);
340
+ }
@@ -0,0 +1,20 @@
1
+ import type { PluginPage } from '@nextpluggable/plugin/manifest';
2
+ import type { InstallRecord, Installs } from './types.js';
3
+ export declare function normalizePathname(pathname: string): string;
4
+ /** Match a pathname against a pattern such as `/shop/:slug` or `/docs/*`. Returns params, or null. */
5
+ export declare function matchPattern(pattern: string, pathname: string): Record<string, string> | null;
6
+ /** The address each page of a plugin is served at (owner's choice, else the plugin's default). */
7
+ export declare function effectivePaths(install: InstallRecord): Record<string, string>;
8
+ export interface PageMatch {
9
+ install: InstallRecord;
10
+ page: PluginPage;
11
+ /** The pattern that matched. */
12
+ pattern: string;
13
+ params: Record<string, string>;
14
+ }
15
+ export declare function findPage(installs: Installs, pathname: string): PageMatch | null;
16
+ /**
17
+ * Check a page address before it is saved. Returns a sentence for the site
18
+ * owner when the address cannot be used, or null when it is fine.
19
+ */
20
+ export declare function pathProblem(installs: Installs, pluginId: string, pageId: string, pattern: string, reserved: string[]): string | null;
@@ -0,0 +1,95 @@
1
+ import { isStaticPath } from '@nextpluggable/plugin/manifest';
2
+ export function normalizePathname(pathname) {
3
+ const p = `/${pathname}`.replace(/\/{2,}/g, '/');
4
+ return p.length > 1 ? p.replace(/\/+$/, '') : p;
5
+ }
6
+ const safeDecode = (s) => {
7
+ try {
8
+ return decodeURIComponent(s);
9
+ }
10
+ catch {
11
+ return s;
12
+ }
13
+ };
14
+ /** Match a pathname against a pattern such as `/shop/:slug` or `/docs/*`. Returns params, or null. */
15
+ export function matchPattern(pattern, pathname) {
16
+ const want = pattern === '/' ? [] : pattern.slice(1).split('/');
17
+ const got = pathname === '/' ? [] : pathname.slice(1).split('/');
18
+ const params = {};
19
+ for (let i = 0; i < want.length; i++) {
20
+ const w = want[i];
21
+ if (w === '*') {
22
+ params['*'] = got.slice(i).map(safeDecode).join('/');
23
+ return params;
24
+ }
25
+ const g = got[i];
26
+ if (g === undefined || g === '')
27
+ return null;
28
+ if (w.startsWith(':'))
29
+ params[w.slice(1)] = safeDecode(g);
30
+ else if (w !== g && w !== safeDecode(g))
31
+ return null;
32
+ }
33
+ return got.length === want.length ? params : null;
34
+ }
35
+ /** Higher wins: fully static paths, then more literal segments, wildcards last. */
36
+ function specificity(pattern) {
37
+ const segs = pattern.slice(1).split('/');
38
+ let score = isStaticPath(pattern) ? 1_000_000 : 0;
39
+ for (const s of segs)
40
+ score += s === '*' ? -1000 : s.startsWith(':') ? 1 : 100;
41
+ return score;
42
+ }
43
+ /** The address each page of a plugin is served at (owner's choice, else the plugin's default). */
44
+ export function effectivePaths(install) {
45
+ const out = {};
46
+ for (const page of install.manifest.pages ?? [])
47
+ out[page.id] = install.paths[page.id] ?? page.path;
48
+ return out;
49
+ }
50
+ export function findPage(installs, pathname) {
51
+ const path = normalizePathname(pathname);
52
+ let best = null;
53
+ for (const install of Object.values(installs)) {
54
+ if (!install.enabled)
55
+ continue;
56
+ const paths = effectivePaths(install);
57
+ for (const page of install.manifest.pages ?? []) {
58
+ const pattern = paths[page.id];
59
+ const params = matchPattern(pattern, path);
60
+ if (!params)
61
+ continue;
62
+ const score = specificity(pattern);
63
+ if (!best || score > best.score)
64
+ best = { install, page, pattern, params, score };
65
+ }
66
+ }
67
+ return best;
68
+ }
69
+ /** Two patterns collide when they would match exactly the same addresses. */
70
+ const shape = (pattern) => pattern
71
+ .split('/')
72
+ .map((s) => (s.startsWith(':') ? ':' : s))
73
+ .join('/');
74
+ /**
75
+ * Check a page address before it is saved. Returns a sentence for the site
76
+ * owner when the address cannot be used, or null when it is fine.
77
+ */
78
+ export function pathProblem(installs, pluginId, pageId, pattern, reserved) {
79
+ for (const prefix of reserved) {
80
+ if (pattern === prefix || pattern.startsWith(`${prefix}/`))
81
+ return `The address ${pattern} is reserved by the site.`;
82
+ }
83
+ for (const other of Object.values(installs)) {
84
+ const paths = effectivePaths(other);
85
+ for (const page of other.manifest.pages ?? []) {
86
+ if (other.id === pluginId && page.id === pageId)
87
+ continue;
88
+ if (shape(paths[page.id]) === shape(pattern))
89
+ return other.id === pluginId
90
+ ? `The address ${pattern} is already used by another page of this plugin.`
91
+ : `The address ${pattern} is already used by the plugin "${other.manifest.name}".`;
92
+ }
93
+ }
94
+ return null;
95
+ }
@@ -0,0 +1,32 @@
1
+ import type { AdminUser, PageResult, PluginContext, PluginServer, PluginStorage } from '@nextpluggable/plugin/server';
2
+ import type { ResolvedConfig } from '../config.js';
3
+ import type { InstallRecord } from './types.js';
4
+ /** Identifies one exact build of a plugin's client bundle (used on both server and browser). */
5
+ export declare function clientModuleKey(install: InstallRecord): string | null;
6
+ export declare function assetUrl(cfg: ResolvedConfig, pluginId: string, sha256: string, file: 'client.js' | 'style.css'): string;
7
+ /** Setting values safe to send to browsers, with defaults filled in. */
8
+ export declare function publicConfig(install: InstallRecord): Record<string, unknown>;
9
+ /** Every setting value, secrets decrypted. Server-side only. */
10
+ export declare function serverConfig(cfg: ResolvedConfig, install: InstallRecord): Record<string, unknown>;
11
+ export declare function pluginStorage(cfg: ResolvedConfig, pluginId: string): PluginStorage;
12
+ export declare function pluginContext(cfg: ResolvedConfig, install: InstallRecord): PluginContext;
13
+ /** Evaluate a plugin's server bundle (CommonJS, self-contained) and return what it exports. */
14
+ export declare function evaluateServer(pluginId: string, code: string): PluginServer;
15
+ /** The plugin's server module, or null when it has none. Cached by content hash. */
16
+ export declare function loadServer(cfg: ResolvedConfig, install: InstallRecord): Promise<PluginServer | null>;
17
+ export interface ClientAssets {
18
+ moduleKey: string;
19
+ scriptSrc: string;
20
+ styleHref?: string;
21
+ }
22
+ /**
23
+ * Make a plugin's client bundle available to the SSR pass of this process and
24
+ * return the URLs the browser loads it from.
25
+ */
26
+ export declare function prepareClient(cfg: ResolvedConfig, install: InstallRecord): Promise<ClientAssets | null>;
27
+ export declare function runPageLoader(cfg: ResolvedConfig, install: InstallRecord, pageId: string, input: {
28
+ path: string;
29
+ params: Record<string, string>;
30
+ }): Promise<PageResult>;
31
+ /** Dispatch a request to a plugin's API handlers (`"METHOD /path"` keys). */
32
+ export declare function handlePluginApi(cfg: ResolvedConfig, install: InstallRecord, request: Request, subpath: string, admin: AdminUser | null): Promise<Response>;
@@ -0,0 +1,190 @@
1
+ import { defaultConfig, isPublicSetting } from '@nextpluggable/plugin/manifest';
2
+ import { decrypt, isEncrypted } from '../crypto.js';
3
+ import { jsonResponse, PluginGoneError } from '../errors.js';
4
+ import { globals } from '../globals.js';
5
+ import { decodeSegment } from '../storage/types.js';
6
+ import { effectivePaths, matchPattern, normalizePathname } from './routes.js';
7
+ import { dataKey, getFile } from './store.js';
8
+ /** Identifies one exact build of a plugin's client bundle (used on both server and browser). */
9
+ export function clientModuleKey(install) {
10
+ return install.files.client ? `${install.id}@${install.files.client.sha256.slice(0, 16)}` : null;
11
+ }
12
+ export function assetUrl(cfg, pluginId, sha256, file) {
13
+ return `${cfg.apiPath}/assets/${pluginId}/${sha256.slice(0, 16)}/${file}`;
14
+ }
15
+ /** Setting values safe to send to browsers, with defaults filled in. */
16
+ export function publicConfig(install) {
17
+ const out = {};
18
+ const defaults = defaultConfig(install.manifest);
19
+ for (const field of install.manifest.settings ?? []) {
20
+ if (!isPublicSetting(field))
21
+ continue;
22
+ const v = install.config[field.key] ?? defaults[field.key];
23
+ if (v !== undefined)
24
+ out[field.key] = v;
25
+ }
26
+ return out;
27
+ }
28
+ /** Every setting value, secrets decrypted. Server-side only. */
29
+ export function serverConfig(cfg, install) {
30
+ const out = { ...defaultConfig(install.manifest) };
31
+ for (const field of install.manifest.settings ?? []) {
32
+ const v = install.config[field.key];
33
+ if (v === undefined)
34
+ continue;
35
+ if (isEncrypted(v)) {
36
+ const plain = decrypt(cfg.secret, v);
37
+ if (plain !== null)
38
+ out[field.key] = plain;
39
+ }
40
+ else
41
+ out[field.key] = v;
42
+ }
43
+ return out;
44
+ }
45
+ export function pluginStorage(cfg, pluginId) {
46
+ const prefix = `data/${pluginId}/`;
47
+ return {
48
+ async get(key) {
49
+ const raw = await cfg.storage.get(dataKey(pluginId, key));
50
+ return raw === null ? null : JSON.parse(raw);
51
+ },
52
+ async set(key, value) {
53
+ const raw = JSON.stringify(value);
54
+ if (raw === undefined)
55
+ throw new Error('Only JSON values can be stored.');
56
+ if (raw.length > 1_000_000)
57
+ throw new Error('A stored value may be at most 1 MB.');
58
+ await cfg.storage.set(dataKey(pluginId, key), raw);
59
+ },
60
+ async delete(key) {
61
+ await cfg.storage.delete(dataKey(pluginId, key));
62
+ },
63
+ async list(keyPrefix = '') {
64
+ const keys = await cfg.storage.list(prefix);
65
+ return keys
66
+ .map((k) => decodeSegment(k.slice(prefix.length)))
67
+ .filter((k) => k.startsWith(keyPrefix))
68
+ .sort();
69
+ },
70
+ };
71
+ }
72
+ export function pluginContext(cfg, install) {
73
+ return {
74
+ pluginId: install.id,
75
+ config: serverConfig(cfg, install),
76
+ storage: pluginStorage(cfg, install.id),
77
+ site: { url: cfg.siteUrl },
78
+ paths: effectivePaths(install),
79
+ log: (...args) => console.log(`[plugin:${install.id}]`, ...args),
80
+ };
81
+ }
82
+ /** Evaluate a plugin's server bundle (CommonJS, self-contained) and return what it exports. */
83
+ export function evaluateServer(pluginId, code) {
84
+ const getBuiltin = process.getBuiltinModule;
85
+ if (!getBuiltin)
86
+ throw new Error('Plugins with server code need Node.js 20.16 or newer.');
87
+ const mod = { exports: {} };
88
+ const req = (name) => {
89
+ const builtin = getBuiltin(name);
90
+ if (!builtin)
91
+ throw new Error(`Plugin "${pluginId}" tried to load "${name}", which is not available. Server bundles may only use Node built-ins at runtime.`);
92
+ return builtin;
93
+ };
94
+ // Plugins are trusted code chosen by the site owner; this is deliberate evaluation, not a sandbox.
95
+ new Function('require', 'module', 'exports', `${code}\n//# sourceURL=nextpluggable-plugin:///${pluginId}/server.js`)(req, mod, mod.exports);
96
+ const server = (mod.exports.default ?? mod.exports);
97
+ if (!server || typeof server !== 'object')
98
+ throw new Error(`Plugin "${pluginId}" has an invalid server entry.`);
99
+ return server;
100
+ }
101
+ /** The plugin's server module, or null when it has none. Cached by content hash. */
102
+ export async function loadServer(cfg, install) {
103
+ const file = install.files.server;
104
+ if (!file)
105
+ return null;
106
+ const cache = globals().serverModules;
107
+ const key = `${install.id}@${file.sha256}`;
108
+ const hit = cache.get(key);
109
+ if (hit)
110
+ return hit;
111
+ const code = await getFile(cfg.storage, install.id, 'server', file.sha256);
112
+ if (code === null)
113
+ throw new PluginGoneError(install.id);
114
+ const server = evaluateServer(install.id, code);
115
+ for (const k of cache.keys())
116
+ if (k.startsWith(`${install.id}@`))
117
+ cache.delete(k);
118
+ cache.set(key, server);
119
+ return server;
120
+ }
121
+ /**
122
+ * Make a plugin's client bundle available to the SSR pass of this process and
123
+ * return the URLs the browser loads it from.
124
+ */
125
+ export async function prepareClient(cfg, install) {
126
+ const client = install.files.client;
127
+ const moduleKey = clientModuleKey(install);
128
+ if (!client || !moduleKey)
129
+ return null;
130
+ const ssr = globals().ssrCode;
131
+ if (!ssr.has(moduleKey)) {
132
+ const code = await getFile(cfg.storage, install.id, 'client', client.sha256);
133
+ if (code === null)
134
+ throw new PluginGoneError(install.id);
135
+ for (const k of ssr.keys())
136
+ if (k.startsWith(`${install.id}@`))
137
+ ssr.delete(k);
138
+ ssr.set(moduleKey, code);
139
+ }
140
+ return {
141
+ moduleKey,
142
+ scriptSrc: assetUrl(cfg, install.id, client.sha256, 'client.js'),
143
+ styleHref: install.files.style ? assetUrl(cfg, install.id, install.files.style.sha256, 'style.css') : undefined,
144
+ };
145
+ }
146
+ export async function runPageLoader(cfg, install, pageId, input) {
147
+ const server = await loadServer(cfg, install);
148
+ const loader = server?.pages?.[pageId];
149
+ if (!loader)
150
+ return {};
151
+ const ctx = { ...pluginContext(cfg, install), ...input };
152
+ const result = await loader(ctx);
153
+ return result && typeof result === 'object' ? result : {};
154
+ }
155
+ /** Dispatch a request to a plugin's API handlers (`"METHOD /path"` keys). */
156
+ export async function handlePluginApi(cfg, install, request, subpath, admin) {
157
+ let server;
158
+ try {
159
+ server = await loadServer(cfg, install);
160
+ }
161
+ catch (e) {
162
+ console.error(`[nextpluggable] Could not load plugin "${install.id}":`, e);
163
+ return jsonResponse({ error: 'This plugin could not be loaded.' }, 500);
164
+ }
165
+ const path = normalizePathname(subpath);
166
+ const method = request.method.toUpperCase() === 'HEAD' ? 'GET' : request.method.toUpperCase();
167
+ let methodMismatch = false;
168
+ for (const [key, handler] of Object.entries(server?.api ?? {})) {
169
+ const space = key.indexOf(' ');
170
+ const params = matchPattern(key.slice(space + 1), path);
171
+ if (!params)
172
+ continue;
173
+ if (key.slice(0, space) !== method) {
174
+ methodMismatch = true;
175
+ continue;
176
+ }
177
+ const ctx = { ...pluginContext(cfg, install), request, path, params, admin };
178
+ try {
179
+ const res = await handler(ctx);
180
+ if (!res || typeof res.status !== 'number' || typeof res.headers?.get !== 'function')
181
+ throw new Error(`Handler "${key}" did not return a Response.`);
182
+ return res;
183
+ }
184
+ catch (e) {
185
+ console.error(`[nextpluggable] Plugin "${install.id}" failed handling ${key}:`, e);
186
+ return jsonResponse({ error: 'The plugin ran into a problem handling this request.' }, 500);
187
+ }
188
+ }
189
+ return methodMismatch ? jsonResponse({ error: 'Method not allowed.' }, 405) : jsonResponse({ error: 'Not found.' }, 404);
190
+ }