@stelstone/server 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +275 -0
- package/bin/stelstone.mjs +181 -0
- package/package.json +53 -0
- package/src/adapters/_shared.mjs +401 -0
- package/src/adapters/basic-auth.mjs +102 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +79 -0
- package/src/adapters/cloudflare-access.mjs +144 -0
- package/src/adapters/fs-json-content.mjs +302 -0
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +100 -0
- package/src/adapters/github-content.mjs +577 -0
- package/src/adapters/github-oauth.mjs +153 -0
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +12 -0
- package/src/adapters/local-assets-media.mjs +68 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/resend-mail.mjs +41 -0
- package/src/adapters/types.mjs +104 -0
- package/src/admin-ui-path.mjs +77 -0
- package/src/core/adapter-options.mjs +167 -0
- package/src/core/config-schema.mjs +408 -0
- package/src/core/forms.mjs +99 -0
- package/src/core/handler.mjs +209 -0
- package/src/core/node-adapter.mjs +99 -0
- package/src/core/static-files.mjs +115 -0
- package/src/default-public-config.mjs +39 -0
- package/src/index.mjs +22 -0
- package/src/routes.mjs +737 -0
- package/src/server.mjs +325 -0
- package/src/version.mjs +8 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveAdminUiDir, resolveAdminUiSourceDir, resolveAdminUiVersion } from "./admin-ui-path.mjs";
|
|
5
|
+
import { defaultPublicConfig } from "./default-public-config.mjs";
|
|
6
|
+
import { createRequestHandler } from "./core/handler.mjs";
|
|
7
|
+
import { toNodeMiddleware } from "./core/node-adapter.mjs";
|
|
8
|
+
import { createStaticHandler, fileResponse } from "./core/static-files.mjs";
|
|
9
|
+
import { assertValidConfig, describeConfig } from "./core/config-schema.mjs";
|
|
10
|
+
import {
|
|
11
|
+
githubContentOptions,
|
|
12
|
+
fsContentOptions,
|
|
13
|
+
githubTemplatesOptions,
|
|
14
|
+
authOptions,
|
|
15
|
+
netlifyBuildOptions,
|
|
16
|
+
cdnMediaOptions,
|
|
17
|
+
mailOptions,
|
|
18
|
+
} from "./core/adapter-options.mjs";
|
|
19
|
+
import {
|
|
20
|
+
createFsJsonContent,
|
|
21
|
+
createGitHubContent,
|
|
22
|
+
createLocalAssetsMedia,
|
|
23
|
+
createCdnProxyMedia,
|
|
24
|
+
createBasicAuth,
|
|
25
|
+
createGitHubOAuth,
|
|
26
|
+
createCloudflareAccess,
|
|
27
|
+
createNetlifyBuild,
|
|
28
|
+
createFsTemplates,
|
|
29
|
+
createGitHubTemplates,
|
|
30
|
+
createResendMail,
|
|
31
|
+
} from "./adapters/index.mjs";
|
|
32
|
+
import { createMemoryRateLimiter } from "./core/forms.mjs";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the CMS request handler for the Node runtime.
|
|
36
|
+
*
|
|
37
|
+
* Request policy lives in `core/handler.mjs`, shared with the Worker runtime;
|
|
38
|
+
* this module only wires adapters and adds the Node-only pieces (serving local
|
|
39
|
+
* asset files from disk).
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} opts
|
|
42
|
+
* @param {Object} opts.config cms.config object
|
|
43
|
+
* @param {string} opts.rootDir absolute path to consumer project root
|
|
44
|
+
* @param {() => Object} [opts.publicConfig] returns sanitized config for the browser
|
|
45
|
+
* @param {string} [opts.realm] retained for API compatibility
|
|
46
|
+
* @returns {{ handle: (req: Request) => Promise<Response|null>,
|
|
47
|
+
* middleware: Function, adapters: Object }}
|
|
48
|
+
*/
|
|
49
|
+
export function createCmsServer({ config, rootDir, publicConfig: publicConfigFn, realm = "CMS Admin" }) {
|
|
50
|
+
const secrets = (name) => (name ? process.env[name] : undefined);
|
|
51
|
+
|
|
52
|
+
// Refuse to start on a broken config, and report every problem at once
|
|
53
|
+
// rather than the first one encountered.
|
|
54
|
+
assertValidConfig(config, { runtime: "node", getSecret: secrets });
|
|
55
|
+
|
|
56
|
+
const useGitHub = config.content?.provider === "github";
|
|
57
|
+
|
|
58
|
+
const content = useGitHub
|
|
59
|
+
? createGitHubContent(githubContentOptions(config, secrets))
|
|
60
|
+
: createFsJsonContent(fsContentOptions(config, secrets, rootDir));
|
|
61
|
+
|
|
62
|
+
const localMedia = createLocalAssetsMedia({
|
|
63
|
+
rootDir,
|
|
64
|
+
assetsDir: config.content.assetsDir,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const { provider: authProvider, options: authOpts } = authOptions(config, secrets);
|
|
68
|
+
const auth =
|
|
69
|
+
authProvider === "github-oauth"
|
|
70
|
+
? createGitHubOAuth({ ...authOpts, realm })
|
|
71
|
+
: authProvider === "cloudflare-access"
|
|
72
|
+
? createCloudflareAccess(authOpts)
|
|
73
|
+
: createBasicAuth({ ...authOpts, realm });
|
|
74
|
+
|
|
75
|
+
if (!auth.configured) {
|
|
76
|
+
console.warn("WARNING: no CMS credentials configured — running without authentication");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No media config → no CDN adapter; the media routes answer 404 instead
|
|
80
|
+
// of every /api request dying while the adapter bag is built.
|
|
81
|
+
const cdnMedia = config.media ? createCdnProxyMedia(cdnMediaOptions(config, auth)) : undefined;
|
|
82
|
+
const build = createNetlifyBuild(netlifyBuildOptions(config, secrets));
|
|
83
|
+
|
|
84
|
+
const templates = useGitHub
|
|
85
|
+
? createGitHubTemplates(githubTemplatesOptions(config, secrets))
|
|
86
|
+
: createFsTemplates({ rootDir });
|
|
87
|
+
|
|
88
|
+
const adapters = {
|
|
89
|
+
content,
|
|
90
|
+
templates,
|
|
91
|
+
localMedia,
|
|
92
|
+
cdnMedia,
|
|
93
|
+
auth,
|
|
94
|
+
build,
|
|
95
|
+
// Forms: Resend delivery + a per-process rate limiter. The Worker builds
|
|
96
|
+
// the same pair with a KV-backed limiter.
|
|
97
|
+
mail: config.mail ? createResendMail(mailOptions(config, secrets)) : undefined,
|
|
98
|
+
formLimiter: createMemoryRateLimiter(),
|
|
99
|
+
publicConfig: () => (publicConfigFn ?? defaultPublicConfig)(config),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleApi = createRequestHandler({
|
|
103
|
+
config,
|
|
104
|
+
adapters,
|
|
105
|
+
runtime: "node",
|
|
106
|
+
// Read from the package this server would actually serve, not baked in.
|
|
107
|
+
adminUiVersion: resolveAdminUiVersion(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Local asset files are static and public, exactly as before.
|
|
111
|
+
const assetFiles = createStaticHandler({
|
|
112
|
+
root: path.join(rootDir, config.content.assetsDir || "src/assets"),
|
|
113
|
+
mount: localMedia.urlPrefix,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
async function handle(request) {
|
|
117
|
+
const apiResponse = await handleApi(request);
|
|
118
|
+
if (apiResponse) return apiResponse;
|
|
119
|
+
return assetFiles(request);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { handle, middleware: toNodeMiddleware(handle), adapters };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Start a background scheduler that auto-publishes entries whose
|
|
127
|
+
* `meta.publishAt` timestamp has passed. Runs every 60 seconds.
|
|
128
|
+
* Returns a stop function.
|
|
129
|
+
*/
|
|
130
|
+
export function startScheduler(content, intervalMs = 60_000) {
|
|
131
|
+
async function run() {
|
|
132
|
+
try {
|
|
133
|
+
const due = await content.listScheduled();
|
|
134
|
+
if (!due.length) return;
|
|
135
|
+
const items = due.map(({ collection, file, data }) => {
|
|
136
|
+
delete data.meta.publishAt;
|
|
137
|
+
data.meta.draft = false;
|
|
138
|
+
return { collection, file, data };
|
|
139
|
+
});
|
|
140
|
+
await content.writeBatch(items, "[scheduler] Auto-publish");
|
|
141
|
+
console.log(`[scheduler] Auto-published ${items.length} item(s)`);
|
|
142
|
+
// fs adapter commits at publish time — scoped to the scheduled entries
|
|
143
|
+
// only, so an editor's unfinished draft never gets swept into the
|
|
144
|
+
// scheduler's commit. (github adapter ignores both args, its writeBatch
|
|
145
|
+
// already committed with the message.)
|
|
146
|
+
const result = await content.publish("[scheduler] Auto-publish", {
|
|
147
|
+
entries: items.map(({ collection, file }) => ({ collection, file })),
|
|
148
|
+
});
|
|
149
|
+
console.log("[scheduler] " + result.message);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("[scheduler] Error:", err.message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const timer = setInterval(run, intervalMs);
|
|
155
|
+
return () => clearInterval(timer);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Serve the prebuilt admin SPA, plus the optional per-site preview stylesheet. */
|
|
159
|
+
function createAdminUiHandler({ dir, previewThemeCss }) {
|
|
160
|
+
const files = createStaticHandler({ root: dir, mount: "/admin", spaFallback: true });
|
|
161
|
+
return function serveAdminUi(request) {
|
|
162
|
+
const { pathname } = new URL(request.url);
|
|
163
|
+
// Answer before the static handler so the <link> never 404s.
|
|
164
|
+
if (pathname === "/admin/preview-theme.css") {
|
|
165
|
+
if (previewThemeCss && fs.existsSync(previewThemeCss)) {
|
|
166
|
+
return fileResponse(path.resolve(previewThemeCss));
|
|
167
|
+
}
|
|
168
|
+
return new Response("/* no preview-theme.css configured */", {
|
|
169
|
+
headers: { "Content-Type": "text/css" },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return files(request);
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Mount connect-style middleware under a URL prefix, stripping it like connect does. */
|
|
177
|
+
function mountPrefix(prefix, middleware) {
|
|
178
|
+
return (req, res, next) => {
|
|
179
|
+
const url = req.url || "/";
|
|
180
|
+
if (url !== prefix && !url.startsWith(`${prefix}/`) && !url.startsWith(`${prefix}?`)) {
|
|
181
|
+
return next();
|
|
182
|
+
}
|
|
183
|
+
const originalUrl = url;
|
|
184
|
+
req.url = url.slice(prefix.length) || "/";
|
|
185
|
+
middleware(req, res, (err) => {
|
|
186
|
+
req.url = originalUrl;
|
|
187
|
+
next(err);
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Decide which admin UI to mount.
|
|
194
|
+
*
|
|
195
|
+
* Dev prefers the admin-ui *source* (Vite + HMR), but that only exists in a
|
|
196
|
+
* monorepo/linked checkout — the published tarball ships `dist` alone. So a
|
|
197
|
+
* missing source is not an error: fall back to the built bundle. Consumers
|
|
198
|
+
* that skip this fallback end up with `/admin` unmounted and a confusing 404
|
|
199
|
+
* while the API works fine.
|
|
200
|
+
*
|
|
201
|
+
* Returns an options object for `resolveAdminUi`, or null when neither the
|
|
202
|
+
* source nor a built dist is present.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveAdminUiOptions({ dev = false, previewThemeCss = null, onWarn = console.warn } = {}) {
|
|
205
|
+
if (dev) {
|
|
206
|
+
const root = resolveAdminUiSourceDir();
|
|
207
|
+
if (root) return { mode: "vite-dev", root, base: "/admin/", previewThemeCss };
|
|
208
|
+
// Not worth a warning on its own: serving the built bundle is the normal
|
|
209
|
+
// case for an installed package, it just means no HMR for the admin UI.
|
|
210
|
+
}
|
|
211
|
+
const dir = resolveAdminUiDir();
|
|
212
|
+
if (!dir) {
|
|
213
|
+
onWarn("admin-ui not found — run `npm run build:admin-ui`, or pass the adminUi option explicitly.");
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
return { mode: "static", dir, previewThemeCss };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the admin UI into something mountable. Three modes:
|
|
221
|
+
* { mode: "static", dir } serve a prebuilt SPA bundle
|
|
222
|
+
* { mode: "vite-dev", root, base="/admin/" } use Vite middleware (HMR)
|
|
223
|
+
* { mode: "auto", distDir, sourceDir } vite-dev when NODE_ENV !== "production"
|
|
224
|
+
*
|
|
225
|
+
* Returns `{ fetchHandler }` for the static mode and `{ nodeMiddleware }` for
|
|
226
|
+
* Vite, since Vite's dev server is connect-based and not a Fetch handler.
|
|
227
|
+
*/
|
|
228
|
+
export async function resolveAdminUi(adminUi) {
|
|
229
|
+
if (!adminUi) return {};
|
|
230
|
+
|
|
231
|
+
const resolved =
|
|
232
|
+
adminUi.mode === "auto"
|
|
233
|
+
? process.env.NODE_ENV === "production"
|
|
234
|
+
? { mode: "static", dir: adminUi.distDir, previewThemeCss: adminUi.previewThemeCss }
|
|
235
|
+
: {
|
|
236
|
+
mode: "vite-dev",
|
|
237
|
+
root: adminUi.sourceDir,
|
|
238
|
+
base: adminUi.base || "/admin/",
|
|
239
|
+
previewThemeCss: adminUi.previewThemeCss,
|
|
240
|
+
}
|
|
241
|
+
: adminUi;
|
|
242
|
+
|
|
243
|
+
if (resolved.mode === "static") {
|
|
244
|
+
if (!fs.existsSync(path.join(resolved.dir, "index.html"))) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`admin-ui not built at ${resolved.dir}. Run the admin-ui build before starting the server in production.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return { fetchHandler: createAdminUiHandler(resolved) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (resolved.mode === "vite-dev") {
|
|
253
|
+
const { createServer: createViteServer } = await import("vite");
|
|
254
|
+
const vite = await createViteServer({
|
|
255
|
+
root: resolved.root,
|
|
256
|
+
base: resolved.base || "/admin/",
|
|
257
|
+
server: { middlewareMode: true },
|
|
258
|
+
appType: "spa",
|
|
259
|
+
});
|
|
260
|
+
console.log("Admin UI: Vite dev middleware (HMR enabled)");
|
|
261
|
+
return {
|
|
262
|
+
nodeMiddleware: mountPrefix("/admin", vite.middlewares),
|
|
263
|
+
previewThemeCss: resolved.previewThemeCss,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
throw new Error(`Unknown adminUi mode: ${resolved.mode}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Run a connect-style middleware chain, ending in 404. */
|
|
271
|
+
function runChain(chain, req, res) {
|
|
272
|
+
let i = 0;
|
|
273
|
+
const next = (err) => {
|
|
274
|
+
if (err) {
|
|
275
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
276
|
+
return res.end(JSON.stringify({ error: err.message }));
|
|
277
|
+
}
|
|
278
|
+
const middleware = chain[i++];
|
|
279
|
+
if (!middleware) {
|
|
280
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
281
|
+
return res.end("Not found");
|
|
282
|
+
}
|
|
283
|
+
middleware(req, res, next);
|
|
284
|
+
};
|
|
285
|
+
next();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Convenience: build the handler, mount the admin UI, listen on a port.
|
|
290
|
+
* Returns { server, handle, adapters, stopScheduler }.
|
|
291
|
+
*/
|
|
292
|
+
export async function startCmsServer(opts) {
|
|
293
|
+
const { handle, adapters } = createCmsServer(opts);
|
|
294
|
+
|
|
295
|
+
const previewThemeCss = opts.config?.previewThemeCss
|
|
296
|
+
? path.resolve(opts.rootDir || process.cwd(), opts.config.previewThemeCss)
|
|
297
|
+
: null;
|
|
298
|
+
|
|
299
|
+
const adminUiOpts =
|
|
300
|
+
opts.adminUi ?? resolveAdminUiOptions({ dev: opts.dev, previewThemeCss });
|
|
301
|
+
|
|
302
|
+
const adminUi = await resolveAdminUi(adminUiOpts);
|
|
303
|
+
|
|
304
|
+
// API and local assets first, then the admin SPA, then Vite (dev only).
|
|
305
|
+
const chain = [
|
|
306
|
+
toNodeMiddleware(async (request) => (await handle(request)) ?? adminUi.fetchHandler?.(request) ?? null),
|
|
307
|
+
adminUi.nodeMiddleware,
|
|
308
|
+
].filter(Boolean);
|
|
309
|
+
|
|
310
|
+
const stopScheduler = startScheduler(adapters.content);
|
|
311
|
+
const port = opts.port || process.env.ADMIN_PORT || 4000;
|
|
312
|
+
|
|
313
|
+
return await new Promise((resolve) => {
|
|
314
|
+
const server = http.createServer((req, res) => runChain(chain, req, res));
|
|
315
|
+
server.listen(port, () => {
|
|
316
|
+
// Print what actually resolved, so a misconfiguration shows up in the
|
|
317
|
+
// first lines of the log instead of as odd behaviour later.
|
|
318
|
+
for (const line of describeConfig(opts.config, { adapters, runtime: "node" })) {
|
|
319
|
+
console.log(line);
|
|
320
|
+
}
|
|
321
|
+
console.log(`Admin server running at http://localhost:${port}/admin`);
|
|
322
|
+
resolve({ server, handle, adapters, stopScheduler });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
package/src/version.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated by scripts/sync-versions.mjs — do not edit.
|
|
3
|
+
*
|
|
4
|
+
* A constant rather than a package.json read: the Worker bundle has no
|
|
5
|
+
* require() and no import.meta.url, so reading the manifest at runtime yields
|
|
6
|
+
* "unknown" there.
|
|
7
|
+
*/
|
|
8
|
+
export const SERVER_VERSION = "0.26.0";
|