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
package/src/live/redis.ts
CHANGED
|
@@ -68,10 +68,24 @@ export interface RedisClient {
|
|
|
68
68
|
compareAndSet(key: string, expected: string | null, value: string, options?: { px?: number }): Promise<boolean>;
|
|
69
69
|
/** Delete `key` only if it holds `expected`. Atomic. For releasing a lock you hold. */
|
|
70
70
|
compareAndDelete(key: string, expected: string): Promise<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* Several commands sent together (Redis diet Decision 7): ioredis's pipeline, one
|
|
73
|
+
* round trip; the stand-in runs them in order. Replies come back in the commands'
|
|
74
|
+
* order. Each command is billed and counted as one. A batch that failed in flight is
|
|
75
|
+
* sent again only when every command in it is safe to send twice (see `connectRedis`).
|
|
76
|
+
* A command that needs another's answer (an MGET of what a SCAN found) goes in the next batch.
|
|
77
|
+
*/
|
|
78
|
+
batch<const C extends readonly BatchCommand[]>(commands: C): Promise<BatchReplies<C>>;
|
|
71
79
|
/** Close the connection, if any. The client stays usable: the next command reconnects. */
|
|
72
80
|
close(): Promise<void>;
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
type BatchName = Exclude<keyof RedisClient, 'batch' | 'close'>;
|
|
84
|
+
/** One command of a batch: its method's name and arguments, `['mget', keys]`, `['hgetall', key]`. */
|
|
85
|
+
export type BatchCommand = { [K in BatchName]: readonly [K, ...Parameters<RedisClient[K]>] }[BatchName];
|
|
86
|
+
/** The replies to a batch, as the methods of the same names give them. */
|
|
87
|
+
export type BatchReplies<C extends readonly BatchCommand[]> = { -readonly [I in keyof C]: C[I] extends readonly [infer K extends BatchName, ...unknown[]] ? Awaited<ReturnType<RedisClient[K]>> : never };
|
|
88
|
+
|
|
75
89
|
/** Redis couldn't be reached within the budget, or refused a command. `code` is the real socket error's. */
|
|
76
90
|
export class RedisUnavailableError extends Error {
|
|
77
91
|
readonly code: string | null;
|
|
@@ -132,6 +146,86 @@ function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<
|
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
type Ioredis = InstanceType<typeof IORedis>;
|
|
149
|
+
/** The client or a pipeline: the same calls, which a pipeline queues. */
|
|
150
|
+
type Target = Record<string, (...args: unknown[]) => unknown>;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* How one command goes to ioredis: whether sending it twice is harmless, the call (to the
|
|
154
|
+
* client, or queued on a pipeline), and how its reply reads. `send: null`: the answer is
|
|
155
|
+
* known without asking (`answer`), so nothing is sent.
|
|
156
|
+
*/
|
|
157
|
+
interface Spec {
|
|
158
|
+
safe: boolean;
|
|
159
|
+
send: ((t: Target) => unknown) | null;
|
|
160
|
+
read: (c: Ioredis, reply: unknown) => unknown;
|
|
161
|
+
answer?: unknown;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const px = (ms: number) => Math.max(1, Math.round(ms));
|
|
165
|
+
const asIs = (_c: Ioredis, reply: unknown) => reply;
|
|
166
|
+
const isOne = (_c: Ioredis, reply: unknown) => reply === 1;
|
|
167
|
+
const known = (answer: unknown): Spec => ({ safe: true, send: null, read: asIs, answer });
|
|
168
|
+
const spec = (safe: boolean, name: string, args: unknown[], read: Spec['read'] = asIs): Spec => ({ safe, send: (t) => t[name]!(...args), read });
|
|
169
|
+
|
|
170
|
+
function specOf(command: BatchCommand): Spec {
|
|
171
|
+
switch (command[0]) {
|
|
172
|
+
case 'get':
|
|
173
|
+
case 'pttl':
|
|
174
|
+
case 'hgetall':
|
|
175
|
+
case 'smembers':
|
|
176
|
+
return spec(true, command[0], [command[1]]);
|
|
177
|
+
case 'getdel':
|
|
178
|
+
return spec(false, 'getdel', [command[1]]);
|
|
179
|
+
case 'incr':
|
|
180
|
+
return spec(false, 'incr', [command[1]]);
|
|
181
|
+
case 'mget':
|
|
182
|
+
return command[1].length ? spec(true, 'mget', [...command[1]]) : known([]);
|
|
183
|
+
case 'set': {
|
|
184
|
+
const [, key, value, o = {}] = command;
|
|
185
|
+
const args: unknown[] = [key, value];
|
|
186
|
+
if (o.px !== undefined) args.push('PX', px(o.px));
|
|
187
|
+
if (o.nx) args.push('NX');
|
|
188
|
+
return spec(!o.nx, 'set', args, (_c, reply) => reply === 'OK');
|
|
189
|
+
}
|
|
190
|
+
case 'del': {
|
|
191
|
+
const [, ...keys] = command;
|
|
192
|
+
return keys.length ? spec(true, 'del', keys) : known(0);
|
|
193
|
+
}
|
|
194
|
+
case 'pexpire':
|
|
195
|
+
return spec(true, 'pexpire', [command[1], px(command[2])], isOne);
|
|
196
|
+
case 'hget':
|
|
197
|
+
return spec(true, 'hget', [command[1], command[2]]);
|
|
198
|
+
case 'hset':
|
|
199
|
+
return spec(true, 'hset', [command[1], command[2]]);
|
|
200
|
+
case 'hdel':
|
|
201
|
+
case 'sadd':
|
|
202
|
+
case 'srem': {
|
|
203
|
+
const [name, key, ...rest] = command;
|
|
204
|
+
return rest.length ? spec(true, name, [key, ...rest]) : known(0);
|
|
205
|
+
}
|
|
206
|
+
case 'scan': {
|
|
207
|
+
const pattern = command[1];
|
|
208
|
+
// The first page goes with the rest of a batch; any further pages follow on the same connection.
|
|
209
|
+
return spec(true, 'scan', ['0', 'MATCH', pattern, 'COUNT', SCAN_COUNT], async (c, reply) => {
|
|
210
|
+
let [cursor, page] = reply as [string, string[]];
|
|
211
|
+
const keys = new Set(page);
|
|
212
|
+
while (cursor !== '0') {
|
|
213
|
+
[cursor, page] = await c.scan(cursor, 'MATCH', pattern, 'COUNT', SCAN_COUNT);
|
|
214
|
+
for (const k of page) keys.add(k);
|
|
215
|
+
}
|
|
216
|
+
return [...keys];
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
case 'compareAndSet': {
|
|
220
|
+
const [, key, expected, value, o = {}] = command;
|
|
221
|
+
return spec(false, 'eval', [CAS_SET, 1, key, expected === null ? '1' : '0', expected ?? '', value, o.px === undefined ? '' : String(px(o.px))], isOne);
|
|
222
|
+
}
|
|
223
|
+
case 'compareAndDelete':
|
|
224
|
+
return spec(false, 'eval', [CAS_DEL, 1, command[1], command[2]], isOne);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const SCAN_COUNT = 200;
|
|
135
229
|
|
|
136
230
|
/**
|
|
137
231
|
* The real client. Nothing connects until the first command, and the connection
|
|
@@ -249,7 +343,8 @@ export function connectRedis(url: string, options: RedisOptions = {}): RedisClie
|
|
|
249
343
|
return await withTimeout(send(c), commandMs, 'Redis command');
|
|
250
344
|
} catch (error) {
|
|
251
345
|
const code = errorCode(error);
|
|
252
|
-
|
|
346
|
+
// A pipeline answered part way: ioredis aborts the rest ("Command aborted due to connection close").
|
|
347
|
+
const lost = code === 'CARACMS_TIMEOUT' || /connection is closed|stream isn't writeable|command timed out|aborted due to connection close/i.test(String((error as Error)?.message));
|
|
253
348
|
if (!lost) throw error; // Redis answered with an error: not ours to retry
|
|
254
349
|
drop(c);
|
|
255
350
|
if (!idempotent || Date.now() + backoffMs > deadline) {
|
|
@@ -264,45 +359,57 @@ export function connectRedis(url: string, options: RedisOptions = {}): RedisClie
|
|
|
264
359
|
}
|
|
265
360
|
};
|
|
266
361
|
|
|
362
|
+
/** One command, through `run`; one whose answer is known without asking (an MGET of no keys) isn't sent. */
|
|
363
|
+
const one = <T>(command: BatchCommand): Promise<T> => {
|
|
364
|
+
const s = specOf(command);
|
|
365
|
+
if (s.send === null) return Promise.resolve(s.answer as T);
|
|
366
|
+
return run(s.safe, async (c) => (await s.read(c, await s.send!(c as unknown as Target))) as T);
|
|
367
|
+
};
|
|
368
|
+
|
|
267
369
|
return {
|
|
268
|
-
get: (key) =>
|
|
269
|
-
mget: (keys) => (
|
|
270
|
-
set: (key, value, o
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
370
|
+
get: (key) => one(['get', key]),
|
|
371
|
+
mget: (keys) => one(['mget', keys]),
|
|
372
|
+
set: (key, value, o) => one(['set', key, value, o]),
|
|
373
|
+
getdel: (key) => one(['getdel', key]),
|
|
374
|
+
del: (...keys) => one(['del', ...keys]),
|
|
375
|
+
incr: (key) => one(['incr', key]),
|
|
376
|
+
pexpire: (key, ms) => one(['pexpire', key, ms]),
|
|
377
|
+
pttl: (key) => one(['pttl', key]),
|
|
378
|
+
hget: (key, field) => one(['hget', key, field]),
|
|
379
|
+
hset: (key, fields) => one(['hset', key, fields]),
|
|
380
|
+
hdel: (key, ...fields) => one(['hdel', key, ...fields]),
|
|
381
|
+
hgetall: (key) => one(['hgetall', key]),
|
|
382
|
+
sadd: (key, ...members) => one(['sadd', key, ...members]),
|
|
383
|
+
srem: (key, ...members) => one(['srem', key, ...members]),
|
|
384
|
+
smembers: (key) => one(['smembers', key]),
|
|
385
|
+
scan: (pattern) => one(['scan', pattern]),
|
|
386
|
+
compareAndSet: (key, expected, value, o) => one(['compareAndSet', key, expected, value, o]),
|
|
387
|
+
compareAndDelete: (key, expected) => one(['compareAndDelete', key, expected]),
|
|
388
|
+
// Redis diet Decision 7. ioredis answers a pipeline per command, `[error, reply]`, and
|
|
389
|
+
// a connection lost part way fills every unanswered one with "Connection is closed.":
|
|
390
|
+
// it can't say whether those ran (the command may have arrived, its answer not). So
|
|
391
|
+
// the batch is the unit: sent again whole when every command in it is safe to send
|
|
392
|
+
// twice, never when any isn't, and then it fails as a single unsafe command does.
|
|
393
|
+
batch: <const C extends readonly BatchCommand[]>(commands: C): Promise<BatchReplies<C>> => {
|
|
394
|
+
const specs = commands.map(specOf);
|
|
395
|
+
const sent = specs.filter((s) => s.send !== null);
|
|
396
|
+
if (sent.length === 0) return Promise.resolve(specs.map((s) => s.answer) as BatchReplies<C>);
|
|
397
|
+
return run(
|
|
398
|
+
sent.every((s) => s.safe),
|
|
399
|
+
async (c) => {
|
|
400
|
+
const pipeline = c.pipeline();
|
|
401
|
+
for (const s of sent) s.send!(pipeline as unknown as Target);
|
|
402
|
+
const results = await pipeline.exec();
|
|
403
|
+
if (!results) throw new Error('Redis gave no answer to the batch.');
|
|
404
|
+
const failed = results.find(([error]) => error);
|
|
405
|
+
if (failed) throw failed[0];
|
|
406
|
+
const out: unknown[] = [];
|
|
407
|
+
let i = 0;
|
|
408
|
+
for (const s of specs) out.push(s.send === null ? s.answer : await s.read(c, results[i++]![1]));
|
|
409
|
+
return out as BatchReplies<C>;
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
},
|
|
306
413
|
async close() {
|
|
307
414
|
if (idleTimer) clearTimeout(idleTimer);
|
|
308
415
|
idleTimer = null;
|
|
@@ -527,6 +634,21 @@ export function memoryRedis(options: { now?: () => number } = {}): MemoryRedis {
|
|
|
527
634
|
remove(key);
|
|
528
635
|
return true;
|
|
529
636
|
},
|
|
637
|
+
// Run in order, each counted; like a pipeline, every command runs and the first error is thrown after.
|
|
638
|
+
async batch<const C extends readonly BatchCommand[]>(commands: C): Promise<BatchReplies<C>> {
|
|
639
|
+
const out: unknown[] = [];
|
|
640
|
+
let failure: { error: unknown } | null = null;
|
|
641
|
+
for (const [name, ...args] of commands) {
|
|
642
|
+
try {
|
|
643
|
+
out.push(await (r[name] as (...a: unknown[]) => Promise<unknown>)(...args));
|
|
644
|
+
} catch (error) {
|
|
645
|
+
failure ??= { error };
|
|
646
|
+
out.push(null);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (failure) throw failure.error;
|
|
650
|
+
return out as BatchReplies<C>;
|
|
651
|
+
},
|
|
530
652
|
async close() {},
|
|
531
653
|
};
|
|
532
654
|
return r;
|
package/src/live/runtime.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface RuntimeOptions {
|
|
|
50
50
|
log?: (line: string) => void;
|
|
51
51
|
runningCommit?: () => string | null;
|
|
52
52
|
railwayFetch?: typeof fetch;
|
|
53
|
+
/** Tests: the deploy hook's fetch. */
|
|
54
|
+
deployHookFetch?: typeof fetch;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** Build and start a runtime (not shared: tests make their own). */
|
|
@@ -69,8 +71,9 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
|
|
|
69
71
|
backend = memory;
|
|
70
72
|
} else {
|
|
71
73
|
const tracker = new Tracker(now);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
+
const gh = settings.github;
|
|
75
|
+
const connection = gh.kind === 'token' ? { token: gh.token, owner: gh.owner, repo: gh.repo, now } : { appId: gh.appId, privateKey: gh.privateKey, owner: gh.owner, repo: gh.repo, now };
|
|
76
|
+
backend = new GitHubBackend(new GitHubClient(connection, tracker), settings.branches);
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
const { main, drafts } = backend.branches;
|
|
@@ -86,7 +89,12 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
|
|
|
86
89
|
log,
|
|
87
90
|
runningCommit: options.runningCommit,
|
|
88
91
|
timings: settings.timings,
|
|
89
|
-
|
|
92
|
+
bakedCommit: settings.bakedCommit,
|
|
93
|
+
redeployer: settings.deployHook
|
|
94
|
+
? { kind: 'hook', url: settings.deployHook, fetch: options.deployHookFetch }
|
|
95
|
+
: settings.railway
|
|
96
|
+
? { kind: 'railway', ...settings.railway, fetch: options.railwayFetch }
|
|
97
|
+
: null,
|
|
90
98
|
});
|
|
91
99
|
// The copy only: the startup check waits for the live editor (editor.ts), which fills
|
|
92
100
|
// `commitPending` first, so a merge from main never runs ahead of pending autosaves (Decision 8).
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status poll's write-through cache (Redis diet Decisions 1–5).
|
|
3
|
+
*
|
|
4
|
+
* Every Redis write a site makes goes through its one server, so what the status route
|
|
5
|
+
* reads can be kept here and updated (or dropped) by the writes themselves, instead of
|
|
6
|
+
* being asked of Redis on every poll.
|
|
7
|
+
*
|
|
8
|
+
* - **What it holds is Redis as last read or written here, by key**: strings, hashes,
|
|
9
|
+
* sets, and scans (every key under a prefix, `caracms:autosave:`, with its value).
|
|
10
|
+
* Records, never views made from them: whether a lock holds, or which notices are a
|
|
11
|
+
* person's, is worked out per poll (Watch-outs).
|
|
12
|
+
* - **Only the status route reads it** (Decision 3). Writes, create, delete, move, the
|
|
13
|
+
* sweep, Publish and sign-in read Redis directly.
|
|
14
|
+
* - **An entry is re-read once it's `statusCacheMs` old, counted from its last Redis
|
|
15
|
+
* read** (Decision 1): a write-through keeps it current but doesn't make it younger, so
|
|
16
|
+
* what bypasses this server (the other server during a deploy, a key expiring, a hand
|
|
17
|
+
* edit) shows within that time (Decision 4).
|
|
18
|
+
* - **The writes are the modules'** (Decision 2): autosaves.ts, locks.ts, git.ts and
|
|
19
|
+
* notices.ts tell it each write as it lands. A write it can apply exactly (a value just
|
|
20
|
+
* set, a field just written) updates the entry; anything else (a compare-and-swap that
|
|
21
|
+
* lost, so the value is unknown) drops it. A write to an entry not held is only noted.
|
|
22
|
+
* - **Refreshes are single-flight** (Decision 5): a poll needing an entry another poll is
|
|
23
|
+
* already reading waits for that read. The ones it's missing go in one batch (SCANs
|
|
24
|
+
* with the rest, then their MGETs). A refresh that fails caches nothing, and nothing
|
|
25
|
+
* refreshes on a timer: with no poll there's no Redis read, so Redis can sleep.
|
|
26
|
+
* - **A write that lands while an entry is being read** may be missing from what the
|
|
27
|
+
* read brings back: that entry is kept for the polls waiting on it, but as already
|
|
28
|
+
* old, so the next poll reads it again.
|
|
29
|
+
*/
|
|
30
|
+
import type { BatchCommand, RedisClient } from './redis.ts';
|
|
31
|
+
|
|
32
|
+
type Entry =
|
|
33
|
+
| { kind: 'string'; value: string | null; at: number }
|
|
34
|
+
| { kind: 'hash'; value: Map<string, string>; at: number }
|
|
35
|
+
| { kind: 'set'; value: Set<string>; at: number }
|
|
36
|
+
| { kind: 'scan'; value: Map<string, string>; at: number };
|
|
37
|
+
|
|
38
|
+
type Kind = Entry['kind'];
|
|
39
|
+
|
|
40
|
+
/** What a poll reads: keys by kind, and scans by key prefix (`caracms:lock:`). */
|
|
41
|
+
export interface StatusReads {
|
|
42
|
+
scans?: readonly string[];
|
|
43
|
+
hashes?: readonly string[];
|
|
44
|
+
sets?: readonly string[];
|
|
45
|
+
strings?: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One poll's view of the cache. Its entries are the cache's own, so the poll's later write-throughs show in it. */
|
|
49
|
+
export interface StatusSnapshot {
|
|
50
|
+
/** Every key under the prefix, with its value. */
|
|
51
|
+
scan(prefix: string): ReadonlyMap<string, string>;
|
|
52
|
+
hash(key: string): ReadonlyMap<string, string>;
|
|
53
|
+
set(key: string): ReadonlySet<string>;
|
|
54
|
+
string(key: string): string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const name = (kind: Kind, key: string) => `${kind} ${key}`;
|
|
58
|
+
|
|
59
|
+
export class StatusCache {
|
|
60
|
+
private readonly redis: RedisClient;
|
|
61
|
+
private readonly maxAgeMs: number;
|
|
62
|
+
private readonly now: () => number;
|
|
63
|
+
private readonly entries = new Map<string, Entry>();
|
|
64
|
+
private readonly reading = new Map<string, Promise<Map<string, Entry>>>();
|
|
65
|
+
/** Write-throughs so far, and the count at each entry's latest (for a read that a write overtook). */
|
|
66
|
+
private writes = 0;
|
|
67
|
+
private readonly written = new Map<string, number>();
|
|
68
|
+
|
|
69
|
+
constructor(options: { redis: RedisClient; maxAgeMs: number; now: () => number }) {
|
|
70
|
+
this.redis = options.redis;
|
|
71
|
+
this.maxAgeMs = options.maxAgeMs;
|
|
72
|
+
this.now = options.now;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What a poll reads, from memory where it's fresh enough, else from Redis (one batch, shared with any poll waiting on the same). */
|
|
76
|
+
async read(reads: StatusReads): Promise<StatusSnapshot> {
|
|
77
|
+
const wanted: [Kind, string][] = [
|
|
78
|
+
...(reads.scans ?? []).map((k) => ['scan', k] as [Kind, string]),
|
|
79
|
+
...(reads.hashes ?? []).map((k) => ['hash', k] as [Kind, string]),
|
|
80
|
+
...(reads.sets ?? []).map((k) => ['set', k] as [Kind, string]),
|
|
81
|
+
...(reads.strings ?? []).map((k) => ['string', k] as [Kind, string]),
|
|
82
|
+
];
|
|
83
|
+
const now = this.now();
|
|
84
|
+
const waits = new Set<Promise<Map<string, Entry>>>();
|
|
85
|
+
const missing: [Kind, string][] = [];
|
|
86
|
+
for (const [kind, key] of wanted) {
|
|
87
|
+
const n = name(kind, key);
|
|
88
|
+
const inFlight = this.reading.get(n);
|
|
89
|
+
if (inFlight) waits.add(inFlight);
|
|
90
|
+
else {
|
|
91
|
+
const e = this.entries.get(n);
|
|
92
|
+
if (!e || now - e.at >= this.maxAgeMs) missing.push([kind, key]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (missing.length) {
|
|
96
|
+
const read = this.refresh(missing);
|
|
97
|
+
for (const [kind, key] of missing) this.reading.set(name(kind, key), read);
|
|
98
|
+
const done = () => {
|
|
99
|
+
for (const [kind, key] of missing) if (this.reading.get(name(kind, key)) === read) this.reading.delete(name(kind, key));
|
|
100
|
+
if (this.reading.size === 0) this.written.clear();
|
|
101
|
+
};
|
|
102
|
+
read.then(done, done);
|
|
103
|
+
waits.add(read);
|
|
104
|
+
}
|
|
105
|
+
const fetched = new Map<string, Entry>();
|
|
106
|
+
for (const got of await Promise.all(waits)) for (const [n, e] of got) fetched.set(n, e);
|
|
107
|
+
// The cache's own entry where it still has one (later write-throughs land in it), else what the read brought back,
|
|
108
|
+
// pinned now: a drop while the poll is still in its writes leaves it the records it read, never none.
|
|
109
|
+
const pinned = new Map<string, Entry | undefined>(wanted.map(([kind, key]) => [name(kind, key), this.entries.get(name(kind, key)) ?? fetched.get(name(kind, key))]));
|
|
110
|
+
const pick = (kind: Kind, key: string) => pinned.get(name(kind, key));
|
|
111
|
+
return {
|
|
112
|
+
scan: (prefix) => (pick('scan', prefix)?.value as Map<string, string> | undefined) ?? new Map(),
|
|
113
|
+
hash: (key) => (pick('hash', key)?.value as Map<string, string> | undefined) ?? new Map(),
|
|
114
|
+
set: (key) => (pick('set', key)?.value as Set<string> | undefined) ?? new Set(),
|
|
115
|
+
string: (key) => (pick('string', key)?.value as string | null | undefined) ?? null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read these entries from Redis: one batch, then one more for the scans' MGETs. */
|
|
120
|
+
private async refresh(entries: [Kind, string][]): Promise<Map<string, Entry>> {
|
|
121
|
+
const started = this.writes;
|
|
122
|
+
const at = this.now();
|
|
123
|
+
const commands = entries.map(([kind, key]): BatchCommand => {
|
|
124
|
+
if (kind === 'scan') return ['scan', `${key}*`];
|
|
125
|
+
if (kind === 'hash') return ['hgetall', key];
|
|
126
|
+
if (kind === 'set') return ['smembers', key];
|
|
127
|
+
return ['get', key];
|
|
128
|
+
});
|
|
129
|
+
const replies = (await this.redis.batch(commands)) as unknown[];
|
|
130
|
+
const scanned = entries.flatMap(([kind], i) => (kind === 'scan' && (replies[i] as string[]).length ? [i] : []));
|
|
131
|
+
const values = scanned.length ? ((await this.redis.batch(scanned.map((i): BatchCommand => ['mget', replies[i] as string[]]))) as (string | null)[][]) : [];
|
|
132
|
+
const out = new Map<string, Entry>();
|
|
133
|
+
entries.forEach(([kind, key], i) => {
|
|
134
|
+
const n = name(kind, key);
|
|
135
|
+
// Written since this read began: kept for now, but already old.
|
|
136
|
+
const age = (this.written.get(n) ?? 0) > started ? -Infinity : at;
|
|
137
|
+
let entry: Entry;
|
|
138
|
+
if (kind === 'scan') {
|
|
139
|
+
const keys = replies[i] as string[];
|
|
140
|
+
const got = values[scanned.indexOf(i)] ?? [];
|
|
141
|
+
const map = new Map<string, string>();
|
|
142
|
+
keys.forEach((k, j) => {
|
|
143
|
+
const v = got[j];
|
|
144
|
+
if (v !== null && v !== undefined) map.set(k, v);
|
|
145
|
+
});
|
|
146
|
+
entry = { kind, value: map, at: age };
|
|
147
|
+
} else if (kind === 'hash') entry = { kind, value: new Map(Object.entries(replies[i] as Record<string, string>)), at: age };
|
|
148
|
+
else if (kind === 'set') entry = { kind, value: new Set(replies[i] as string[]), at: age };
|
|
149
|
+
else entry = { kind, value: replies[i] as string | null, at: age };
|
|
150
|
+
this.entries.set(n, entry);
|
|
151
|
+
out.set(n, entry);
|
|
152
|
+
});
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Write-throughs (the modules call these once a write has landed) ─────────────
|
|
157
|
+
|
|
158
|
+
/** Note a write to these entry names; return the ones held. */
|
|
159
|
+
private touch(names: string[]): Entry[] {
|
|
160
|
+
this.writes++;
|
|
161
|
+
const held: Entry[] = [];
|
|
162
|
+
for (const n of names) {
|
|
163
|
+
// Only a read in flight needs to know (`refresh`); with none, nothing is remembered.
|
|
164
|
+
if (this.reading.size) this.written.set(n, this.writes);
|
|
165
|
+
const e = this.entries.get(n);
|
|
166
|
+
if (e) held.push(e);
|
|
167
|
+
}
|
|
168
|
+
return held;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The scan entries (held or not) whose prefix this key is under. */
|
|
172
|
+
private scansOf(key: string): string[] {
|
|
173
|
+
const out: string[] = [];
|
|
174
|
+
for (const n of [...this.entries.keys(), ...this.reading.keys()]) if (n.startsWith('scan ') && key.startsWith(n.slice(5))) out.push(n);
|
|
175
|
+
return [...new Set(out)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A string key now holds `value` (null: deleted). */
|
|
179
|
+
string(key: string, value: string | null): void {
|
|
180
|
+
for (const e of this.touch([name('string', key), ...this.scansOf(key)])) {
|
|
181
|
+
if (e.kind === 'string') e.value = value;
|
|
182
|
+
else if (e.kind === 'scan') {
|
|
183
|
+
if (value === null) e.value.delete(key);
|
|
184
|
+
else e.value.set(key, value);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A hash's fields were set, or deleted. */
|
|
190
|
+
hash(key: string, set: Record<string, string>, del: readonly string[] = []): void {
|
|
191
|
+
for (const e of this.touch([name('hash', key)])) {
|
|
192
|
+
if (e.kind !== 'hash') continue;
|
|
193
|
+
for (const [f, v] of Object.entries(set)) e.value.set(f, v);
|
|
194
|
+
for (const f of del) e.value.delete(f);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Members were added to a set. */
|
|
199
|
+
add(key: string, ...members: string[]): void {
|
|
200
|
+
for (const e of this.touch([name('set', key)])) if (e.kind === 'set') for (const m of members) e.value.add(m);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The key changed in a way not known here (a compare-and-swap that lost): forget it, and any scan it's under. */
|
|
204
|
+
drop(key: string): void {
|
|
205
|
+
const names = [...(['string', 'hash', 'set'] as const).map((k) => name(k, key)), ...this.scansOf(key)];
|
|
206
|
+
this.touch(names);
|
|
207
|
+
for (const n of names) this.entries.delete(n);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Run a write to `key`; one that fails (lost in flight, so it may or may not have landed) drops the key from the cache. */
|
|
212
|
+
export async function landed<T>(cache: StatusCache | null, key: string, write: () => Promise<T>): Promise<T> {
|
|
213
|
+
try {
|
|
214
|
+
return await write();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
cache?.drop(key);
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/live/status.ts
CHANGED
|
@@ -62,7 +62,11 @@ export interface PublishRecord {
|
|
|
62
62
|
tryAgainAt: string | null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* `slow` (runs-anywhere Decision 10): still not live well after it was made, or after its
|
|
67
|
+
* last Try again, with no failure known: "taking longer than usual. It may have failed."
|
|
68
|
+
*/
|
|
69
|
+
export type PublishState = 'publishing' | 'live' | 'failed' | 'slow';
|
|
66
70
|
|
|
67
71
|
/** One GitHub Deployment and its newest status (null: no status yet). */
|
|
68
72
|
export interface DeploymentFact {
|
|
@@ -76,7 +80,7 @@ const FINAL = new Set(['success', 'failure', 'error', 'inactive']);
|
|
|
76
80
|
|
|
77
81
|
export interface PublishFacts {
|
|
78
82
|
record: PublishRecord;
|
|
79
|
-
/** The running server's commit (
|
|
83
|
+
/** The running server's commit (mode.ts → `runningCommitFrom`), or null with none known. */
|
|
80
84
|
running: string | null;
|
|
81
85
|
/** Is `running` the publish commit or a descendant of it? null when not known (not asked). */
|
|
82
86
|
runningIncludesPublish: boolean | null;
|
|
@@ -84,6 +88,9 @@ export interface PublishFacts {
|
|
|
84
88
|
laterOnMain: ReadonlySet<string>;
|
|
85
89
|
/** The repo's deployments, any order. */
|
|
86
90
|
deployments: DeploymentFact[];
|
|
91
|
+
/** Now (ms), and how long a publish may take before it reads as `slow` (`LiveTimings.publishSlowMs`). Either unset: never slow. */
|
|
92
|
+
now?: number;
|
|
93
|
+
slowAfterMs?: number;
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/**
|
|
@@ -94,6 +101,10 @@ export interface PublishFacts {
|
|
|
94
101
|
* - **failed**: the newest deployment that has *answered*, for the publish commit or
|
|
95
102
|
* a later commit on `main` (since the last Try again), says `failure` or `error`.
|
|
96
103
|
* A deployment stuck `in_progress` is never read as finished or failed.
|
|
104
|
+
* - **slow** (runs-anywhere Decision 10): not live and not failed, `slowAfterMs` after the
|
|
105
|
+
* publish was made, or after its last Try again (`tryAgainAt`). Without deployments a
|
|
106
|
+
* failure can never be known, and on Railway a skipped deploy stays `in_progress` for
|
|
107
|
+
* good; a running commit that isn't in the repo never includes the publish either.
|
|
97
108
|
* - **publishing**: otherwise.
|
|
98
109
|
*/
|
|
99
110
|
export function publishState(f: PublishFacts): { state: PublishState; assumed: boolean } {
|
|
@@ -105,5 +116,6 @@ export function publishState(f: PublishFacts): { state: PublishState; assumed: b
|
|
|
105
116
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
106
117
|
const newest = answered[0];
|
|
107
118
|
if (newest && (newest.state === 'failure' || newest.state === 'error')) return { state: 'failed', assumed: false };
|
|
119
|
+
if (f.now !== undefined && f.slowAfterMs !== undefined && f.now - Date.parse(f.record.tryAgainAt ?? f.record.at) >= f.slowAfterMs) return { state: 'slow', assumed: false };
|
|
108
120
|
return { state: 'publishing', assumed: false };
|
|
109
121
|
}
|
package/src/live/timings.ts
CHANGED
|
@@ -29,6 +29,22 @@ export interface LiveTimings {
|
|
|
29
29
|
autosaveTtlMs: number;
|
|
30
30
|
/** Decision 13: each tab polls the status this often (the editor's POLL_MS; /admin passes it to the page). */
|
|
31
31
|
pollMs: number;
|
|
32
|
+
/** Runs-anywhere Decision 10: a publish not live this long after it was made (or its last Try again) reads as taking longer than usual. */
|
|
33
|
+
publishSlowMs: number;
|
|
34
|
+
/** Runs-anywhere (Watch-outs): Publish's request answers by this long, well inside Cloudflare's 100 s; the Publish carries on past it. */
|
|
35
|
+
publishAnswerMs: number;
|
|
36
|
+
/** Redis diet Decision 1: the status poll's cached reads are re-read from Redis once this old (counted from the last Redis read). */
|
|
37
|
+
statusCacheMs: number;
|
|
38
|
+
/** Redis diet Decision 6: a tab's heartbeat goes only once its lock's last beat is this old (or to take the lock back). */
|
|
39
|
+
beatEveryMs: number;
|
|
40
|
+
/** Redis diet Decision 8: a visible tab with no input this long polls at `slowPollMs` (/admin passes it to the page). */
|
|
41
|
+
idleSlowAfterMs: number;
|
|
42
|
+
/** Redis diet Decision 8: an idle tab's poll interval (/admin passes it to the page). */
|
|
43
|
+
slowPollMs: number;
|
|
44
|
+
/** Redis diet Decision 10: once the tab holds the document, a keep goes this long after the last change (/admin passes it to the page). */
|
|
45
|
+
keepDelayMs: number;
|
|
46
|
+
/** Redis diet Decision 10: …and never later than this after the first change not yet sent (/admin passes it to the page). */
|
|
47
|
+
keepCapMs: number;
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
export const DEFAULT_TIMINGS: LiveTimings = {
|
|
@@ -44,4 +60,12 @@ export const DEFAULT_TIMINGS: LiveTimings = {
|
|
|
44
60
|
presenceMs: 15_000,
|
|
45
61
|
autosaveTtlMs: 7 * 24 * 60 * 60_000,
|
|
46
62
|
pollMs: 5000,
|
|
63
|
+
publishSlowMs: 15 * 60_000,
|
|
64
|
+
publishAnswerMs: 60_000,
|
|
65
|
+
statusCacheMs: 30_000,
|
|
66
|
+
beatEveryMs: 30_000,
|
|
67
|
+
idleSlowAfterMs: 2 * 60_000,
|
|
68
|
+
slowPollMs: 30_000,
|
|
69
|
+
keepDelayMs: 3000,
|
|
70
|
+
keepCapMs: 15_000,
|
|
47
71
|
};
|
package/src/media.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { createHmac } from 'node:crypto';
|
|
27
27
|
import type { ImageFormat } from './fields.ts';
|
|
28
|
-
import type
|
|
28
|
+
import { renderMedia, type RenderImage } from './media-value.ts';
|
|
29
29
|
|
|
30
30
|
/** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
|
|
31
31
|
export const IMAGE_WIDTHS: readonly number[] = [160, 320, 480, 640, 960, 1280, 1920];
|
|
@@ -253,3 +253,28 @@ export function brandImageUrl(key: string, cssHeight: number, config: MediaConfi
|
|
|
253
253
|
if (config.kind !== 'ready') return null;
|
|
254
254
|
return imageUrl(config.settings, key, { height: cssHeight * 2 });
|
|
255
255
|
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Urban Return port, Decision 20: one URL for a Site Settings image, for the page's frame
|
|
259
|
+
* (a header, a footer, a layout), which has no block's `cms` for `<Media>`. Exported from
|
|
260
|
+
* `caracms/content`:
|
|
261
|
+
*
|
|
262
|
+
* const settings = await siteSettings(site, Astro.locals);
|
|
263
|
+
* const url = settingsImageUrl(settings.megaPhoto, { width: 640, height: 400 });
|
|
264
|
+
*
|
|
265
|
+
* `value` is the field as `siteSettings` gives it (resolved, M9), or the stored value. The
|
|
266
|
+
* rules are `<Media>`'s: the signed imgproxy URL, `fit` to one side, `fill` to both with the
|
|
267
|
+
* focal point's gravity, a GIF or an SVG passed through unresized, WebP; under
|
|
268
|
+
* `CARACMS_MEDIA=memory` the in-memory media's URL. No size: `fit` at the ladder's top rung.
|
|
269
|
+
* Null for an empty value (or a clip, or a file's key) and when the media isn't set up.
|
|
270
|
+
*/
|
|
271
|
+
export function settingsImageUrl(value: unknown, options: { width?: number; height?: number } = {}): string | null {
|
|
272
|
+
const image = renderMedia(value, { only: 'image' });
|
|
273
|
+
if (!image || image.kind !== 'image') return null;
|
|
274
|
+
const config = readMediaConfig();
|
|
275
|
+
if (config.kind !== 'ready') return null;
|
|
276
|
+
const width = options.width ?? 0;
|
|
277
|
+
const height = options.height ?? 0;
|
|
278
|
+
const sized = width > 0 || height > 0 ? { width, height } : { width: IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1]! };
|
|
279
|
+
return imageUrl(config.settings, image.key, { ...sized, focal: image.focal });
|
|
280
|
+
}
|