@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/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
+ }
@@ -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";