@micacms/runtime 0.5.0-rc.2 → 0.6.0-rc.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 +3 -1
- package/dist/app.js +1 -1
- package/dist/app.js.map +1 -1
- package/dist/config.d.ts +13 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +2 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +9 -3
- package/dist/context.js.map +1 -1
- package/dist/http.d.ts +1 -3
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +2 -9
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/errors.d.ts +1 -2
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +0 -2
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/id.d.ts +0 -10
- package/dist/lib/id.d.ts.map +1 -1
- package/dist/lib/id.js +0 -50
- package/dist/lib/id.js.map +1 -1
- package/dist/lib/rate-limit.d.ts +0 -2
- package/dist/lib/rate-limit.d.ts.map +1 -1
- package/dist/lib/rate-limit.js +0 -3
- package/dist/lib/rate-limit.js.map +1 -1
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/index.js.map +1 -1
- package/dist/routes/admin-content.d.ts.map +1 -1
- package/dist/routes/admin-content.js +4 -3
- package/dist/routes/admin-content.js.map +1 -1
- package/dist/routes/admin-media.d.ts.map +1 -1
- package/dist/routes/admin-media.js +6 -1
- package/dist/routes/admin-media.js.map +1 -1
- package/dist/routes/admin-system.d.ts.map +1 -1
- package/dist/routes/admin-system.js +6 -4
- package/dist/routes/admin-system.js.map +1 -1
- package/dist/schema.d.ts +15 -3
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +63 -10
- package/dist/schema.js.map +1 -1
- package/dist/services/changes.d.ts.map +1 -1
- package/dist/services/changes.js +89 -13
- package/dist/services/changes.js.map +1 -1
- package/dist/services/content-store.d.ts +6 -1
- package/dist/services/content-store.d.ts.map +1 -1
- package/dist/services/content-store.js +25 -15
- package/dist/services/content-store.js.map +1 -1
- package/dist/services/staging.d.ts +2 -0
- package/dist/services/staging.d.ts.map +1 -1
- package/dist/services/staging.js +15 -4
- package/dist/services/staging.js.map +1 -1
- package/dist/workspace/tree.d.ts +6 -0
- package/dist/workspace/tree.d.ts.map +1 -1
- package/dist/workspace/tree.js +28 -0
- package/dist/workspace/tree.js.map +1 -1
- package/package.json +12 -4
- package/src/app.ts +127 -0
- package/src/config.ts +113 -0
- package/src/context.ts +167 -0
- package/src/http.ts +46 -0
- package/src/index.ts +141 -0
- package/src/lib/errors.ts +84 -0
- package/src/lib/file-type.ts +108 -0
- package/src/lib/id.ts +6 -0
- package/src/lib/logger.ts +87 -0
- package/src/lib/ports.ts +17 -0
- package/src/lib/rate-limit.ts +72 -0
- package/src/lib/schema-stats.ts +183 -0
- package/src/lib/version.ts +29 -0
- package/src/middleware/index.ts +234 -0
- package/src/routes/admin-content.ts +197 -0
- package/src/routes/admin-media.ts +96 -0
- package/src/routes/admin-spa.ts +82 -0
- package/src/routes/admin-staging.ts +126 -0
- package/src/routes/admin-system.ts +146 -0
- package/src/routes/health.ts +67 -0
- package/src/schema.ts +217 -0
- package/src/server.ts +68 -0
- package/src/services/changes.ts +605 -0
- package/src/services/content-store.ts +492 -0
- package/src/services/media.ts +229 -0
- package/src/services/staging.ts +330 -0
- package/src/workspace/developer.ts +46 -0
- package/src/workspace/files.ts +243 -0
- package/src/workspace/git.ts +108 -0
- package/src/workspace/locks.ts +103 -0
- package/src/workspace/policy.ts +77 -0
- package/src/workspace/tree.ts +314 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin routes for the pending changes and Publish. Sync and conflict
|
|
3
|
+
* resolution exist only for a clone that Mica keeps. `committed-pending-push`
|
|
4
|
+
* is a 200: the commit is real, and an error status would tell the editor that
|
|
5
|
+
* nothing happened.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { actorOf, type Env } from "../http.js";
|
|
12
|
+
import { ApiError, badRequest } from "../lib/errors.js";
|
|
13
|
+
import { ConflictError } from "../workspace/files.js";
|
|
14
|
+
|
|
15
|
+
const publishSchema = z.object({
|
|
16
|
+
message: z.string().max(500),
|
|
17
|
+
/** The reviewed paths, with the revision each had in the review. */
|
|
18
|
+
paths: z.record(z.string().min(1), z.string().nullable()),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const resolveSchema = z.object({
|
|
22
|
+
group: z.string().min(1),
|
|
23
|
+
choice: z.enum(["mine", "website"]),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const discardSchema = z.object({ paths: z.array(z.string().min(1)).min(1).max(2000) });
|
|
27
|
+
|
|
28
|
+
const compareSchema = z.discriminatedUnion("kind", [
|
|
29
|
+
z.object({
|
|
30
|
+
kind: z.literal("entry"),
|
|
31
|
+
collection: z.string().min(1),
|
|
32
|
+
slug: z.string().min(1),
|
|
33
|
+
from: z.string().min(1).optional(),
|
|
34
|
+
}),
|
|
35
|
+
z.object({ kind: z.literal("singleton"), name: z.string().min(1) }),
|
|
36
|
+
z.object({ kind: z.literal("order"), collection: z.string().min(1) }),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
async function jsonBody(c: { req: { json(): Promise<unknown> } }): Promise<unknown> {
|
|
40
|
+
return c.req.json().catch(() => {
|
|
41
|
+
throw badRequest("The request body must be valid JSON.");
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A repository conflict is 409 and recoverable — refresh, review, retry. */
|
|
46
|
+
async function handled<T>(run: () => Promise<T>): Promise<T> {
|
|
47
|
+
try {
|
|
48
|
+
return await run();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof ConflictError) throw new ApiError("conflict", error.message);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function adminStagingRoutes(): Hono<Env> {
|
|
56
|
+
const app = new Hono<Env>();
|
|
57
|
+
|
|
58
|
+
app.get("/changes", async (c) => {
|
|
59
|
+
const { staging } = c.get("ctx").services;
|
|
60
|
+
return c.json(await staging.status());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Raw data before and after one pending change; the editor renders the comparison.
|
|
64
|
+
app.get("/changes/compare", async (c) => {
|
|
65
|
+
const { staging } = c.get("ctx").services;
|
|
66
|
+
const parsed = compareSchema.safeParse({
|
|
67
|
+
kind: c.req.query("kind"),
|
|
68
|
+
collection: c.req.query("collection"),
|
|
69
|
+
slug: c.req.query("slug"),
|
|
70
|
+
from: c.req.query("from"),
|
|
71
|
+
name: c.req.query("name"),
|
|
72
|
+
});
|
|
73
|
+
if (!parsed.success) throw badRequest("The compare target is malformed.");
|
|
74
|
+
const against = c.req.query("against") === "origin" ? "origin" : "head";
|
|
75
|
+
|
|
76
|
+
return c.json(await staging.compare(parsed.data, against));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.post("/publish", async (c) => {
|
|
80
|
+
const { staging } = c.get("ctx").services;
|
|
81
|
+
const parsed = publishSchema.safeParse(await jsonBody(c));
|
|
82
|
+
if (!parsed.success) throw badRequest("The publish payload is malformed.");
|
|
83
|
+
|
|
84
|
+
const actor = actorOf(c);
|
|
85
|
+
const result = await handled(() =>
|
|
86
|
+
staging.publish({
|
|
87
|
+
message: parsed.data.message,
|
|
88
|
+
paths: parsed.data.paths,
|
|
89
|
+
author: actor ? { name: actor.name || actor.email, email: actor.email } : undefined,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return c.json(result);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
app.post("/publish/push", async (c) => {
|
|
97
|
+
const { staging } = c.get("ctx").services;
|
|
98
|
+
return c.json(await handled(() => staging.push()));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// "Try again" after a sync error.
|
|
102
|
+
app.post("/sync", async (c) => {
|
|
103
|
+
const { staging } = c.get("ctx").services;
|
|
104
|
+
await staging.sync();
|
|
105
|
+
return c.json(await staging.status());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.post("/changes/resolve", async (c) => {
|
|
109
|
+
const { staging } = c.get("ctx").services;
|
|
110
|
+
const parsed = resolveSchema.safeParse(await jsonBody(c));
|
|
111
|
+
if (!parsed.success) throw badRequest("Expected a group and a choice: mine or website.");
|
|
112
|
+
|
|
113
|
+
await handled(() => staging.resolve(parsed.data.group, parsed.data.choice));
|
|
114
|
+
return c.json(await staging.status());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
app.post("/changes/discard", async (c) => {
|
|
118
|
+
const { staging } = c.get("ctx").services;
|
|
119
|
+
const parsed = discardSchema.safeParse(await jsonBody(c));
|
|
120
|
+
if (!parsed.success) throw badRequest("Expected a list of change paths.");
|
|
121
|
+
|
|
122
|
+
return c.json(await handled(() => staging.discard(parsed.data.paths)));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return app;
|
|
126
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema, dashboard and system information. The SPA knows no collection; it
|
|
3
|
+
* renders what `/schema` returns.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
|
|
8
|
+
import { listedFields, resolveSlugField, resolveTitleField } from "@micacms/core";
|
|
9
|
+
|
|
10
|
+
import type { Env } from "../http.js";
|
|
11
|
+
import { schemaStats } from "../lib/schema-stats.js";
|
|
12
|
+
import { schemaOrigin } from "../schema.js";
|
|
13
|
+
import { VERSION } from "../lib/version.js";
|
|
14
|
+
import { formatBytes } from "../services/media.js";
|
|
15
|
+
|
|
16
|
+
export function adminSystemRoutes(): Hono<Env> {
|
|
17
|
+
const app = new Hono<Env>();
|
|
18
|
+
|
|
19
|
+
/** The content model, plus the derived values that the UI would otherwise recompute. */
|
|
20
|
+
app.get("/schema", (c) => {
|
|
21
|
+
const { site } = c.get("ctx");
|
|
22
|
+
|
|
23
|
+
return c.json({
|
|
24
|
+
collections: Object.values(site.model.collections).map((def) => ({
|
|
25
|
+
name: def.name,
|
|
26
|
+
label: def.label,
|
|
27
|
+
labelSingular: def.labelSingular ?? def.label,
|
|
28
|
+
description: def.description ?? null,
|
|
29
|
+
sortable: def.sortable ?? false,
|
|
30
|
+
defaultSort: def.defaultSort ?? null,
|
|
31
|
+
titleField: resolveTitleField(def),
|
|
32
|
+
slugField: resolveSlugField(def),
|
|
33
|
+
listedFields: listedFields(def),
|
|
34
|
+
fields: def.fields,
|
|
35
|
+
readOnly: site.readOnly.get(`collections.${def.name}`) ?? null,
|
|
36
|
+
})),
|
|
37
|
+
singletons: Object.values(site.model.singletons).map((def) => ({
|
|
38
|
+
name: def.name,
|
|
39
|
+
label: def.label,
|
|
40
|
+
description: def.description ?? null,
|
|
41
|
+
fields: def.fields,
|
|
42
|
+
readOnly: site.readOnly.get(`singletons.${def.name}`) ?? null,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
app.get("/overview", async (c) => {
|
|
48
|
+
const ctx = c.get("ctx");
|
|
49
|
+
const { content, media } = ctx.services;
|
|
50
|
+
|
|
51
|
+
const collections = await Promise.all(
|
|
52
|
+
Object.values(ctx.site.model.collections).map(async (def) => ({
|
|
53
|
+
name: def.name,
|
|
54
|
+
label: def.label,
|
|
55
|
+
total: (await content.list(def.name)).length,
|
|
56
|
+
})),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const files = await media.list();
|
|
60
|
+
|
|
61
|
+
return c.json({
|
|
62
|
+
collections,
|
|
63
|
+
media: {
|
|
64
|
+
files: files.length,
|
|
65
|
+
bytes: files.reduce((sum, file) => sum + file.size, 0),
|
|
66
|
+
readable: formatBytes(files.reduce((sum, file) => sum + file.size, 0)),
|
|
67
|
+
},
|
|
68
|
+
repository: ctx.site.contentRepository.origin,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What this deployment is and serves. Every admitted editor sees it, because
|
|
74
|
+
* there is no administrator role. It never returns a secret value.
|
|
75
|
+
*/
|
|
76
|
+
app.get("/system", async (c) => {
|
|
77
|
+
const ctx = c.get("ctx");
|
|
78
|
+
const { config } = ctx;
|
|
79
|
+
|
|
80
|
+
const stats = schemaStats(ctx.site.model);
|
|
81
|
+
|
|
82
|
+
const counts = Object.fromEntries(
|
|
83
|
+
await Promise.all(
|
|
84
|
+
Object.values(ctx.site.model.collections).map(
|
|
85
|
+
async (def) => [def.name, (await ctx.services.content.list(def.name)).length] as const,
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const files = await ctx.services.media.list();
|
|
91
|
+
const mediaBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
92
|
+
|
|
93
|
+
const uptimeSeconds = Math.floor(process.uptime());
|
|
94
|
+
|
|
95
|
+
const operational = ctx.operational();
|
|
96
|
+
const repository = await ctx.services.staging.repository();
|
|
97
|
+
|
|
98
|
+
return c.json({
|
|
99
|
+
cms: {
|
|
100
|
+
version: VERSION,
|
|
101
|
+
environment: config.NODE_ENV,
|
|
102
|
+
node: process.version,
|
|
103
|
+
platform: `${process.platform}/${process.arch}`,
|
|
104
|
+
startedAt: new Date(Date.now() - uptimeSeconds * 1000).toISOString(),
|
|
105
|
+
uptimeSeconds,
|
|
106
|
+
},
|
|
107
|
+
schema: {
|
|
108
|
+
totals: stats.totals,
|
|
109
|
+
kinds: stats.kinds,
|
|
110
|
+
relations: stats.relations,
|
|
111
|
+
collections: stats.collections.map((item) => ({
|
|
112
|
+
...item,
|
|
113
|
+
entries: counts[item.name] ?? 0,
|
|
114
|
+
})),
|
|
115
|
+
singletons: stats.singletons,
|
|
116
|
+
},
|
|
117
|
+
repository: {
|
|
118
|
+
origin: ctx.site.contentRepository.origin,
|
|
119
|
+
entries: Object.values(counts).reduce((sum, count) => sum + count, 0),
|
|
120
|
+
media: { files: files.length, bytes: mediaBytes, readable: formatBytes(mediaBytes) },
|
|
121
|
+
...repository,
|
|
122
|
+
},
|
|
123
|
+
deployment: {
|
|
124
|
+
site: ctx.site.id,
|
|
125
|
+
baseUrl: config.MICA_PUBLIC_URL,
|
|
126
|
+
schemaOrigin: schemaOrigin(ctx.site.contentRepository),
|
|
127
|
+
// A clone that Mica keeps names its remote in `repository.url` instead.
|
|
128
|
+
contentRoot: repository.owner === "developer" ? config.contentRoot : null,
|
|
129
|
+
dataDir: operational.dataDir ?? null,
|
|
130
|
+
logLevel: config.MICA_LOG_LEVEL,
|
|
131
|
+
maxUploadBytes: config.MICA_MAX_UPLOAD_BYTES,
|
|
132
|
+
maxUploadReadable: formatBytes(config.MICA_MAX_UPLOAD_BYTES),
|
|
133
|
+
sessionTtlHours: operational.sessionTtlHours ?? null,
|
|
134
|
+
sessionIdleHours: operational.sessionIdleHours ?? null,
|
|
135
|
+
auth: operational.auth,
|
|
136
|
+
admission: operational.admission ?? null,
|
|
137
|
+
trustProxy: config.MICA_TRUST_PROXY,
|
|
138
|
+
adminOrigins: config.adminOrigins,
|
|
139
|
+
generatedSecrets: operational.generatedSecrets ?? [],
|
|
140
|
+
memoryBytes: process.memoryUsage().rss,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return app;
|
|
146
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/health` does no I/O, so a repository outage does not restart the container.
|
|
3
|
+
* `/ready` checks the content repository and is cached because it is public.
|
|
4
|
+
* Neither names the repository or the uptime.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono, type Context } from "hono";
|
|
8
|
+
|
|
9
|
+
import type { Env } from "../http.js";
|
|
10
|
+
|
|
11
|
+
type Check = "ok" | "unavailable";
|
|
12
|
+
|
|
13
|
+
interface Readiness {
|
|
14
|
+
status: "ok" | "error";
|
|
15
|
+
repository: Check;
|
|
16
|
+
/** Short codes from the composition, such as `sync_failing`. */
|
|
17
|
+
reasons?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HealthOptions {
|
|
21
|
+
/** How long a readiness result is reused. */
|
|
22
|
+
cacheMs?: number;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function healthRoutes(options: HealthOptions = {}): Hono<Env> {
|
|
27
|
+
const cacheMs = options.cacheMs ?? 30_000;
|
|
28
|
+
const now = options.now ?? Date.now;
|
|
29
|
+
// The promise, not the result: requests that arrive during a probe share it.
|
|
30
|
+
let cached: { at: number; result: Promise<Readiness> } | null = null;
|
|
31
|
+
|
|
32
|
+
const app = new Hono<Env>();
|
|
33
|
+
|
|
34
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
35
|
+
|
|
36
|
+
app.get("/ready", async (c) => {
|
|
37
|
+
if (!cached || now() - cached.at >= cacheMs) {
|
|
38
|
+
cached = { at: now(), result: probe(c) };
|
|
39
|
+
}
|
|
40
|
+
const result = await cached.result;
|
|
41
|
+
return c.json(result, result.status === "ok" ? 200 : 503);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return app;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function probe(c: Context<Env>): Promise<Readiness> {
|
|
48
|
+
const ctx = c.get("ctx");
|
|
49
|
+
const logger = c.get("logger");
|
|
50
|
+
|
|
51
|
+
// A managed clone knows whether it can be edited without asking the remote.
|
|
52
|
+
if (ctx.health.ready) {
|
|
53
|
+
const { ok, reasons } = ctx.health.ready();
|
|
54
|
+
return { status: ok ? "ok" : "error", repository: ok ? "ok" : "unavailable", reasons };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `list` on a missing root returns an empty array, so this proves reachability only.
|
|
58
|
+
let repository: Check = "ok";
|
|
59
|
+
try {
|
|
60
|
+
await ctx.site.contentRepository.list("");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
logger.error("Content repository is unreachable", { error: String(error) });
|
|
63
|
+
repository = "unavailable";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { status: repository === "ok" ? "ok" : "error", repository };
|
|
67
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the content model from `.mica/schema.json` in the content repository.
|
|
3
|
+
* The Astro project writes it. The manifest is untrusted input (another core
|
|
4
|
+
* version, hand-edited or truncated), so it is validated again here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
checkCompatibility,
|
|
9
|
+
defineSite,
|
|
10
|
+
deriveLayout,
|
|
11
|
+
entryLayout,
|
|
12
|
+
LayoutError,
|
|
13
|
+
misplacedFiles,
|
|
14
|
+
recordLayout,
|
|
15
|
+
isFieldKind,
|
|
16
|
+
isProtocolManifest,
|
|
17
|
+
MANIFEST_PATH,
|
|
18
|
+
PROTOCOL_VERSION,
|
|
19
|
+
type Collection,
|
|
20
|
+
type ContentSource,
|
|
21
|
+
type FieldMap,
|
|
22
|
+
type Singleton,
|
|
23
|
+
type Site,
|
|
24
|
+
} from "@micacms/core";
|
|
25
|
+
|
|
26
|
+
export class SchemaError extends Error {
|
|
27
|
+
override name = "SchemaError";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where a model came from, for logs and the system screen. */
|
|
31
|
+
export function schemaOrigin(source: ContentSource): string {
|
|
32
|
+
return `${source.origin}/${MANIFEST_PATH}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads the model from the content repository. A missing file gets a long
|
|
37
|
+
* message because a first deployment hits it.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadSchema(source: ContentSource): Promise<Site> {
|
|
40
|
+
const origin = schemaOrigin(source);
|
|
41
|
+
|
|
42
|
+
let text: string | null;
|
|
43
|
+
try {
|
|
44
|
+
text = await source.read(MANIFEST_PATH);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// An unreachable repository is not a bad model; the fixes are different.
|
|
47
|
+
throw new SchemaError(
|
|
48
|
+
`Could not read the content model from ${source.origin}.\n` +
|
|
49
|
+
` ${(error as Error).message}\n` +
|
|
50
|
+
` The repository has to be reachable before Mica can start: the model\n` +
|
|
51
|
+
` lives in it, at ${MANIFEST_PATH}.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (text === null) throw new SchemaError(describeMissing(origin));
|
|
56
|
+
|
|
57
|
+
let raw: unknown;
|
|
58
|
+
try {
|
|
59
|
+
raw = JSON.parse(text);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new SchemaError(`${origin} is not valid JSON: ${(error as Error).message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return checkSchema(raw, origin);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One message for each collection or singleton with entries in the other
|
|
69
|
+
* layout. Mica refuses to start on any: editing next to hidden entries would
|
|
70
|
+
* let a save create a second copy of a page.
|
|
71
|
+
*/
|
|
72
|
+
export async function layoutProblems(source: ContentSource, site: Site): Promise<string[]> {
|
|
73
|
+
const problems: string[] = [];
|
|
74
|
+
const parts = [
|
|
75
|
+
...Object.entries(site.collections).map(([name, def]) => [name, def.fields, "collection"] as const),
|
|
76
|
+
...Object.entries(site.singletons).map(([name, def]) => [name, def.fields, "singleton"] as const),
|
|
77
|
+
];
|
|
78
|
+
for (const [name, fields, kind] of parts) {
|
|
79
|
+
const paths = await misplacedFiles(source, fields, name, kind);
|
|
80
|
+
if (paths.length > 0) problems.push(new LayoutError(paths, name, entryLayout(fields)).message);
|
|
81
|
+
}
|
|
82
|
+
return problems;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function describeMissing(origin: string): string {
|
|
86
|
+
return (
|
|
87
|
+
`No content model at ${origin}.\n` +
|
|
88
|
+
` Mica ships without one — the Astro project defines it and writes it here.\n` +
|
|
89
|
+
` In the project: npx astro build (or start the dev server once)\n` +
|
|
90
|
+
` then commit ${MANIFEST_PATH} and push it to the branch Mica is reading.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Runs the `defineSite` checks on untrusted input. A newer `protocolVersion` is
|
|
96
|
+
* refused; an unknown field kind is not, and `readOnlyParts` names what it locks.
|
|
97
|
+
*/
|
|
98
|
+
export function checkSchema(raw: unknown, origin = "the schema"): Site {
|
|
99
|
+
if (raw === null || typeof raw !== "object") {
|
|
100
|
+
throw new SchemaError(`${origin} is ${raw === null ? "null" : typeof raw}, expected an object.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A bare `{ collections }` without the protocol envelope is also accepted.
|
|
104
|
+
if (isProtocolManifest(raw)) {
|
|
105
|
+
const compatibility = checkCompatibility(raw);
|
|
106
|
+
if (!compatibility.ok && compatibility.reason === "newer") {
|
|
107
|
+
throw new SchemaError(
|
|
108
|
+
`${origin} follows protocol version ${compatibility.version}; this build of Mica ` +
|
|
109
|
+
`understands version ${PROTOCOL_VERSION}.\n` +
|
|
110
|
+
` The project was built against a newer @micacms/astro. Upgrade Mica.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const candidate = raw as { collections?: unknown; singletons?: unknown };
|
|
116
|
+
|
|
117
|
+
if (candidate.collections === null || typeof candidate.collections !== "object") {
|
|
118
|
+
throw new SchemaError(`${origin} has no \`collections\`. Expected defineSite({ collections: { ... } }).`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const rawSingletons = candidate.singletons;
|
|
122
|
+
if (rawSingletons !== undefined && (rawSingletons === null || typeof rawSingletons !== "object")) {
|
|
123
|
+
throw new SchemaError(`${origin} has a \`singletons\` that is not an object.`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const collections = candidate.collections as Record<string, Collection>;
|
|
127
|
+
const singletons = (rawSingletons ?? {}) as Record<string, Singleton>;
|
|
128
|
+
|
|
129
|
+
for (const [name, def] of Object.entries(collections)) {
|
|
130
|
+
assertFieldMap(def?.fields, `collections.${name}`, origin);
|
|
131
|
+
}
|
|
132
|
+
for (const [name, def] of Object.entries(singletons)) {
|
|
133
|
+
assertFieldMap(def?.fields, `singletons.${name}`, origin);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Drop, do not refuse, a default sort on an undeclared field: only the list order is affected.
|
|
137
|
+
for (const def of Object.values(collections)) {
|
|
138
|
+
const field = def?.defaultSort?.field;
|
|
139
|
+
if (field && field !== "position" && !def.fields?.[field]) delete def.defaultSort;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const site = defineSite({ collections, singletons });
|
|
144
|
+
// Read and classify entries where the manifest says they are, not where fields this build may not know imply.
|
|
145
|
+
for (const def of [...Object.values(site.collections), ...Object.values(site.singletons)]) {
|
|
146
|
+
const stored = (def as { layout?: unknown }).layout;
|
|
147
|
+
if (stored === "file" || stored === "directory") recordLayout(def.fields, stored);
|
|
148
|
+
}
|
|
149
|
+
return site;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new SchemaError(`${origin} is not a valid content model.\n ${(error as Error).message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function assertFieldMap(fields: unknown, at: string, origin: string): void {
|
|
156
|
+
if (fields === null || typeof fields !== "object") {
|
|
157
|
+
throw new SchemaError(`${origin}: "${at}" has no \`fields\` object.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const [key, field] of Object.entries(fields as FieldMap)) {
|
|
161
|
+
const here = `${at}.${key}`;
|
|
162
|
+
|
|
163
|
+
if (field === null || typeof field !== "object") {
|
|
164
|
+
throw new SchemaError(`${origin}: field "${here}" is not an object.`);
|
|
165
|
+
}
|
|
166
|
+
if (field.kind === "object" || field.kind === "repeater") {
|
|
167
|
+
assertFieldMap(field.fields, here, origin);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The collections and singletons this build of Mica must not write, keyed
|
|
174
|
+
* `collections.<name>` or `singletons.<name>`, with the reason for the editor.
|
|
175
|
+
* A field kind from a newer @micacms/astro makes only its own collection or
|
|
176
|
+
* singleton read-only; Mica still starts, and the rest stays editable.
|
|
177
|
+
*/
|
|
178
|
+
export function readOnlyParts(site: Site): Map<string, string> {
|
|
179
|
+
const parts = new Map<string, string>();
|
|
180
|
+
const add = (key: string, def: Collection | Singleton) => {
|
|
181
|
+
const unknown = unknownKinds(def.fields, []);
|
|
182
|
+
const stored = (def as { layout?: unknown }).layout;
|
|
183
|
+
if (unknown.length > 0) {
|
|
184
|
+
parts.set(
|
|
185
|
+
key,
|
|
186
|
+
`"${def.label}" uses ${unknown.map((field) => `"${field.path}" (${field.kind})`).join(", ")}, ` +
|
|
187
|
+
"a field type that this version of Mica does not know. Upgrade Mica to edit it.",
|
|
188
|
+
);
|
|
189
|
+
} else if (stored !== undefined && stored !== deriveLayout(def.fields)) {
|
|
190
|
+
parts.set(
|
|
191
|
+
key,
|
|
192
|
+
`"${def.label}" is stored as ${String(stored)}, which this version of Mica does not expect. ` +
|
|
193
|
+
"Upgrade Mica to edit it.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
for (const [name, def] of Object.entries(site.collections)) add(`collections.${name}`, def);
|
|
198
|
+
for (const [name, def] of Object.entries(site.singletons)) add(`singletons.${name}`, def);
|
|
199
|
+
return parts;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function unknownKinds(fields: FieldMap, at: string[]): Array<{ path: string; kind: string }> {
|
|
203
|
+
return Object.entries(fields).flatMap(([key, field]) => {
|
|
204
|
+
const here = [...at, key];
|
|
205
|
+
if (!isFieldKind(field.kind)) return [{ path: here.join("."), kind: String(field.kind) }];
|
|
206
|
+
return field.kind === "object" || field.kind === "repeater" ? unknownKinds(field.fields, here) : [];
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A short digest of a model, to show drift. Field order counts, because it is
|
|
212
|
+
* the order of the admin form.
|
|
213
|
+
*/
|
|
214
|
+
export async function schemaFingerprint(site: Site): Promise<string> {
|
|
215
|
+
const { createHash } = await import("node:crypto");
|
|
216
|
+
return createHash("sha256").update(JSON.stringify(site)).digest("hex").slice(0, 12);
|
|
217
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds the app to a socket and shuts down cleanly on SIGTERM and SIGINT. Each
|
|
3
|
+
* app keeps its own startup message.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { serve } from "@hono/node-server";
|
|
7
|
+
import type { Hono } from "hono";
|
|
8
|
+
|
|
9
|
+
import type { Env } from "./http.js";
|
|
10
|
+
import type { Logger } from "./lib/logger.js";
|
|
11
|
+
|
|
12
|
+
export interface StartServerOptions {
|
|
13
|
+
app: Hono<Env>;
|
|
14
|
+
host: string;
|
|
15
|
+
port: number;
|
|
16
|
+
logger: Logger;
|
|
17
|
+
onListening(info: { port: number }): void;
|
|
18
|
+
portInUseMessage?(): string;
|
|
19
|
+
shutdown(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function startServer(options: StartServerOptions): void {
|
|
23
|
+
const { logger } = options;
|
|
24
|
+
|
|
25
|
+
const server = serve(
|
|
26
|
+
{ fetch: options.app.fetch, hostname: options.host, port: options.port },
|
|
27
|
+
(info) => options.onListening({ port: info.port }),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
server.on("error", (error: NodeJS.ErrnoException) => {
|
|
31
|
+
if (error.code !== "EADDRINUSE") throw error;
|
|
32
|
+
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
options.portInUseMessage?.() ??
|
|
35
|
+
`Port ${options.port} on ${options.host} is already in use. Stop the other ` +
|
|
36
|
+
`process, or set PORT.\n`,
|
|
37
|
+
);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let shuttingDown = false;
|
|
42
|
+
|
|
43
|
+
const shutdown = (signal: string): void => {
|
|
44
|
+
if (shuttingDown) return;
|
|
45
|
+
shuttingDown = true;
|
|
46
|
+
logger.info("Shutting down", { signal });
|
|
47
|
+
|
|
48
|
+
server.close(() => {
|
|
49
|
+
void options.shutdown().then(() => {
|
|
50
|
+
logger.info("Shutdown complete");
|
|
51
|
+
process.exit(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// A hung connection must not block a container restart.
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
logger.warn("Forcing exit after shutdown timeout");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}, 15_000).unref();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
63
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
64
|
+
|
|
65
|
+
process.on("unhandledRejection", (reason) => {
|
|
66
|
+
logger.error("Unhandled promise rejection", { reason: String(reason) });
|
|
67
|
+
});
|
|
68
|
+
}
|