caracms 0.2.0 → 0.3.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 +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/documents/actions.ts +6 -4
- package/src/editor/client/documents/dialogs.tsx +2 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +13 -1
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +26 -1
package/src/live/editor.ts
CHANGED
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* **A poll asks GitHub for nothing** beyond `git.check()` (once a minute) and the
|
|
16
16
|
* deployment reads while a publish is in flight (git.ts, every 10 s at most).
|
|
17
17
|
*
|
|
18
|
+
* **A poll asks Redis for little** (Redis diet): its reads come from the status cache
|
|
19
|
+
* (status-cache.ts, `git.statusCache`), refreshed from Redis once 30 s old; it writes
|
|
20
|
+
* only its presence, and a heartbeat when the lock needs one. The other handlers read
|
|
21
|
+
* Redis directly (Decision 3).
|
|
22
|
+
*
|
|
18
23
|
* State lives in Redis and git (two servers run during a deploy); what's held here
|
|
19
24
|
* is the sweep's "am I ticking" and git.ts's caches.
|
|
20
25
|
*/
|
|
@@ -39,17 +44,27 @@ import type {
|
|
|
39
44
|
} from '../editor/types.ts';
|
|
40
45
|
import { Autosaves } from './autosaves.ts';
|
|
41
46
|
import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
|
|
42
|
-
import { movesIn, titleOf } from './git.ts';
|
|
47
|
+
import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
|
|
48
|
+
import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
|
|
43
49
|
import { parseRedirects, type Redirect } from '../redirects.ts';
|
|
44
50
|
import { Locks } from './locks.ts';
|
|
45
51
|
import { displayName, type Person } from './messages.ts';
|
|
46
52
|
import type { LiveSettings } from './mode.ts';
|
|
53
|
+
import { Notices, type StoodNotice } from './notices.ts';
|
|
47
54
|
import { RedisUnavailableError } from './redis.ts';
|
|
48
55
|
import { liveRuntime, type LiveRuntime } from './runtime.ts';
|
|
49
56
|
import { Sweep, type Schedule } from './sweep.ts';
|
|
50
57
|
|
|
51
58
|
type Handler = (request: Request, person: Person | null) => Promise<Response>;
|
|
52
59
|
|
|
60
|
+
|
|
61
|
+
/** The heading of the editor's GitHub access note (runs-anywhere Decision 5); the sentence says what to fix. */
|
|
62
|
+
const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
|
|
63
|
+
refused: 'GitHub refused this site',
|
|
64
|
+
permission: 'GitHub refused a change',
|
|
65
|
+
expiring: "This site's GitHub token expires soon",
|
|
66
|
+
};
|
|
67
|
+
|
|
53
68
|
export interface LiveEditor {
|
|
54
69
|
readonly runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>;
|
|
55
70
|
readonly autosaves: Autosaves;
|
|
@@ -84,15 +99,6 @@ const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.to
|
|
|
84
99
|
const NOTICE_ID = /^(kept|stood):[^\s\p{C}]+$/u;
|
|
85
100
|
const NOTICE_ID_MAX = 512;
|
|
86
101
|
|
|
87
|
-
interface StoodNotice {
|
|
88
|
-
id: string;
|
|
89
|
-
for: string;
|
|
90
|
-
file: string;
|
|
91
|
-
title: string;
|
|
92
|
-
by: Person;
|
|
93
|
-
at: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
102
|
/** Map a site file to its document (collection and path), or nulls. */
|
|
97
103
|
/** Site Settings' redirects, from its text (none when it's missing or unreadable). */
|
|
98
104
|
function redirectsIn(text: string | null): Redirect[] {
|
|
@@ -120,8 +126,11 @@ export async function createLiveEditor(
|
|
|
120
126
|
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
121
127
|
const prefix = options.prefix ?? 'caracms:';
|
|
122
128
|
const log = options.log ?? ((line: string) => console.log(`[caracms] ${line}`));
|
|
123
|
-
|
|
124
|
-
const
|
|
129
|
+
// Redis diet Decision 2: every module that writes what the status poll reads tells its cache.
|
|
130
|
+
const cache = git.statusCache;
|
|
131
|
+
const autosaves = new Autosaves(redis, timings, now, prefix, cache);
|
|
132
|
+
const locks = new Locks(redis, timings, now, async (file) => (await autosaves.get(file)) !== null, prefix, cache);
|
|
133
|
+
const noticeStore = new Notices(redis, timings, prefix, cache);
|
|
125
134
|
const sweep = new Sweep({ redis, git, autosaves, locks, timings, now, schedule: options.schedule, sleep: options.sleep, log, prefix });
|
|
126
135
|
const store = liveDraftsStore({ site, git, autosaves, locks, sweep, now });
|
|
127
136
|
// Publish's step 1 and Decision 8's "commit every pending autosave first".
|
|
@@ -139,12 +148,6 @@ export async function createLiveEditor(
|
|
|
139
148
|
// hook above in place, so a merge from main commits every pending autosave first.
|
|
140
149
|
await git.check({ force: true }).catch((error: unknown) => log(`check at startup: ${error instanceof Error ? error.message : String(error)}`));
|
|
141
150
|
|
|
142
|
-
const keys = {
|
|
143
|
-
publishLock: `${prefix}publish:lock`,
|
|
144
|
-
dismissed: `${prefix}dismissed`,
|
|
145
|
-
notices: `${prefix}notices`,
|
|
146
|
-
};
|
|
147
|
-
|
|
148
151
|
/** Common gate: a person, the same origin for anything but GET, Redis outages as 503. */
|
|
149
152
|
const handler =
|
|
150
153
|
(methods: string[], fn: (request: Request, person: Person) => Promise<Response>): Handler =>
|
|
@@ -204,36 +207,22 @@ export async function createLiveEditor(
|
|
|
204
207
|
return list;
|
|
205
208
|
};
|
|
206
209
|
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
/** `read`: the Publish lock and record as the status poll's cache has them; otherwise read from Redis. */
|
|
211
|
+
const publishView = async (read?: { running: boolean; record: PublishView['record'] }): Promise<PublishView> => {
|
|
212
|
+
const running = read ? read.running : (await redis.get(git.publishLockKey)) !== null;
|
|
209
213
|
let st;
|
|
210
214
|
try {
|
|
211
|
-
st = await git.publishStatus();
|
|
215
|
+
st = await git.publishStatus(read ? { record: read.record } : {});
|
|
212
216
|
} catch (error) {
|
|
213
217
|
log(`publish status: ${error instanceof Error ? error.message : String(error)}`);
|
|
214
|
-
const record = await git.lastPublish();
|
|
218
|
+
const record = read ? read.record : await git.lastPublish();
|
|
215
219
|
st = { state: record ? ('publishing' as const) : ('none' as const), record, assumed: false, canTryAgain: false };
|
|
216
220
|
}
|
|
217
|
-
return { state: st.state, running, record: st.record, assumed: st.assumed, canTryAgain: st.canTryAgain,
|
|
221
|
+
return { state: st.state, running, record: st.record, assumed: st.assumed, canTryAgain: st.canTryAgain, hasRedeployer: Boolean(settings.deployHook) || settings.railway !== null };
|
|
218
222
|
};
|
|
219
223
|
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
const out: NoticeView[] = [];
|
|
223
|
-
for (const n of git.keptDraftNotices()) {
|
|
224
|
-
const id = `kept:${n.file}:${n.commit}`;
|
|
225
|
-
if (!dismissed.has(id)) out.push({ id, kind: 'draft-kept', file: n.file, title: git.titleFor(n.file) });
|
|
226
|
-
}
|
|
227
|
-
for (const raw of Object.values(await redis.hgetall(keys.notices))) {
|
|
228
|
-
try {
|
|
229
|
-
const n = JSON.parse(raw) as StoodNotice;
|
|
230
|
-
if (n.for === person.email.toLowerCase()) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
|
|
231
|
-
} catch {
|
|
232
|
-
/* skip */
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return out;
|
|
236
|
-
};
|
|
224
|
+
const titleFor = (file: string) => git.titleFor(file);
|
|
225
|
+
const notices = (person: Person): Promise<NoticeView[]> => noticeStore.of(person, git.keptDraftNotices(), titleFor);
|
|
237
226
|
|
|
238
227
|
// The write route: M3's handler over the live store, which answers 401 with no person.
|
|
239
228
|
const write: Handler = (request, person) => handleWrite(request, store, { person });
|
|
@@ -256,19 +245,36 @@ export async function createLiveEditor(
|
|
|
256
245
|
// so it lapses by itself (Take over, 2026-10-15). A tab that doesn't say is treated as active.
|
|
257
246
|
const input = Number(url.searchParams.get('input'));
|
|
258
247
|
const active = !url.searchParams.has('input') || !(input >= timings.lockIdleMs);
|
|
248
|
+
// Redis diet Contracts: `interval` is how long until the tab's next poll, which its
|
|
249
|
+
// presence holds by (Decision 9). None, or not a number: a 5-second tab (an old page).
|
|
250
|
+
const every = Number(url.searchParams.get('interval'));
|
|
251
|
+
const interval = Number.isFinite(every) && every > 0 ? Math.min(every, Math.max(timings.pollMs, timings.slowPollMs)) : timings.pollMs;
|
|
252
|
+
|
|
253
|
+
// Every read from the status cache (Decision 1): memory, or Redis once 30 s old.
|
|
254
|
+
const snap = await cache.read({
|
|
255
|
+
scans: [autosaves.keyPrefix, locks.keyPrefix],
|
|
256
|
+
hashes: [locks.presenceKey(), autosaves.movesKey(), noticeStore.noticesKey],
|
|
257
|
+
sets: [noticeStore.dismissedKey],
|
|
258
|
+
strings: [git.publishLockKey, git.publishRecordKey, ...(file ? [autosaves.changedKey(file)] : [])],
|
|
259
|
+
});
|
|
259
260
|
if (tab) {
|
|
260
|
-
await locks.present(tab, person, file);
|
|
261
|
-
|
|
261
|
+
await locks.present(tab, person, file, interval);
|
|
262
|
+
// Decision 6: a beat only when the lock (as cached) is this tab's and needs one.
|
|
263
|
+
if (file && active) {
|
|
264
|
+
const cached = locks.allFrom(snap.scan(locks.keyPrefix)).get(file) ?? null;
|
|
265
|
+
if (locks.needsBeat(cached, tab, person)) await locks.beat(file, tab, person, cached);
|
|
266
|
+
}
|
|
262
267
|
}
|
|
263
268
|
// Decision 8's check, throttled to once a minute and shared inside git.ts. Started, not
|
|
264
269
|
// awaited: the poll answers with what's known, and never waits on GitHub.
|
|
265
270
|
void git.check().catch((error: unknown) => log(`check: ${error instanceof Error ? error.message : String(error)}`));
|
|
266
271
|
|
|
267
|
-
|
|
272
|
+
// From the snapshot after this poll's own writes landed in it (its presence, its beat).
|
|
273
|
+
const saves = Autosaves.allFrom(snap.scan(autosaves.keyPrefix).values()).map((r) => r.value);
|
|
268
274
|
if (saves.length) sweep.poke(); // they may have come in on the other server during a deploy
|
|
269
275
|
const pending = new Set(saves.map((a) => a.file));
|
|
270
|
-
const presence = await locks.
|
|
271
|
-
const lockViews = [...(
|
|
276
|
+
const presence = await locks.presenceFrom(snap.hash(locks.presenceKey()));
|
|
277
|
+
const lockViews = [...locks.allFrom(snap.scan(locks.keyPrefix))]
|
|
272
278
|
.filter(([f, r]) => locks.holds(r.lock, pending.has(f)))
|
|
273
279
|
.map(([f, r]) => locks.view(f, r.lock, tab, person));
|
|
274
280
|
|
|
@@ -315,7 +321,7 @@ export async function createLiveEditor(
|
|
|
315
321
|
// Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
|
|
316
322
|
// Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
|
|
317
323
|
const readNow = (f: string) => (textNow.has(f) ? (textNow.get(f) ?? null) : git.read(f));
|
|
318
|
-
const moves = movesIn(site, [...waiting.keys()], readNow,
|
|
324
|
+
const moves = movesIn(site, [...waiting.keys()], readNow, new Map(snap.hash(autosaves.movesKey())));
|
|
319
325
|
const redirects = moves.from.size ? redirectsIn(readNow(SITE_SETTINGS_FILE)) : [];
|
|
320
326
|
for (const [file, from] of moves.from) {
|
|
321
327
|
const w = waiting.get(file)!;
|
|
@@ -330,12 +336,14 @@ export async function createLiveEditor(
|
|
|
330
336
|
const lock = lockViews.find((l) => l.file === file) ?? null;
|
|
331
337
|
// M5a Decision 12: a viewer learns the document was deleted, and by whom, on this poll.
|
|
332
338
|
let deleted: NonNullable<StatusResponse['open']>['deleted'] = null;
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
const save = saves.find((a) => a.file === file);
|
|
340
|
+
const record = Autosaves.changedFrom(snap.string(autosaves.changedKey(file)));
|
|
341
|
+
if (save ? save.gone : git.read(file) === null) {
|
|
335
342
|
deleted = record?.gone ? { by: record.by, at: record.at } : { by: null, at: null };
|
|
336
343
|
}
|
|
337
|
-
open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file), deleted };
|
|
344
|
+
open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted };
|
|
338
345
|
}
|
|
346
|
+
const access = git.backend.accessProblem();
|
|
339
347
|
const body: StatusResponse = {
|
|
340
348
|
mode: 'live',
|
|
341
349
|
you: person,
|
|
@@ -346,8 +354,10 @@ export async function createLiveEditor(
|
|
|
346
354
|
waiting: list,
|
|
347
355
|
locks: lockViews,
|
|
348
356
|
presence,
|
|
349
|
-
notices:
|
|
350
|
-
publish: await publishView(),
|
|
357
|
+
notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
|
|
358
|
+
publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
|
|
359
|
+
// Runs-anywhere Decision 5: while it holds, not dismissable.
|
|
360
|
+
githubAccess: access ? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access) } : null,
|
|
351
361
|
// M5a Decision 10: from the committed copy, never an autosave.
|
|
352
362
|
brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
|
|
353
363
|
};
|
|
@@ -395,28 +405,44 @@ export async function createLiveEditor(
|
|
|
395
405
|
|
|
396
406
|
const publish = handler(['POST'], async (_request, person) => {
|
|
397
407
|
const before = await locks.presence();
|
|
398
|
-
const
|
|
408
|
+
const work = git.publish(person).then(async (result) => {
|
|
409
|
+
await stoodNotices(result, before, person);
|
|
410
|
+
return result;
|
|
411
|
+
});
|
|
412
|
+
// Runs-anywhere (Watch-outs): Cloudflare gives up on an answer after 100 s, and a
|
|
413
|
+
// Publish whose GitHub calls are slow can take longer. Past `publishAnswerMs` the
|
|
414
|
+
// request answers "joined" with the status (the Publish lock shows it running) and
|
|
415
|
+
// the Publish carries on; the panel follows it on the poll.
|
|
416
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
417
|
+
const late = new Promise<null>((resolve) => {
|
|
418
|
+
timer = setTimeout(() => resolve(null), timings.publishAnswerMs);
|
|
419
|
+
});
|
|
420
|
+
const result = await Promise.race([work, late]).finally(() => clearTimeout(timer));
|
|
421
|
+
if (result === null) {
|
|
422
|
+
work.catch((error: unknown) => log(`publish (answered early): ${error instanceof Error ? error.message : String(error)}`));
|
|
423
|
+
return json(200, { result: 'joined', publish: await publishView() } satisfies PublishResponse);
|
|
424
|
+
}
|
|
425
|
+
return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
/** "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1). */
|
|
429
|
+
const stoodNotices = async (result: Awaited<ReturnType<typeof git.publish>>, before: Awaited<ReturnType<typeof locks.presence>>, person: Person) => {
|
|
399
430
|
if (result.kind === 'published') {
|
|
400
431
|
// "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1).
|
|
401
432
|
const { record } = result;
|
|
402
|
-
const
|
|
433
|
+
const stood: StoodNotice[] = [];
|
|
403
434
|
for (const p of before) {
|
|
404
435
|
const i = record.files.indexOf(p.file);
|
|
405
436
|
if (i === -1) continue;
|
|
406
437
|
for (const someone of p.people) {
|
|
407
438
|
if (sameEmail(someone, person)) continue;
|
|
408
439
|
const id = `stood:${record.commit}:${p.file}:${someone.email.toLowerCase()}`;
|
|
409
|
-
|
|
410
|
-
fields[id] = JSON.stringify(notice);
|
|
440
|
+
stood.push({ id, for: someone.email.toLowerCase(), file: p.file, title: result.fileTitles[i] ?? titleOf(p.file, null), by: person, at: record.at });
|
|
411
441
|
}
|
|
412
442
|
}
|
|
413
|
-
|
|
414
|
-
await redis.hset(keys.notices, fields);
|
|
415
|
-
await redis.pexpire(keys.notices, timings.autosaveTtlMs);
|
|
416
|
-
}
|
|
443
|
+
await noticeStore.addStood(stood);
|
|
417
444
|
}
|
|
418
|
-
|
|
419
|
-
});
|
|
445
|
+
};
|
|
420
446
|
|
|
421
447
|
const history = handler(['GET'], async () => {
|
|
422
448
|
const entries = await git.history({ refresh: true });
|
|
@@ -424,9 +450,10 @@ export async function createLiveEditor(
|
|
|
424
450
|
});
|
|
425
451
|
|
|
426
452
|
const tryAgain = handler(['POST'], async () => {
|
|
427
|
-
if (!settings.railway) return json(409, { ok: false, error:
|
|
453
|
+
if (!settings.deployHook && !settings.railway) return json(409, { ok: false, error: NO_REDEPLOYER_REASON } satisfies TryAgainResponse);
|
|
428
454
|
const st = await publishView();
|
|
429
|
-
|
|
455
|
+
// Runs-anywhere Decision 10: taking longer than usual offers it too. One click, one call (the editor's button waits for the answer).
|
|
456
|
+
if (st.state !== 'failed' && st.state !== 'slow') return json(409, { ok: false, error: 'The last publish hasn\'t failed, so there\'s nothing to try again.' } satisfies TryAgainResponse);
|
|
430
457
|
const r = await git.tryAgain();
|
|
431
458
|
return r.ok ? json(200, { ok: true } satisfies TryAgainResponse) : json(502, { ok: false, error: r.reason } satisfies TryAgainResponse);
|
|
432
459
|
});
|
|
@@ -439,10 +466,8 @@ export async function createLiveEditor(
|
|
|
439
466
|
if (typeof id !== 'string' || id.length > NOTICE_ID_MAX || !NOTICE_ID.test(id)) throw new WriteRefused(400, '"id" must be a notice\'s id.');
|
|
440
467
|
const shown = (await notices(person)).find((n) => n.id === id);
|
|
441
468
|
if (!shown) throw new WriteRefused(404, 'There\'s no such notice for you to dismiss.');
|
|
442
|
-
if (shown.kind === 'draft-kept')
|
|
443
|
-
|
|
444
|
-
await redis.pexpire(keys.dismissed, timings.autosaveTtlMs);
|
|
445
|
-
} else await redis.hdel(keys.notices, id);
|
|
469
|
+
if (shown.kind === 'draft-kept') await noticeStore.dismiss(id);
|
|
470
|
+
else await noticeStore.remove(id);
|
|
446
471
|
return json(200, { ok: true });
|
|
447
472
|
});
|
|
448
473
|
|
package/src/live/git.ts
CHANGED
|
@@ -42,9 +42,11 @@ import { movesKey } from './autosaves.ts';
|
|
|
42
42
|
import type { WaitingChange } from '../editor/types.ts';
|
|
43
43
|
import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, type Comparison, type GitBackend, type Head, type Heads, type TreeChange } from './backend.ts';
|
|
44
44
|
import { HttpError } from './github.ts';
|
|
45
|
+
import { runningCommitFrom } from './mode.ts';
|
|
45
46
|
import { mergeFile } from './merge.ts';
|
|
46
47
|
import { draftMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
|
|
47
48
|
import type { RedisClient } from './redis.ts';
|
|
49
|
+
import { landed, StatusCache } from './status-cache.ts';
|
|
48
50
|
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
49
51
|
import { publishState, waitingFromCommits, type DeploymentFact, type PublishRecord, type PublishState, type WaitingFile } from './status.ts';
|
|
50
52
|
|
|
@@ -103,14 +105,20 @@ export interface HistoryEntry {
|
|
|
103
105
|
state: PublishState;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
fetch?: typeof fetch
|
|
113
|
-
}
|
|
108
|
+
/**
|
|
109
|
+
* Try again's redeployer (runs-anywhere Decision 11): Railway's API with a project token
|
|
110
|
+
* (`CARACMS_RAILWAY_TOKEN`, M4 Decision 12), or a deploy hook (`CARACMS_DEPLOY_HOOK_URL`):
|
|
111
|
+
* one bare POST, any 2xx a success. The hook's URL is a secret: never logged or sent on.
|
|
112
|
+
*/
|
|
113
|
+
export type Redeployer =
|
|
114
|
+
| { kind: 'railway'; token: string; serviceId: string; environmentId: string; fetch?: typeof fetch }
|
|
115
|
+
| { kind: 'hook'; url: string; fetch?: typeof fetch; timeoutMs?: number };
|
|
116
|
+
|
|
117
|
+
/** A deploy hook that hasn't answered in this long has failed (it's one POST; nothing retries by itself). */
|
|
118
|
+
export const DEPLOY_HOOK_TIMEOUT_MS = 20_000;
|
|
119
|
+
|
|
120
|
+
/** Try again's answer when no redeployer is set: no host's name (Decision 11). */
|
|
121
|
+
export const NO_REDEPLOYER_REASON = "Try again isn't set up on this site: a developer can redeploy it.";
|
|
114
122
|
|
|
115
123
|
export interface LiveGitOptions {
|
|
116
124
|
backend: GitBackend;
|
|
@@ -119,12 +127,16 @@ export interface LiveGitOptions {
|
|
|
119
127
|
now?: () => number;
|
|
120
128
|
sleep?: (ms: number) => Promise<void>;
|
|
121
129
|
log?: (line: string) => void;
|
|
122
|
-
/** The running server's commit, read on every status (tests move it). Default:
|
|
130
|
+
/** The running server's commit, read on every status (tests move it). Default: mode.ts → `runningCommitFrom` over the environment and `bakedCommit`. */
|
|
123
131
|
runningCommit?: () => string | null;
|
|
132
|
+
/** The commit baked in at build (runs-anywhere Decision 9), for the default `runningCommit`. */
|
|
133
|
+
bakedCommit?: string | null;
|
|
124
134
|
timings?: Partial<LiveTimings>;
|
|
125
|
-
|
|
135
|
+
redeployer?: Redeployer | null;
|
|
126
136
|
/** Redis key prefix. Default `caracms:`. */
|
|
127
137
|
keyPrefix?: string;
|
|
138
|
+
/** The status poll's cache (Redis diet), told of the publish lock, the publish record and the moves Publish clears. Default: one of its own. */
|
|
139
|
+
statusCache?: StatusCache;
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
/** The site file that holds Site Settings, relative to the site (Decision 15). Defined in config.ts (M5a: the reserved `settings` collection). */
|
|
@@ -153,6 +165,12 @@ export class LiveGit {
|
|
|
153
165
|
readonly redis: RedisClient;
|
|
154
166
|
readonly timings: LiveTimings;
|
|
155
167
|
readonly now: () => number;
|
|
168
|
+
/**
|
|
169
|
+
* The status poll's write-through cache (status-cache.ts, Redis diet Decisions 1–5),
|
|
170
|
+
* one per process: kept here because this module writes some of what it holds; the
|
|
171
|
+
* live editor's autosaves, locks and notices write through the same one.
|
|
172
|
+
*/
|
|
173
|
+
readonly statusCache: StatusCache;
|
|
156
174
|
/** Phase 2 fills this: commit every pending autosave (Decision 10 step 1; Decision 8 before a merge). */
|
|
157
175
|
commitPending: () => Promise<void> = async () => {};
|
|
158
176
|
/**
|
|
@@ -168,7 +186,7 @@ export class LiveGit {
|
|
|
168
186
|
private readonly sleep: (ms: number) => Promise<void>;
|
|
169
187
|
private readonly log: (line: string) => void;
|
|
170
188
|
private readonly runningCommit: () => string | null;
|
|
171
|
-
private readonly
|
|
189
|
+
private readonly redeployer: Redeployer | null;
|
|
172
190
|
private readonly keyPrefix: string;
|
|
173
191
|
|
|
174
192
|
private copyHead: Head | null = null;
|
|
@@ -194,9 +212,11 @@ export class LiveGit {
|
|
|
194
212
|
this.now = options.now ?? Date.now;
|
|
195
213
|
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
196
214
|
this.log = options.log ?? ((line) => console.log(`[caracms] ${line}`));
|
|
197
|
-
|
|
198
|
-
this.
|
|
215
|
+
const baked = options.bakedCommit ?? null;
|
|
216
|
+
this.runningCommit = options.runningCommit ?? (() => runningCommitFrom(process.env, baked));
|
|
217
|
+
this.redeployer = options.redeployer ?? null;
|
|
199
218
|
this.keyPrefix = options.keyPrefix ?? 'caracms:';
|
|
219
|
+
this.statusCache = options.statusCache ?? new StatusCache({ redis: this.redis, maxAgeMs: this.timings.statusCacheMs, now: this.now });
|
|
200
220
|
this.prefix = repoPrefix(options.site);
|
|
201
221
|
// Every collection's folder, shared blocks' included (M5a Decision 8); Site Settings is its one file (inScope).
|
|
202
222
|
this.folders = Object.values(options.site.collections)
|
|
@@ -241,6 +261,20 @@ export class LiveGit {
|
|
|
241
261
|
return `${this.keyPrefix}${name}`;
|
|
242
262
|
}
|
|
243
263
|
|
|
264
|
+
/** The Publish lock's key (one Publish at a time) and the newest publish's record's (Decision 10, 12). */
|
|
265
|
+
get publishLockKey(): string {
|
|
266
|
+
return this.key('publish:lock');
|
|
267
|
+
}
|
|
268
|
+
get publishRecordKey(): string {
|
|
269
|
+
return this.key('publish:last');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async setPublishRecord(record: PublishRecord): Promise<void> {
|
|
273
|
+
const raw = JSON.stringify(record);
|
|
274
|
+
await landed(this.statusCache, this.publishRecordKey, () => this.redis.set(this.publishRecordKey, raw));
|
|
275
|
+
this.statusCache.string(this.publishRecordKey, raw);
|
|
276
|
+
}
|
|
277
|
+
|
|
244
278
|
/** Run `fn` alone among this server's git operations, so the copy is never changed by two at once. */
|
|
245
279
|
private exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
246
280
|
const run = this.queue.then(fn, fn);
|
|
@@ -642,9 +676,11 @@ export class LiveGit {
|
|
|
642
676
|
* Redis lock); a second press while one runs joins it.
|
|
643
677
|
*/
|
|
644
678
|
async publish(by: Person): Promise<PublishResult> {
|
|
645
|
-
const lock = this.
|
|
679
|
+
const lock = this.publishLockKey;
|
|
646
680
|
const token = randomBytes(12).toString('base64url');
|
|
647
|
-
|
|
681
|
+
const taken = await landed(this.statusCache, lock, () => this.redis.set(lock, token, { nx: true, px: this.timings.publishLockMs }));
|
|
682
|
+
if (taken) this.statusCache.string(lock, token);
|
|
683
|
+
else {
|
|
648
684
|
const until = this.now() + this.timings.publishLockMs;
|
|
649
685
|
while (this.now() < until && (await this.redis.get(lock)) !== null) await this.sleep(500);
|
|
650
686
|
return { kind: 'joined', record: await this.lastPublish() };
|
|
@@ -659,7 +695,10 @@ export class LiveGit {
|
|
|
659
695
|
throw new Error(`caracms: publish gave up after ${MAX_ATTEMPTS} tries (main or drafts kept moving).`);
|
|
660
696
|
});
|
|
661
697
|
} finally {
|
|
662
|
-
|
|
698
|
+
// Let go: gone when it was still ours; otherwise (it expired and someone else took it, or the call failed) not known here.
|
|
699
|
+
const gone = await this.redis.compareAndDelete(lock, token).catch(() => false);
|
|
700
|
+
if (gone) this.statusCache.string(lock, null);
|
|
701
|
+
else this.statusCache.drop(lock);
|
|
663
702
|
}
|
|
664
703
|
}
|
|
665
704
|
|
|
@@ -710,16 +749,28 @@ export class LiveGit {
|
|
|
710
749
|
this.historyCache = null;
|
|
711
750
|
// The moves this publish took live are done with (a move made since stays waiting).
|
|
712
751
|
const published = [...records].filter(([old]) => files.includes(old)).map(([old]) => old);
|
|
713
|
-
if (published.length)
|
|
752
|
+
if (published.length) {
|
|
753
|
+
const moves = movesKey(this.keyPrefix);
|
|
754
|
+
const cleared = await this.redis.hdel(moves, ...published).then(
|
|
755
|
+
() => true,
|
|
756
|
+
() => false,
|
|
757
|
+
);
|
|
758
|
+
if (cleared) this.statusCache.hash(moves, {}, published);
|
|
759
|
+
else this.statusCache.drop(moves);
|
|
760
|
+
}
|
|
714
761
|
const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
|
|
715
|
-
await this.
|
|
762
|
+
await this.setPublishRecord(record);
|
|
716
763
|
this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
|
|
717
764
|
return { kind: 'published', record, attempts: attempt, fileTitles };
|
|
718
765
|
}
|
|
719
766
|
|
|
720
767
|
/** The newest publish, from Redis. */
|
|
721
768
|
async lastPublish(): Promise<PublishRecord | null> {
|
|
722
|
-
|
|
769
|
+
return LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** A publish record from its text as read (the status cache's, on a poll). */
|
|
773
|
+
static publishRecordFrom(raw: string | null): PublishRecord | null {
|
|
723
774
|
if (!raw) return null;
|
|
724
775
|
try {
|
|
725
776
|
return JSON.parse(raw) as PublishRecord;
|
|
@@ -842,14 +893,15 @@ export class LiveGit {
|
|
|
842
893
|
// ── Publish status (Decision 12) ────────────────────────────────────────────
|
|
843
894
|
|
|
844
895
|
/** The newest publish's state. Asks GitHub at most every `deploymentReadMs`, and only while it isn't live. */
|
|
845
|
-
async publishStatus(): Promise<PublishStatus> {
|
|
846
|
-
|
|
896
|
+
async publishStatus(options: { record?: PublishRecord | null } = {}): Promise<PublishStatus> {
|
|
897
|
+
// `record`: the newest publish as the caller read it (the status poll's cache); otherwise read here.
|
|
898
|
+
const record = options.record !== undefined ? options.record : await this.lastPublish();
|
|
847
899
|
if (!record) return { state: 'none', record: null, assumed: false, canTryAgain: false };
|
|
848
900
|
const running = this.runningCommit();
|
|
849
901
|
if (running === null) {
|
|
850
902
|
if (!this.assumedLogged) {
|
|
851
903
|
this.assumedLogged = true;
|
|
852
|
-
this.log('no
|
|
904
|
+
this.log('no running commit known (CARACMS_RUNNING_COMMIT, a host\'s commit variable, or one baked in at build): a publish reads as live once its refs moved');
|
|
853
905
|
}
|
|
854
906
|
return { state: 'live', record, assumed: true, canTryAgain: false };
|
|
855
907
|
}
|
|
@@ -858,8 +910,8 @@ export class LiveGit {
|
|
|
858
910
|
let facts: DeploymentFact[] = [];
|
|
859
911
|
let later = new Set<string>();
|
|
860
912
|
if (!includes) ({ facts, later } = await this.deploymentFacts(record));
|
|
861
|
-
const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts });
|
|
862
|
-
return { state, record, assumed: false, canTryAgain: state === 'failed' && this.
|
|
913
|
+
const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts, now: this.now(), slowAfterMs: this.timings.publishSlowMs });
|
|
914
|
+
return { state, record, assumed: false, canTryAgain: (state === 'failed' || state === 'slow') && this.redeployer !== null };
|
|
863
915
|
});
|
|
864
916
|
}
|
|
865
917
|
|
|
@@ -904,7 +956,7 @@ export class LiveGit {
|
|
|
904
956
|
} catch (error) {
|
|
905
957
|
if (error instanceof HttpError && (error.status === 403 || error.status === 404)) {
|
|
906
958
|
this.deploymentsDenied = true;
|
|
907
|
-
this.log(`can't read GitHub Deployments (${error.status}): the App needs "Deployments: read".
|
|
959
|
+
this.log(`can't read GitHub Deployments (${error.status}): the site's GitHub token or App needs "Deployments: read" to tell a failed publish. Without it, a publish shows as publishing until the server runs it, then as taking longer than usual.`);
|
|
908
960
|
return { facts: [], later: new Set() };
|
|
909
961
|
}
|
|
910
962
|
throw error;
|
|
@@ -930,7 +982,7 @@ export class LiveGit {
|
|
|
930
982
|
const parsed = parseMessage(c.message);
|
|
931
983
|
if (parsed.kind !== 'publish') return;
|
|
932
984
|
const live = running === null || (runningAt !== -1 && i >= runningAt) || (status.record?.commit === c.sha && status.state === 'live');
|
|
933
|
-
const pending: PublishState = status.state === 'failed'
|
|
985
|
+
const pending: PublishState = status.state === 'failed' || status.state === 'slow' ? status.state : 'publishing';
|
|
934
986
|
entries.push({ commit: c.sha, by: parsed.publishedBy, at: c.date, titles: parsed.titles, files: parsed.publishedFiles, state: live ? 'live' : pending });
|
|
935
987
|
});
|
|
936
988
|
this.historyCache = { at: this.now(), entries };
|
|
@@ -940,13 +992,26 @@ export class LiveGit {
|
|
|
940
992
|
|
|
941
993
|
// ── Try again (Decision 12) ─────────────────────────────────────────────────
|
|
942
994
|
|
|
943
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* Redeploy through the redeployer (runs-anywhere Decision 11): Railway's API with the
|
|
997
|
+
* project token, or the deploy hook's one POST. Never publishes again, and never
|
|
998
|
+
* retries by itself. A success restarts the "taking longer than usual" clock.
|
|
999
|
+
*/
|
|
944
1000
|
async tryAgain(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
945
|
-
if (!this.
|
|
1001
|
+
if (!this.redeployer) return { ok: false, reason: NO_REDEPLOYER_REASON };
|
|
946
1002
|
const record = await this.lastPublish();
|
|
947
1003
|
if (!record) return { ok: false, reason: 'Nothing has been published.' };
|
|
948
|
-
const
|
|
949
|
-
|
|
1004
|
+
const r = this.redeployer.kind === 'hook' ? await this.callHook(this.redeployer) : await this.redeployOnRailway(this.redeployer);
|
|
1005
|
+
if (!r.ok) return r;
|
|
1006
|
+
await this.setPublishRecord({ ...record, tryAgainAt: new Date(this.now()).toISOString() });
|
|
1007
|
+
this.deploymentsCache = null;
|
|
1008
|
+
this.log(`try again: ${this.redeployer.kind === 'hook' ? 'the deploy hook took it' : 'Railway is redeploying'} (publish ${record.commit.slice(0, 7)})`);
|
|
1009
|
+
return { ok: true };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private async redeployOnRailway(railway: Extract<Redeployer, { kind: 'railway' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
1013
|
+
const { token, serviceId, environmentId } = railway;
|
|
1014
|
+
const res = await (railway.fetch ?? fetch)(RAILWAY_API, {
|
|
950
1015
|
method: 'POST',
|
|
951
1016
|
headers: { 'content-type': 'application/json', 'project-access-token': token },
|
|
952
1017
|
body: JSON.stringify({
|
|
@@ -956,15 +1021,28 @@ export class LiveGit {
|
|
|
956
1021
|
});
|
|
957
1022
|
const body = (await res.json().catch(() => ({}))) as { data?: { serviceInstanceRedeploy?: boolean }; errors?: { message: string }[] };
|
|
958
1023
|
if (!res.ok || body.errors?.length || !body.data?.serviceInstanceRedeploy) {
|
|
959
|
-
const reason = body.errors?.map((e) => e.message).join('; ') || `
|
|
1024
|
+
const reason = body.errors?.map((e) => e.message).join('; ') || `the host answered ${res.status}`;
|
|
960
1025
|
this.log(`try again: Railway refused the redeploy (${reason})`);
|
|
961
1026
|
return { ok: false, reason };
|
|
962
1027
|
}
|
|
963
|
-
await this.redis.set(this.key('publish:last'), JSON.stringify({ ...record, tryAgainAt: new Date(this.now()).toISOString() }));
|
|
964
|
-
this.deploymentsCache = null;
|
|
965
|
-
this.log(`try again: Railway is redeploying (publish ${record.commit.slice(0, 7)})`);
|
|
966
1028
|
return { ok: true };
|
|
967
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
/** The deploy hook: one POST, no body, no extra headers; any 2xx is success. The URL is never logged or returned. */
|
|
1032
|
+
private async callHook(hook: Extract<Redeployer, { kind: 'hook' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
1033
|
+
let reason: string;
|
|
1034
|
+
try {
|
|
1035
|
+
const res = await (hook.fetch ?? fetch)(hook.url, { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(hook.timeoutMs ?? DEPLOY_HOOK_TIMEOUT_MS) });
|
|
1036
|
+
await res.body?.cancel().catch(() => {});
|
|
1037
|
+
if (res.status >= 200 && res.status < 300) return { ok: true };
|
|
1038
|
+
reason = `the deploy hook answered ${res.status}`;
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
const name = error instanceof Error ? error.name : '';
|
|
1041
|
+
reason = name === 'TimeoutError' || name === 'AbortError' ? "the deploy hook didn't answer in time" : "the deploy hook couldn't be reached";
|
|
1042
|
+
}
|
|
1043
|
+
this.log(`try again: ${reason}`);
|
|
1044
|
+
return { ok: false, reason };
|
|
1045
|
+
}
|
|
968
1046
|
}
|
|
969
1047
|
|
|
970
1048
|
export interface Moves {
|