@micacms/runtime 0.5.0 → 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.js +2 -2
- 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 +3 -2
- package/dist/routes/admin-content.js.map +1 -1
- package/dist/routes/admin-system.js +4 -4
- package/dist/routes/admin-system.js.map +1 -1
- package/dist/schema.d.ts +6 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +19 -1
- 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.map +1 -1
- package/dist/services/content-store.js +9 -10
- package/dist/services/content-store.js.map +1 -1
- package/dist/services/staging.d.ts.map +1 -1
- package/dist/services/staging.js +7 -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,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
|
+
}
|