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.
Files changed (50) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +64 -20
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +5 -3
  8. package/skills/caracms-adopt/look-same.mjs +8 -4
  9. package/skills/caracms-blocks/SKILL.md +20 -2
  10. package/skills/caracms-tina/SKILL.md +14 -10
  11. package/src/components/Head.astro +3 -2
  12. package/src/content.ts +2 -0
  13. package/src/editor/client/editor.ts +24 -6
  14. package/src/editor/client/live/publish.tsx +20 -10
  15. package/src/editor/client/pane/parts.tsx +34 -2
  16. package/src/editor/client/state/keeper.ts +51 -6
  17. package/src/editor/client/state/live.ts +45 -6
  18. package/src/editor/middleware.ts +3 -2
  19. package/src/editor/routes/try-again.ts +2 -1
  20. package/src/editor/server/admin-props.ts +2 -1
  21. package/src/editor/server/mode.ts +13 -3
  22. package/src/editor/styles/levels/live.css +1 -1
  23. package/src/editor/types.ts +25 -6
  24. package/src/files-route.ts +9 -4
  25. package/src/forms/endpoint.ts +11 -16
  26. package/src/head.ts +10 -1
  27. package/src/index.ts +53 -7
  28. package/src/live/README.md +4 -2
  29. package/src/live/auth/auth.ts +4 -1
  30. package/src/live/auth/guard.ts +3 -2
  31. package/src/live/auth/index.ts +6 -7
  32. package/src/live/auth/login.ts +17 -8
  33. package/src/live/auth/middleware.ts +7 -3
  34. package/src/live/auth/request.ts +92 -11
  35. package/src/live/auth/views.ts +9 -4
  36. package/src/live/autosaves.ts +59 -16
  37. package/src/live/backend.ts +13 -1
  38. package/src/live/drafts-store.ts +4 -3
  39. package/src/live/editor.ts +92 -67
  40. package/src/live/git.ts +112 -34
  41. package/src/live/github.ts +128 -13
  42. package/src/live/locks.ts +92 -26
  43. package/src/live/mode.ts +131 -18
  44. package/src/live/notices.ts +100 -0
  45. package/src/live/redis.ts +161 -39
  46. package/src/live/runtime.ts +11 -3
  47. package/src/live/status-cache.ts +219 -0
  48. package/src/live/status.ts +14 -2
  49. package/src/live/timings.ts +24 -0
  50. 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
- path: preview ? previewPath() : Astro.url.pathname,
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
- const files = new Set((liveState.get().status?.locks ?? []).filter((l) => l.mine === 'this-tab').map((l) => l.file));
356
- const o = lockOverride.get();
357
- if (o?.state === 'yours') files.add(o.file);
358
- return [...files];
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; "Nothing waiting".
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 failed with the line that a developer can
13
- * redeploy from Railway when there's no token (`hasToken: false`).
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="failed">
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
- {record ? `${names} ${record.titles.length === 1 ? 'was' : 'were'} published at ${clock(record.at, status.timeZone)}, but` : 'The'} the live site couldn't be updated.
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 from Railway</b>
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, and the Recent strip at the foot. A level is
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: "Can't reach the server" (Decision 6),
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**, the whole document is POSTed to the
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
- if (ok && dirty(r) && r.timer === null && !r.failure) void send(r);
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)) schedule(r);
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
- const query = new URLSearchParams({ ...(file ? { file } : {}), input: String(Math.max(0, Math.round(now() - lastInput))) });
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
- const visible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
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
- }, options.props.pollMs ?? POLL_MS);
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
- lastInput = now();
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
- lastInput = now();
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' } });
@@ -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
- if (!isEditorPath(context.url.pathname)) return next();
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 Railway (Decision 12).
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
- return { you: person, status, pollMs: mode.settings.timings.pollMs };
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 Railway token (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). */
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)}
@@ -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
- /** `none`: nothing published since the record began. */
435
- state: 'none' | 'publishing' | 'live' | 'failed';
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
- /** CARACMS_RAILWAY_TOKEN is set. Failed with no token: "a developer can redeploy from Railway". */
444
- hasToken: boolean;
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 {