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.
- package/README.md +288 -2
- 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 +123 -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 +55 -0
- package/dist/config.js +57 -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 +34 -0
- package/dist/core/runtime.js +195 -0
- package/dist/core/sitemap.d.ts +12 -0
- package/dist/core/sitemap.js +83 -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 +47 -0
- package/dist/index.js +65 -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 +91 -0
- package/dist/next/seo.d.ts +32 -0
- package/dist/next/seo.js +86 -0
- package/dist/next/slot.d.ts +7 -0
- package/dist/next/slot.js +43 -0
- package/dist/next/style.d.ts +10 -0
- package/dist/next/style.js +13 -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
|
@@ -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,34 @@
|
|
|
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
|
+
/** The stylesheet itself, when it is small enough to be written into the page. */
|
|
22
|
+
styleCss?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Make a plugin's client bundle available to the SSR pass of this process and
|
|
26
|
+
* return the URLs the browser loads it from.
|
|
27
|
+
*/
|
|
28
|
+
export declare function prepareClient(cfg: ResolvedConfig, install: InstallRecord): Promise<ClientAssets | null>;
|
|
29
|
+
export declare function runPageLoader(cfg: ResolvedConfig, install: InstallRecord, pageId: string, input: {
|
|
30
|
+
path: string;
|
|
31
|
+
params: Record<string, string>;
|
|
32
|
+
}): Promise<PageResult>;
|
|
33
|
+
/** Dispatch a request to a plugin's API handlers (`"METHOD /path"` keys). */
|
|
34
|
+
export declare function handlePluginApi(cfg: ResolvedConfig, install: InstallRecord, request: Request, subpath: string, admin: AdminUser | null): Promise<Response>;
|