caracms 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/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
|
@@ -16,7 +16,7 @@ import { site } from 'virtual:caracms/config';
|
|
|
16
16
|
import { build } from 'virtual:caracms/public';
|
|
17
17
|
import { documentUrl } from '../content.ts';
|
|
18
18
|
import type { PageProps } from '../document.ts';
|
|
19
|
-
import { googleTagSnippet, headOf } from '../head.ts';
|
|
19
|
+
import { googleTagSnippet, headOf, withoutHtml } from '../head.ts';
|
|
20
20
|
import { readMediaConfig } from '../media.ts';
|
|
21
21
|
import { renderSourceOf } from '../read.ts';
|
|
22
22
|
import { SITE_SETTINGS_FILE } from '../reserved.ts';
|
|
@@ -39,7 +39,8 @@ const previewPath = () => {
|
|
|
39
39
|
const head = headOf({
|
|
40
40
|
document,
|
|
41
41
|
settingsText,
|
|
42
|
-
|
|
42
|
+
// A public page's own address, without a `build.format: 'file'` prerender's `.html` (head.ts).
|
|
43
|
+
path: preview ? previewPath() : withoutHtml(Astro.url.pathname),
|
|
43
44
|
siteUrl: preview ? null : build.siteUrl,
|
|
44
45
|
media: readMediaConfig(),
|
|
45
46
|
tagAllowed: build.production && !preview,
|
package/src/content.ts
CHANGED
|
@@ -41,6 +41,8 @@ import { parseSource } from './json-source.ts';
|
|
|
41
41
|
export { contentReader } from './read.ts';
|
|
42
42
|
// M8 trial (item 1): Site Settings for a site's own routes, as the page component gets them.
|
|
43
43
|
export { siteSettings } from './read.ts';
|
|
44
|
+
// Urban Return port, Decision 20: a Site Settings image's URL for the page's frame (media.ts).
|
|
45
|
+
export { settingsImageUrl } from './media.ts';
|
|
44
46
|
|
|
45
47
|
export type WarningCode = 'invalid-json' | 'no-title' | 'nested-index';
|
|
46
48
|
|
|
@@ -336,6 +336,25 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
336
336
|
/** A lock answer (a 409, or Edit here) shown at once, until a poll started after it answers. */
|
|
337
337
|
const lockOverride = createStore<{ file: string; state: LockState; lock: LockView | null; after: number } | null>(null);
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Redis diet Decision 10: files a write from this tab was Kept for, with the poll count
|
|
341
|
+
* then. The write took (or kept) the lock, so the tab holds it until a poll started
|
|
342
|
+
* after that write answers: the poll is the word from then on.
|
|
343
|
+
*/
|
|
344
|
+
const keptAfter = new Map<string, number>();
|
|
345
|
+
/** Files this tab holds the lock on: the last poll's, a lock answer's (Edit here), and those a write just took. */
|
|
346
|
+
const lockedFiles = (): string[] => {
|
|
347
|
+
const files = new Set((liveState.get().status?.locks ?? []).filter((l) => l.mine === 'this-tab').map((l) => l.file));
|
|
348
|
+
const o = lockOverride.get();
|
|
349
|
+
if (o?.state === 'yours') files.add(o.file);
|
|
350
|
+
const seq = liveState.get().seq;
|
|
351
|
+
for (const [file, after] of keptAfter) {
|
|
352
|
+
if (seq > after) keptAfter.delete(file);
|
|
353
|
+
else files.add(file);
|
|
354
|
+
}
|
|
355
|
+
return [...files];
|
|
356
|
+
};
|
|
357
|
+
|
|
339
358
|
// Unsent edits held in this browser (the cookie says so): the bar starts at Keeping.
|
|
340
359
|
const initialKeep = props.unsent ? 'keeping' : 'kept';
|
|
341
360
|
const keeper = options.client
|
|
@@ -351,12 +370,11 @@ export function createEditor(props: AdminProps, options: { client: boolean }): E
|
|
|
351
370
|
tab,
|
|
352
371
|
retryDelays: RETRY_DELAYS,
|
|
353
372
|
you,
|
|
354
|
-
lockedFiles
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
},
|
|
373
|
+
lockedFiles,
|
|
374
|
+
holds: (file: string) => lockedFiles().includes(file),
|
|
375
|
+
onKept: (file: string) => keptAfter.set(file, liveSession.started()),
|
|
376
|
+
...(liveProps?.keepDelayMs !== undefined ? { keepDelay: liveProps.keepDelayMs } : {}),
|
|
377
|
+
...(liveProps?.keepCapMs !== undefined ? { keepCap: liveProps.keepCapMs } : {}),
|
|
360
378
|
onLocked: (file, refusal) => lockOverride.set({ file, state: refusal.state, lock: refusal.lock, after: liveSession.started() }),
|
|
361
379
|
onSignedOut: (body) => liveState.update({ signedOut: body }),
|
|
362
380
|
}
|
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
* #live #failed #blocked #history). Live mode only.
|
|
5
5
|
*
|
|
6
6
|
* - **The bar's button**: "3 waiting" with who, and Publish; "Publishing…" with the
|
|
7
|
-
* running bar; "Live · 14:12"; "Publish failed" with Try again; "
|
|
7
|
+
* running bar; "Live · 14:12"; "Publish failed" with Try again; "Taking longer than
|
|
8
|
+
* usual" (runs-anywhere Decision 10: not live 15 minutes on, no failure known), with
|
|
9
|
+
* Try again when a redeployer is set; "Nothing waiting".
|
|
8
10
|
* Off while the server can't be reached or you're signed out.
|
|
9
11
|
* - **The popover is the summary**: each waiting document, who changed it and when,
|
|
10
12
|
* "Editing now", uncommitted changes included (the status merges them), the merge
|
|
11
13
|
* notice where the draft was kept, then the final Publish button. Then publishing →
|
|
12
|
-
* live, or failed with Try again, or
|
|
13
|
-
* redeploy
|
|
14
|
+
* live, or failed (or taking longer than usual) with Try again, or with the line that
|
|
15
|
+
* a developer can redeploy it when no redeployer is set (`hasRedeployer: false`;
|
|
16
|
+
* runs-anywhere Decision 11: no host's name).
|
|
14
17
|
* - **History**: who published, when, which documents, and whether each went live,
|
|
15
18
|
* in the site's time zone, named once. Read when the waiting popover opens (on
|
|
16
19
|
* intent), so it's there when History is pressed, and the last read shows while
|
|
@@ -129,12 +132,12 @@ export function PublishButton() {
|
|
|
129
132
|
</button>
|
|
130
133
|
);
|
|
131
134
|
}
|
|
132
|
-
if (pub.state === 'failed' && !n) {
|
|
135
|
+
if ((pub.state === 'failed' || pub.state === 'slow') && !n) {
|
|
133
136
|
return (
|
|
134
|
-
<button {...common} className="pub fail" data-pub=
|
|
137
|
+
<button {...common} className="pub fail" data-pub={pub.state}>
|
|
135
138
|
<span className="wt">
|
|
136
139
|
<Icon name="warn" size="sm" />
|
|
137
|
-
Publish failed
|
|
140
|
+
{pub.state === 'slow' ? 'Taking longer than usual' : 'Publish failed'}
|
|
138
141
|
</span>
|
|
139
142
|
<span className={`pb${pub.canTryAgain ? '' : ' off'}`}>{pub.canTryAgain ? 'Try again' : 'Publish'}</span>
|
|
140
143
|
</button>
|
|
@@ -280,14 +283,16 @@ export function WaitingPop() {
|
|
|
280
283
|
</>,
|
|
281
284
|
);
|
|
282
285
|
}
|
|
283
|
-
if (pub.state === 'failed' && !n) {
|
|
286
|
+
if ((pub.state === 'failed' || pub.state === 'slow') && !n) {
|
|
287
|
+
const slow = pub.state === 'slow';
|
|
288
|
+
const published = record ? `${names} ${record.titles.length === 1 ? 'was' : 'were'} published at ${clock(record.at, status.timeZone)}` : null;
|
|
284
289
|
return wrap(
|
|
285
290
|
<>
|
|
286
291
|
<div className="pop-h">
|
|
287
292
|
<div>
|
|
288
|
-
<h2 style={{ color: 'var(--status-danger)' }}>The publish didn't go live</h2>
|
|
293
|
+
<h2 style={{ color: 'var(--status-danger)' }}>{slow ? 'This publish is taking longer than usual' : "The publish didn't go live"}</h2>
|
|
289
294
|
<p className="sub">
|
|
290
|
-
{
|
|
295
|
+
{slow ? `${published ? `${published}. ` : ''}It may have failed.` : `${published ? `${published}, but` : 'The'} the live site couldn't be updated.`}
|
|
291
296
|
</p>
|
|
292
297
|
</div>
|
|
293
298
|
</div>
|
|
@@ -305,7 +310,7 @@ export function WaitingPop() {
|
|
|
305
310
|
<div className="note danger" data-note="no-token">
|
|
306
311
|
<Icon name="warn" />
|
|
307
312
|
<div>
|
|
308
|
-
<b>A developer can redeploy it
|
|
313
|
+
<b>A developer can redeploy it</b>
|
|
309
314
|
</div>
|
|
310
315
|
</div>
|
|
311
316
|
)}
|
|
@@ -597,6 +602,11 @@ export function HistoryPop() {
|
|
|
597
602
|
<Icon name="warn" size="xs" />
|
|
598
603
|
Failed
|
|
599
604
|
</span>
|
|
605
|
+
) : h.state === 'slow' ? (
|
|
606
|
+
<span className="chip danger" data-chip="slow">
|
|
607
|
+
<Icon name="warn" size="xs" />
|
|
608
|
+
Taking longer than usual
|
|
609
|
+
</span>
|
|
600
610
|
) : (
|
|
601
611
|
<span className="chip waiting">Publishing</span>
|
|
602
612
|
)}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pieces every pane level is built from (the mockup's sidebar components): the
|
|
3
3
|
* trail with the stacked level tabs, the current level's header, the notes every
|
|
4
|
-
* level shows
|
|
4
|
+
* level shows (the GitHub access note among them, runs-anywhere Decision 5), and the
|
|
5
|
+
* Recent strip at the foot. A level is
|
|
5
6
|
*
|
|
6
7
|
* <>
|
|
7
8
|
* <Trail>{level tabs}<CurrentHead … /></Trail>
|
|
@@ -76,13 +77,44 @@ export function CurrentHead(props: {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* Notes every level shows above its content:
|
|
80
|
+
* Notes every level shows above its content: GitHub access (live mode, below), then
|
|
81
|
+
* "Can't reach the server" (Decision 6),
|
|
80
82
|
* or, when the server answered a write with a refusal (a 4xx: the edits don't fit
|
|
81
83
|
* their base, the file was renamed or deleted while open), its own message. M3
|
|
82
84
|
* retries with the next change or a reload (no timer), so M4's countdown, "Try now"
|
|
83
85
|
* and unsent count are slots without data.
|
|
84
86
|
*/
|
|
85
87
|
export function GlobalNotes() {
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<GitHubAccessNote />
|
|
91
|
+
<KeepNotes />
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs-anywhere Decision 5: the site's GitHub access is wrong (a refused credential, a
|
|
98
|
+
* missing permission, a token expiring soon), for everyone, on every level, while it
|
|
99
|
+
* holds. Not dismissable. The sentence is the server's (`status.githubAccess`).
|
|
100
|
+
*/
|
|
101
|
+
function GitHubAccessNote() {
|
|
102
|
+
const editor = useEditor();
|
|
103
|
+
const access = useStore(editor.liveState, (s) => s.status?.githubAccess ?? null);
|
|
104
|
+
if (editor.mode !== 'live' || !access) return null;
|
|
105
|
+
const expiring = access.kind === 'expiring';
|
|
106
|
+
return (
|
|
107
|
+
<div className={`note ${expiring ? 'warn' : 'danger'}`} role={expiring ? 'status' : 'alert'} data-note="github-access" data-kind={access.kind}>
|
|
108
|
+
<Icon name="warn" />
|
|
109
|
+
<div>
|
|
110
|
+
<b>{access.title}</b>
|
|
111
|
+
<p>{access.message}</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function KeepNotes() {
|
|
86
118
|
const editor = useEditor();
|
|
87
119
|
const state = useStore(editor.keep, (k) => k.state);
|
|
88
120
|
const error = useStore(editor.keep, (k) => k.error);
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Keeping: getting the editor's copy of each document to the server (M3 Decisions 5
|
|
3
3
|
* and 6; M4 Decisions 4, 9 and 11).
|
|
4
4
|
*
|
|
5
|
-
* - **About a second after the last change
|
|
6
|
-
* write route with the base it was made against: `{ file, base, document }
|
|
5
|
+
* - **About a second after the last change** (local mode), the whole document is POSTed to the
|
|
6
|
+
* write route with the base it was made against: `{ file, base, document }`. Live mode
|
|
7
|
+
* (Redis diet Decision 10, `holds`): at once while the tab doesn't hold the document's
|
|
8
|
+
* lock and no write for it is in flight; otherwise 3 s after the last change, at most
|
|
9
|
+
* 15 s after the first change not yet sent
|
|
7
10
|
* (server/write.ts). One write per document in flight; a change made meanwhile
|
|
8
11
|
* goes in the next one. In live mode every write carries the tab's id
|
|
9
12
|
* (`X-Caracms-Tab`) and its place in the tab's order (`X-Caracms-Seq`, rising, from
|
|
@@ -116,7 +119,7 @@ export interface KeeperOptions {
|
|
|
116
119
|
storage: KeeperStorage | null;
|
|
117
120
|
/** Storage keys are `caracms:unsent:<storageKey>:<docId>`, so two sites on one dev port don't mix. */
|
|
118
121
|
storageKey: string;
|
|
119
|
-
/** ms after the last change. Default 1000. */
|
|
122
|
+
/** ms after the last change. Default 1000 (local mode; live mode's timing is `holds`'s, below). */
|
|
120
123
|
delay?: number;
|
|
121
124
|
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
122
125
|
clearTimer?: (timer: unknown) => void;
|
|
@@ -145,6 +148,20 @@ export interface KeeperOptions {
|
|
|
145
148
|
onSignedOut?: (body: SignedOutError) => void;
|
|
146
149
|
/** Who is signed in, for the re-send rule ("your own commits"). */
|
|
147
150
|
you?: () => Person | null;
|
|
151
|
+
/**
|
|
152
|
+
* Redis diet Decision 10: whether this tab holds the document's lock (a write for it
|
|
153
|
+
* was Kept, or a poll says so). Given, live mode's timing applies: a change goes at
|
|
154
|
+
* once while the document isn't held and no write for it is in flight (that write
|
|
155
|
+
* takes the lock); otherwise `keepDelay` after the last change, and never later than
|
|
156
|
+
* `keepCap` after the first change not yet sent. Absent: `delay` after the last change.
|
|
157
|
+
*/
|
|
158
|
+
holds?: (file: string) => boolean;
|
|
159
|
+
/** Decision 10: ms after the last change, once held. Default 3000. */
|
|
160
|
+
keepDelay?: number;
|
|
161
|
+
/** Decision 10: …and at most this long after the first change not yet sent. Default 15000. */
|
|
162
|
+
keepCap?: number;
|
|
163
|
+
/** A write for this file was Kept (the server has it, and the write took or kept the lock). */
|
|
164
|
+
onKept?: (file: string) => void;
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
/** A held record the editor may need to look at again: once the lock lapses, or the session is back. */
|
|
@@ -223,6 +240,9 @@ interface Record {
|
|
|
223
240
|
by: Person | null;
|
|
224
241
|
elsewhere: boolean;
|
|
225
242
|
timer: unknown;
|
|
243
|
+
/** Live timing: when the last change came, and the first change not yet sent (null: none). */
|
|
244
|
+
lastChange: number;
|
|
245
|
+
firstUnsent: number | null;
|
|
226
246
|
}
|
|
227
247
|
|
|
228
248
|
const PREFIX = 'caracms:unsent:';
|
|
@@ -309,6 +329,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
309
329
|
const clearTimer = options.clearTimer ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
|
|
310
330
|
const log = options.log ?? ((message) => console.warn(`[caracms] ${message}`));
|
|
311
331
|
const now = options.now ?? Date.now;
|
|
332
|
+
const live = options.holds ? { holds: options.holds, delay: options.keepDelay ?? 3000, cap: options.keepCap ?? 15_000 } : null;
|
|
312
333
|
const prefix = `${PREFIX}${options.storageKey}:`;
|
|
313
334
|
const records = new Map<string, Record>();
|
|
314
335
|
let offline = false;
|
|
@@ -394,7 +415,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
394
415
|
const record = (id: string, file: string, fileVersion: string): Record => {
|
|
395
416
|
let r = records.get(id);
|
|
396
417
|
if (!r) {
|
|
397
|
-
r = { id, file, base: '', document: null, fileVersion, revision: 0, written: 0, inFlight: false, failure: null, refusal: null, by: null, elsewhere: false, timer: null };
|
|
418
|
+
r = { id, file, base: '', document: null, fileVersion, revision: 0, written: 0, inFlight: false, failure: null, refusal: null, by: null, elsewhere: false, timer: null, lastChange: 0, firstUnsent: null };
|
|
398
419
|
records.set(id, r);
|
|
399
420
|
}
|
|
400
421
|
return r;
|
|
@@ -402,6 +423,12 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
402
423
|
const stash = (r: Record) => {
|
|
403
424
|
if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion });
|
|
404
425
|
};
|
|
426
|
+
/** Decision 10 (live): ms until the change waiting goes, once the document is held: the delay after the last change, capped from the first unsent one. */
|
|
427
|
+
const due = (r: Record) => {
|
|
428
|
+
if (!live) return delay;
|
|
429
|
+
const at = Math.min(r.lastChange + live.delay, (r.firstUnsent ?? r.lastChange) + live.cap);
|
|
430
|
+
return Math.max(0, at - now());
|
|
431
|
+
};
|
|
405
432
|
const schedule = (r: Record, ms = delay) => {
|
|
406
433
|
if (r.timer !== null) clearTimer(r.timer);
|
|
407
434
|
r.timer = setTimer(() => {
|
|
@@ -447,6 +474,9 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
447
474
|
if (!dirty(r) || !r.document) return;
|
|
448
475
|
if (r.inFlight) return; // the change waits; it's sent when this write returns
|
|
449
476
|
const revision = r.revision;
|
|
477
|
+
// Every change so far goes in this write; the cap counts again from the next one.
|
|
478
|
+
const firstUnsent = r.firstUnsent;
|
|
479
|
+
r.firstUnsent = null;
|
|
450
480
|
r.inFlight = true;
|
|
451
481
|
refresh();
|
|
452
482
|
let ok = false;
|
|
@@ -470,6 +500,7 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
470
500
|
r.elsewhere = false;
|
|
471
501
|
if (typeof body.version === 'string') r.fileVersion = body.version;
|
|
472
502
|
r.written = Math.max(r.written, revision);
|
|
503
|
+
options.onKept?.(r.file);
|
|
473
504
|
if (dirty(r)) stash(r); // newer edits wait: they're now against the version just written
|
|
474
505
|
else storage.remove(r.id);
|
|
475
506
|
} catch (error) {
|
|
@@ -505,6 +536,8 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
505
536
|
}
|
|
506
537
|
} finally {
|
|
507
538
|
r.inFlight = false;
|
|
539
|
+
// Not sent after all: the cap still counts from the earliest change waiting.
|
|
540
|
+
if (!ok && firstUnsent !== null) r.firstUnsent = Math.min(firstUnsent, r.firstUnsent ?? firstUnsent);
|
|
508
541
|
if (ok && !offline) {
|
|
509
542
|
stopRetry();
|
|
510
543
|
// Live mode: the server is back, so whatever else couldn't reach it goes now.
|
|
@@ -514,7 +547,12 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
514
547
|
refresh();
|
|
515
548
|
}
|
|
516
549
|
// A change that came in during the write goes out now, unless its own timer is still running.
|
|
517
|
-
|
|
550
|
+
// Live mode (Decision 10): it waits by the same rule as any change to a held document.
|
|
551
|
+
if (ok && dirty(r) && r.timer === null && !r.failure) {
|
|
552
|
+
const ms = live ? due(r) : 0;
|
|
553
|
+
if (ms > 0) schedule(r, ms);
|
|
554
|
+
else void send(r);
|
|
555
|
+
}
|
|
518
556
|
}
|
|
519
557
|
|
|
520
558
|
function adopt(id: string, stored: StoredUnsent): Record {
|
|
@@ -561,8 +599,15 @@ export function createKeeper(options: KeeperOptions): Keeper {
|
|
|
561
599
|
r.base = edit.base;
|
|
562
600
|
r.document = edit.document;
|
|
563
601
|
r.revision += 1;
|
|
602
|
+
const t = now();
|
|
603
|
+
r.lastChange = t;
|
|
604
|
+
if (r.firstUnsent === null) r.firstUnsent = t;
|
|
564
605
|
stash(r);
|
|
565
|
-
if (!r.failure || !WAITS.has(r.failure))
|
|
606
|
+
if (!r.failure || !WAITS.has(r.failure)) {
|
|
607
|
+
// Decision 10: the first write to a document goes at once, since it's what takes the lock.
|
|
608
|
+
if (live && !r.inFlight && !live.holds(r.file)) schedule(r, 0);
|
|
609
|
+
else schedule(r, due(r));
|
|
610
|
+
}
|
|
566
611
|
// After a failed write (unreachable or refused), the next change retries everything waiting.
|
|
567
612
|
for (const other of records.values()) if (other !== r && other.failure && !WAITS.has(other.failure) && dirty(other)) schedule(other, 0);
|
|
568
613
|
refresh();
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* - **The poll**: `GET /_caracms/status?file=<the open document>` every `POLL_MS`
|
|
7
7
|
* while the tab is visible, and at once when it becomes visible again. It carries
|
|
8
8
|
* the tab's id, so it's also the lock's heartbeat and the tab's presence.
|
|
9
|
+
* - **Idle tabs slow down** (Redis diet Decision 8): a visible tab with no input for
|
|
10
|
+
* `idleSlowAfterMs` polls every `slowPollMs`; any input, becoming visible, or a poll
|
|
11
|
+
* answer showing someone else's lock on the open document (another person or another
|
|
12
|
+
* tab) brings it back to `pollMs` at once. Each poll says how long until the next
|
|
13
|
+
* (`interval`), so the server holds the tab's presence that long (Decision 9); the
|
|
14
|
+
* next poll never comes later than the interval it sent.
|
|
9
15
|
* - **Last-known data**: a poll that can't reach the server (a deploy replacing it,
|
|
10
16
|
* which Publish causes) keeps the last status and says `reachable: false`. Nothing
|
|
11
17
|
* is lost and nothing shows as an error; the next answer brings it back.
|
|
@@ -22,6 +28,10 @@ import { createStore, type Store } from './store.ts';
|
|
|
22
28
|
*/
|
|
23
29
|
export const POLL_MS = 5000;
|
|
24
30
|
|
|
31
|
+
/** Redis diet Decision 8's defaults, for a page whose props don't carry the live timings. */
|
|
32
|
+
export const IDLE_SLOW_AFTER_MS = 2 * 60_000;
|
|
33
|
+
export const SLOW_POLL_MS = 30_000;
|
|
34
|
+
|
|
25
35
|
/** Where sign out goes (Phase 3's route): `{ ok, location }` to a JSON request. */
|
|
26
36
|
export const SIGN_OUT = '/admin/login/signout';
|
|
27
37
|
|
|
@@ -98,6 +108,25 @@ export function createLiveSession(options: LiveOptions): LiveSession {
|
|
|
98
108
|
let lastInput = now();
|
|
99
109
|
let inFlight: Promise<void> | null = null;
|
|
100
110
|
let again = false;
|
|
111
|
+
const pollMs = options.props.pollMs ?? POLL_MS;
|
|
112
|
+
const slowPollMs = options.props.slowPollMs ?? SLOW_POLL_MS;
|
|
113
|
+
const idleSlowAfterMs = options.props.idleSlowAfterMs ?? IDLE_SLOW_AFTER_MS;
|
|
114
|
+
/** The interval the last poll sent: the next one comes no later (the server holds presence from it). */
|
|
115
|
+
let sentInterval = pollMs;
|
|
116
|
+
/** Set by start(): input while the loop runs slow brings it back to `pollMs` at once. */
|
|
117
|
+
let onInput: (() => void) | null = null;
|
|
118
|
+
const visible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
119
|
+
/** Decision 8: the open document's lock is someone else's (another person, or another tab). */
|
|
120
|
+
const othersLock = () => {
|
|
121
|
+
const lock = state.get().status?.open?.lock;
|
|
122
|
+
return !!lock && lock.mine !== 'this-tab';
|
|
123
|
+
};
|
|
124
|
+
/** How long until the next poll, as things stand. */
|
|
125
|
+
const interval = () => (visible() && now() - lastInput >= idleSlowAfterMs && !othersLock() ? slowPollMs : pollMs);
|
|
126
|
+
const used = () => {
|
|
127
|
+
lastInput = now();
|
|
128
|
+
onInput?.();
|
|
129
|
+
};
|
|
101
130
|
|
|
102
131
|
const headers = (json: boolean): Record<string, string> => ({ Accept: 'application/json', [TAB_HEADER]: options.tab, ...(json ? { 'Content-Type': 'application/json' } : {}) });
|
|
103
132
|
|
|
@@ -125,7 +154,9 @@ export function createLiveSession(options: LiveOptions): LiveSession {
|
|
|
125
154
|
const mine = ++seq;
|
|
126
155
|
const file = options.openFile();
|
|
127
156
|
// `input`: ms since someone used this tab. The server stops renewing an idle tab's lock.
|
|
128
|
-
|
|
157
|
+
// `interval`: ms until this tab's next poll (Redis diet Contracts): the server holds its presence from it.
|
|
158
|
+
sentInterval = interval();
|
|
159
|
+
const query = new URLSearchParams({ ...(file ? { file } : {}), input: String(Math.max(0, Math.round(now() - lastInput))), interval: String(sentInterval) });
|
|
129
160
|
const answer = await call<StatusResponse>(`${ENDPOINTS.status}?${query}`);
|
|
130
161
|
if (!answer) {
|
|
131
162
|
state.update({ reachable: false });
|
|
@@ -169,17 +200,22 @@ export function createLiveSession(options: LiveOptions): LiveSession {
|
|
|
169
200
|
let stopped = false;
|
|
170
201
|
/** One loop at a time: a restart starts a new generation, and the old one stops at its next turn. */
|
|
171
202
|
let generation = 0;
|
|
172
|
-
|
|
203
|
+
/** Whether the timer now running is a slow one (input then restarts at once). */
|
|
204
|
+
let slow = false;
|
|
173
205
|
const loop = (g: number) => {
|
|
174
206
|
if (stopped || g !== generation) return;
|
|
207
|
+
// Never later than the last poll said; sooner when the answer showed someone else's lock.
|
|
208
|
+
const ms = Math.min(sentInterval, interval());
|
|
209
|
+
slow = ms > pollMs;
|
|
175
210
|
timer = setTimer(async () => {
|
|
176
211
|
timer = null;
|
|
177
212
|
if (g !== generation) return;
|
|
178
213
|
if (visible()) await poll();
|
|
179
214
|
loop(g);
|
|
180
|
-
},
|
|
215
|
+
}, ms);
|
|
181
216
|
};
|
|
182
217
|
const restart = () => {
|
|
218
|
+
slow = false;
|
|
183
219
|
const g = ++generation;
|
|
184
220
|
if (timer !== null) clearTimer(timer);
|
|
185
221
|
timer = null;
|
|
@@ -191,14 +227,17 @@ export function createLiveSession(options: LiveOptions): LiveSession {
|
|
|
191
227
|
lastInput = now(); // coming back to the tab counts as using it
|
|
192
228
|
restart();
|
|
193
229
|
};
|
|
194
|
-
const onActivity = () =>
|
|
195
|
-
|
|
230
|
+
const onActivity = () => used();
|
|
231
|
+
// Decision 8: input on a slowed tab polls now, and the loop is back to `pollMs`.
|
|
232
|
+
onInput = () => {
|
|
233
|
+
if (slow && !stopped) restart();
|
|
196
234
|
};
|
|
197
235
|
document.addEventListener('visibilitychange', onVisible);
|
|
198
236
|
for (const type of ACTIVITY_EVENTS) document.addEventListener(type, onActivity, { capture: true, passive: true });
|
|
199
237
|
restart();
|
|
200
238
|
return () => {
|
|
201
239
|
stopped = true;
|
|
240
|
+
onInput = null;
|
|
202
241
|
if (timer !== null) clearTimer(timer);
|
|
203
242
|
document.removeEventListener('visibilitychange', onVisible);
|
|
204
243
|
for (const type of ACTIVITY_EVENTS) document.removeEventListener(type, onActivity, { capture: true });
|
|
@@ -231,7 +270,7 @@ export function createLiveSession(options: LiveOptions): LiveSession {
|
|
|
231
270
|
await call(ENDPOINTS.notice, { method: 'POST', body: { id } });
|
|
232
271
|
},
|
|
233
272
|
active() {
|
|
234
|
-
|
|
273
|
+
used();
|
|
235
274
|
},
|
|
236
275
|
async takeOver(file) {
|
|
237
276
|
const answer = await call<LockResponse>(ENDPOINTS.lock, { method: 'POST', body: { file, action: 'take-over' } });
|
package/src/editor/middleware.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Registered first (`addMiddleware({ order: 'pre' })` in ../index.ts), so it runs
|
|
7
7
|
* before Phase 3's sign-in guard, which then only ever sees local or live mode.
|
|
8
|
-
* Every other path passes straight through, and so does the public form endpoint,
|
|
8
|
+
* Every other path passes straight through, and so does a POST to the public form endpoint,
|
|
9
9
|
* `/_caracms/form` (M6 Decision 5): the form is part of the public site, which works
|
|
10
10
|
* without the editor.
|
|
11
11
|
*
|
|
@@ -39,7 +39,8 @@ export const isEditorPath = (pathname: string) =>
|
|
|
39
39
|
(pathname === '/admin' || pathname.startsWith('/admin/') || pathname.startsWith('/_caracms/')) && !isFormEndpoint(pathname);
|
|
40
40
|
|
|
41
41
|
export const onRequest: MiddlewareHandler = (context, next) => {
|
|
42
|
-
|
|
42
|
+
// The form endpoint's POST is public; any other method to it is as unavailable as the rest (guard.ts's let-through is POST-only).
|
|
43
|
+
if (!isEditorPath(context.url.pathname) && !(isFormEndpoint(context.url.pathname) && context.request.method !== 'POST')) return next();
|
|
43
44
|
const mode = editorMode();
|
|
44
45
|
if (mode.kind !== 'unconfigured') {
|
|
45
46
|
if (context.url.pathname !== ENDPOINTS.preview || context.request.method !== 'GET') return next();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* POST /_caracms/try-again: redeploy a failed publish through
|
|
2
|
+
* POST /_caracms/try-again: redeploy a failed (or slow) publish through the redeployer:
|
|
3
|
+
* Railway's API or a deploy hook (M4 Decision 12; runs-anywhere Decision 11).
|
|
3
4
|
* Live mode only (M4 Phase 2); the handler is ../../live/editor.ts → `tryAgain`. Shapes: ../types.ts.
|
|
4
5
|
*/
|
|
5
6
|
import { liveRoute } from './_live.ts';
|
|
@@ -109,7 +109,8 @@ async function liveProps(site: ResolvedSite, url: URL, file: string, person: Per
|
|
|
109
109
|
} catch (error) {
|
|
110
110
|
console.warn(`[caracms] /admin couldn't read the status for its first paint (${error instanceof Error ? error.message : String(error)}); the first poll fills it`);
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
const t = mode.settings.timings;
|
|
113
|
+
return { you: person, status, pollMs: t.pollMs, idleSlowAfterMs: t.idleSlowAfterMs, slowPollMs: t.slowPollMs, keepDelayMs: t.keepDelayMs, keepCapMs: t.keepCapMs };
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The editor's mode at run time (M4 Decision 1), and the drafts store it implies.
|
|
3
3
|
*
|
|
4
|
-
* - `editorMode()`: `readMode(process.env, isProductionServer())`, read once per
|
|
4
|
+
* - `editorMode()`: `readMode(process.env, isProductionServer(), bakedAtBuild())`, read once per
|
|
5
5
|
* process. **A production server is the build**, not `NODE_ENV`: the integration
|
|
6
6
|
* sets `__CARACMS_PRODUCTION__` (a Vite define) to `command !== 'dev'`, so the
|
|
7
7
|
* built server is always live mode (or unconfigured), and `astro dev` is local
|
|
@@ -14,16 +14,26 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { ResolvedSite } from '../../config.ts';
|
|
16
16
|
import { liveEditor, type LiveEditor } from '../../live/editor.ts';
|
|
17
|
-
import { readMode, unconfiguredMessage, type Mode } from '../../live/mode.ts';
|
|
17
|
+
import { readMode, unconfiguredMessage, type Baked, type Mode } from '../../live/mode.ts';
|
|
18
18
|
import { localDraftsStore, type DraftsStore } from './drafts-store.ts';
|
|
19
19
|
|
|
20
20
|
declare const __CARACMS_PRODUCTION__: boolean | undefined;
|
|
21
|
+
declare const __CARACMS_SITE__: string | null | undefined;
|
|
22
|
+
declare const __CARACMS_BUILD_COMMIT__: string | null | undefined;
|
|
21
23
|
|
|
22
24
|
/** True in the built server (`astro build`), false under `astro dev` and in plain Node (tests). */
|
|
23
25
|
export function isProductionServer(): boolean {
|
|
24
26
|
return typeof __CARACMS_PRODUCTION__ === 'boolean' ? __CARACMS_PRODUCTION__ : false;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** The site's address and the commit the build baked in (runs-anywhere Decisions 7 and 9); empty in plain Node (tests). */
|
|
30
|
+
export function bakedAtBuild(): Baked {
|
|
31
|
+
return {
|
|
32
|
+
site: typeof __CARACMS_SITE__ === 'string' ? __CARACMS_SITE__ : null,
|
|
33
|
+
commit: typeof __CARACMS_BUILD_COMMIT__ === 'string' ? __CARACMS_BUILD_COMMIT__ : null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
const SHARED = Symbol.for('caracms.editorMode');
|
|
28
38
|
type Shared = { [SHARED]?: Mode };
|
|
29
39
|
|
|
@@ -31,7 +41,7 @@ type Shared = { [SHARED]?: Mode };
|
|
|
31
41
|
export function editorMode(): Mode {
|
|
32
42
|
const g = globalThis as Shared;
|
|
33
43
|
if (!g[SHARED]) {
|
|
34
|
-
const mode = readMode(process.env, isProductionServer());
|
|
44
|
+
const mode = readMode(process.env, isProductionServer(), bakedAtBuild());
|
|
35
45
|
if (mode.kind === 'unconfigured') console.error(`[caracms] ${unconfiguredMessage(mode)} The public site is served; /admin answers 503.`);
|
|
36
46
|
g[SHARED] = mode;
|
|
37
47
|
}
|
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
.mk{display:contents}
|
|
7
7
|
fieldset.ro-fields{display:contents;margin:0;padding:0;border:0;min-width:0}
|
|
8
8
|
.note .acts .why{flex-basis:100%;margin:0;font-size:var(--text-xs);color:var(--text-subtle)}
|
|
9
|
-
/* Failed with no
|
|
9
|
+
/* Failed (or taking longer than usual) with no redeployer (a state M4 adds): Publish is off, so it takes the off ink, not the failure's red (1.9:1 on the off fill). */
|
|
10
10
|
.pub.fail .pb.off{color:var(--bar-off-text)}
|
package/src/editor/types.ts
CHANGED
|
@@ -205,6 +205,14 @@ export interface LiveProps {
|
|
|
205
205
|
status: StatusResponse | null;
|
|
206
206
|
/** How often the tab polls (live timings' `pollMs`: POLL_MS, shorter only in tests). */
|
|
207
207
|
pollMs?: number;
|
|
208
|
+
/** Redis diet Decision 8: a visible tab with no input this long polls at `slowPollMs` (live timings'; absent: 2 minutes). */
|
|
209
|
+
idleSlowAfterMs?: number;
|
|
210
|
+
/** Redis diet Decision 8: an idle tab's poll interval (absent: 30 s). */
|
|
211
|
+
slowPollMs?: number;
|
|
212
|
+
/** Redis diet Decision 10: once the tab holds the document, a keep goes this long after the last change (absent: 3 s). */
|
|
213
|
+
keepDelayMs?: number;
|
|
214
|
+
/** Redis diet Decision 10: …and never later than this after the first change not yet sent (absent: 15 s). */
|
|
215
|
+
keepCapMs?: number;
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
/** A block in the editor: a DocumentBlock plus a key that stays with it through moves (never written). */
|
|
@@ -431,17 +439,21 @@ export interface PublishRecordView {
|
|
|
431
439
|
}
|
|
432
440
|
|
|
433
441
|
export interface PublishView {
|
|
434
|
-
/**
|
|
435
|
-
|
|
442
|
+
/**
|
|
443
|
+
* `none`: nothing published since the record began. `slow` (runs-anywhere Decision 10):
|
|
444
|
+
* still not live 15 minutes after it was made or last tried again, with no failure
|
|
445
|
+
* known: "taking longer than usual. It may have failed."
|
|
446
|
+
*/
|
|
447
|
+
state: 'none' | 'publishing' | 'live' | 'failed' | 'slow';
|
|
436
448
|
/** A Publish is running now (committing, or moving the refs), before any record. */
|
|
437
449
|
running: boolean;
|
|
438
450
|
record: PublishRecordView | null;
|
|
439
451
|
/** Live only because there's no deploy pipeline (Decision 12). */
|
|
440
452
|
assumed: boolean;
|
|
441
|
-
/** Failed, and Try again can redeploy. */
|
|
453
|
+
/** Failed or slow, and Try again can redeploy. */
|
|
442
454
|
canTryAgain: boolean;
|
|
443
|
-
/**
|
|
444
|
-
|
|
455
|
+
/** A redeployer is set (a deploy hook, or Railway's token). Failed with none: "A developer can redeploy it" (runs-anywhere Decision 11). */
|
|
456
|
+
hasRedeployer: boolean;
|
|
445
457
|
}
|
|
446
458
|
|
|
447
459
|
/** GET /_caracms/status?file=<open document's file> (Decision 13). */
|
|
@@ -470,6 +482,13 @@ export interface StatusResponse {
|
|
|
470
482
|
presence: PresenceView[];
|
|
471
483
|
notices: NoticeView[];
|
|
472
484
|
publish: PublishView;
|
|
485
|
+
/**
|
|
486
|
+
* Runs-anywhere Decision 5: what's wrong with the site's GitHub access, for every
|
|
487
|
+
* person, while it holds (not dismissable, unlike `notices`): a refused credential, a
|
|
488
|
+
* missing permission, or a token expiring within 14 days. `message` names the fix,
|
|
489
|
+
* for the developer it's forwarded to. Null when nothing is wrong.
|
|
490
|
+
*/
|
|
491
|
+
githubAccess: { kind: 'refused' | 'permission' | 'expiring'; title: string; message: string } | null;
|
|
473
492
|
/**
|
|
474
493
|
* The brand as committed in Site Settings (M5a Decision 10: read from `drafts`, never
|
|
475
494
|
* an autosave), so the chrome picks up a change on the next poll with no reload.
|
|
@@ -500,7 +519,7 @@ export interface HistoryEntryView {
|
|
|
500
519
|
at: string;
|
|
501
520
|
titles: string[];
|
|
502
521
|
files: string[];
|
|
503
|
-
state: 'publishing' | 'live' | 'failed';
|
|
522
|
+
state: 'publishing' | 'live' | 'failed' | 'slow';
|
|
504
523
|
}
|
|
505
524
|
/** GET /_caracms/history. */
|
|
506
525
|
export interface HistoryResponse {
|