caracms 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) 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/documents/actions.ts +6 -4
  14. package/src/editor/client/documents/dialogs.tsx +2 -1
  15. package/src/editor/client/editor.ts +24 -6
  16. package/src/editor/client/live/publish.tsx +20 -10
  17. package/src/editor/client/pane/parts.tsx +34 -2
  18. package/src/editor/client/state/keeper.ts +51 -6
  19. package/src/editor/client/state/live.ts +45 -6
  20. package/src/editor/middleware.ts +3 -2
  21. package/src/editor/routes/try-again.ts +2 -1
  22. package/src/editor/server/admin-props.ts +2 -1
  23. package/src/editor/server/mode.ts +13 -3
  24. package/src/editor/styles/levels/live.css +1 -1
  25. package/src/editor/types.ts +25 -6
  26. package/src/files-route.ts +9 -4
  27. package/src/forms/endpoint.ts +11 -16
  28. package/src/head.ts +10 -1
  29. package/src/index.ts +53 -7
  30. package/src/live/README.md +4 -2
  31. package/src/live/auth/auth.ts +4 -1
  32. package/src/live/auth/guard.ts +3 -2
  33. package/src/live/auth/index.ts +6 -7
  34. package/src/live/auth/login.ts +17 -8
  35. package/src/live/auth/middleware.ts +7 -3
  36. package/src/live/auth/request.ts +92 -11
  37. package/src/live/auth/views.ts +9 -4
  38. package/src/live/autosaves.ts +59 -16
  39. package/src/live/backend.ts +13 -1
  40. package/src/live/drafts-store.ts +4 -3
  41. package/src/live/editor.ts +92 -67
  42. package/src/live/git.ts +112 -34
  43. package/src/live/github.ts +128 -13
  44. package/src/live/locks.ts +92 -26
  45. package/src/live/mode.ts +131 -18
  46. package/src/live/notices.ts +100 -0
  47. package/src/live/redis.ts +161 -39
  48. package/src/live/runtime.ts +11 -3
  49. package/src/live/status-cache.ts +219 -0
  50. package/src/live/status.ts +14 -2
  51. package/src/live/timings.ts +24 -0
  52. package/src/media.ts +26 -1
package/src/live/mode.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Which mode the editor runs in, read from the environment (M4 Decision 1).
3
3
  *
4
- * - **Local mode**: `astro dev` with no `GITHUB_APP_ID`. M3's editor, files on disk.
5
- * - **Live mode**: whenever the App is configured, and **always on a production
6
- * server**. It needs `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, the repo
7
- * (`RAILWAY_GIT_REPO_OWNER` and `RAILWAY_GIT_REPO_NAME`, or `GITHUB_REPO=owner/name`;
8
- * Decision 3), `CARACMS_SECRET`, and `REDIS_URL` in production (dev may use the
9
- * stand-in). A production server missing any of them still serves the public site;
10
- * `/admin` answers 503 naming them (Phase 2 builds that from `missing`).
4
+ * - **Local mode**: `astro dev` with no GitHub credentials (`GITHUB_APP_ID` or
5
+ * `CARACMS_GITHUB_TOKEN`). M3's editor, files on disk.
6
+ * - **Live mode**: whenever a credential is configured, and **always on a production
7
+ * server**. It needs one GitHub credential (a fine-grained token, or the App's
8
+ * `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`; runs-anywhere Decisions 3–4), the repo
9
+ * (`GITHUB_REPO=owner/name`, or Railway's `RAILWAY_GIT_REPO_OWNER` and
10
+ * `RAILWAY_GIT_REPO_NAME`; Decision 6), `CARACMS_SECRET`, `REDIS_URL` and the site's
11
+ * `https://` address in production (dev may use the stand-in Redis). A production
12
+ * server missing any of them still serves the public site; `/admin` answers 503
13
+ * naming them (Phase 2 builds that from `missing`).
11
14
  * - **The in-memory GitHub** (Decision 2): `CARACMS_GITHUB=memory`, for tests and local
12
15
  * browser runs, never against the site's own repo. It's live mode with no App and
13
16
  * no repo, and it's **refused in production**.
@@ -19,9 +22,10 @@ import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
19
22
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
20
23
 
21
24
  export interface LiveSettings {
22
- /** `memory`: the in-memory GitHub backend (Decision 2). */
25
+ /** `memory`: the in-memory GitHub backend (Decision 2). `token`: CARACMS_GITHUB_TOKEN (runs-anywhere Decision 3). */
23
26
  github:
24
27
  | { kind: 'app'; appId: string; privateKey: string; owner: string; repo: string }
28
+ | { kind: 'token'; token: string; owner: string; repo: string }
25
29
  | { kind: 'memory' };
26
30
  /** Null in dev without REDIS_URL: the in-memory stand-in. */
27
31
  redisUrl: string | null;
@@ -31,12 +35,22 @@ export interface LiveSettings {
31
35
  allow: string[];
32
36
  /** GITHUB_WEBHOOK_SECRET; null means the webhook route answers 404 (Decision 14). */
33
37
  webhookSecret: string | null;
34
- /** Try again (Decision 12): all three, or null. */
38
+ /** Try again through Railway's API (Decision 12): all three, or null. */
35
39
  railway: { token: string; serviceId: string; environmentId: string } | null;
40
+ /** Try again through a deploy hook (runs-anywhere Decision 11): CARACMS_DEPLOY_HOOK_URL. A secret: never logged or sent to the browser. */
41
+ deployHook?: string | null;
42
+ /**
43
+ * The site's public origin (runs-anywhere Decision 7): Astro's `site` (baked at build,
44
+ * SITE_URL at build included), then SITE_URL, then RAILWAY_PUBLIC_DOMAIN. Never null
45
+ * in production live mode; missing (a test's settings) means unknown.
46
+ */
47
+ address?: string | null;
36
48
  /** CARACMS_BACKUP_USER and CARACMS_BACKUP_PASSWORD, both or neither (Phase 3 checks the length). */
37
49
  backup: { user: string; password: string } | null;
38
- /** CARACMS_RUNNING_COMMIT, else RAILWAY_GIT_COMMIT_SHA; null with no deploy pipeline (Decision 12). */
50
+ /** The running commit at start (runs-anywhere Decision 9, `runningCommitFrom`); null with none known. */
39
51
  runningCommit: string | null;
52
+ /** The commit baked in at build (`Baked.commit`); status reads the running commit afresh from it and the environment. */
53
+ bakedCommit?: string | null;
40
54
  /** Dev only: CARACMS_TIMINGS, a JSON object of LiveTimings to shorten (Decision 2). */
41
55
  timings: LiveTimings;
42
56
  /**
@@ -56,17 +70,90 @@ export type Mode =
56
70
 
57
71
  type Env = Record<string, string | undefined>;
58
72
 
73
+ /** What the build baked in (the integration's defines; editor/server/mode.ts passes them). */
74
+ export interface Baked {
75
+ /** Astro's `site` at build: the config's, or SITE_URL's (M6 Decision 3). */
76
+ site?: string | null;
77
+ /** The commit the build was made from (runs-anywhere Decision 9). */
78
+ commit?: string | null;
79
+ }
80
+
81
+ // The sentences the configuration problems print, kept here so the docs can quote them.
82
+ export const BOTH_CREDENTIALS_PROBLEM =
83
+ 'Both a GitHub App (GITHUB_APP_ID) and a token (CARACMS_GITHUB_TOKEN) are set: set one of them, not both.';
84
+ export const CLASSIC_TOKEN_PROBLEM =
85
+ 'CARACMS_GITHUB_TOKEN is a classic token, which reaches every repo its owner can: use a fine-grained token (it starts with github_pat_) with access to this site\'s repo only.';
86
+ export const NOT_FINE_GRAINED_PROBLEM = 'CARACMS_GITHUB_TOKEN must be a fine-grained token: it starts with github_pat_.';
87
+ export const NO_ADDRESS_PROBLEM =
88
+ "The site's public address isn't known: set SITE_URL to it, like https://fernhill.ie, both when the site is built and when it runs. Without it, sign-in links can't be sent and the editor's saves are refused.";
89
+ export const HTTP_ADDRESS_PROBLEM = (address: string) =>
90
+ `The site's address (${address}) is http://, but signing in needs https://: the session cookie is only ever sent over https. Set SITE_URL to the https:// address (http:// works for localhost only).`;
91
+ export const BOTH_REDEPLOYERS_PROBLEM =
92
+ 'Both CARACMS_DEPLOY_HOOK_URL and CARACMS_RAILWAY_TOKEN are set: Try again redeploys through one of them, so set only one.';
93
+
94
+ /** The prefixes of GitHub's classic tokens (`ghp_` and the OAuth, user-to-server, server-to-server and refresh ones). */
95
+ const CLASSIC_TOKEN = /^(ghp|gho|ghu|ghs|ghr)_/;
96
+
97
+ /** On Railway: it sets these on every service (the gate detects the host the same way). */
98
+ const onRailway = (env: Env) => Boolean(env.RAILWAY_ENVIRONMENT_ID?.trim() || env.RAILWAY_GIT_REPO_OWNER?.trim() || env.RAILWAY_GIT_REPO_NAME?.trim());
99
+
100
+ /**
101
+ * The hosts' commit variables, in order (runs-anywhere Decision 9): Railway's, Render's,
102
+ * Coolify's. The build bakes the first one set (or `git rev-parse HEAD`).
103
+ */
104
+ export const HOST_COMMIT_VARIABLES = ['RAILWAY_GIT_COMMIT_SHA', 'RENDER_GIT_COMMIT', 'SOURCE_COMMIT'] as const;
105
+
106
+ /**
107
+ * The running commit (runs-anywhere Decision 9): CARACMS_RUNNING_COMMIT, then a host's
108
+ * variable, then the commit baked in at build. A value at run time wins over the baked
109
+ * one; the in-memory GitHub ignores the baked one (it's this repo's, which the in-memory
110
+ * repo never has). Null with none known.
111
+ */
112
+ export function runningCommitFrom(env: Env, baked: string | null | undefined): string | null {
113
+ for (const name of ['CARACMS_RUNNING_COMMIT', ...HOST_COMMIT_VARIABLES]) {
114
+ const v = env[name]?.trim();
115
+ if (v) return v;
116
+ }
117
+ if (env.CARACMS_GITHUB?.trim() === 'memory') return null;
118
+ return baked?.trim() || null;
119
+ }
120
+
121
+ /**
122
+ * The site's public origin (runs-anywhere Decision 7): Astro's `site` as built, then
123
+ * SITE_URL, then Railway's public domain. Null when none is set or SITE_URL doesn't parse
124
+ * (the build already warned).
125
+ */
126
+ export function addressFrom(env: Env, bakedSite: string | null | undefined): string | null {
127
+ for (const raw of [bakedSite, env.SITE_URL]) {
128
+ const v = raw?.trim();
129
+ if (!v) continue;
130
+ try {
131
+ const url = new URL(v);
132
+ if (url.protocol === 'https:' || url.protocol === 'http:') return url.origin;
133
+ } catch {
134
+ /* the next one */
135
+ }
136
+ }
137
+ const railway = env.RAILWAY_PUBLIC_DOMAIN?.trim();
138
+ return railway ? `https://${railway}` : null;
139
+ }
140
+
141
+ const isLocalhost = (host: string) => host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1' || host === '[::1]';
142
+
59
143
  /**
60
144
  * Read the mode. `production` is whether this is a production server (the
61
145
  * integration knows: not `astro dev`); it defaults to `NODE_ENV === 'production'`.
146
+ * `baked` is what the build baked in (the address and the commit).
62
147
  */
63
- export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production'): Mode {
148
+ export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production', baked: Baked = {}): Mode {
64
149
  const get = (name: string) => {
65
150
  const v = env[name]?.trim();
66
151
  return v ? v : null;
67
152
  };
68
153
  const memory = get('CARACMS_GITHUB') === 'memory';
69
- if (!production && !memory && !get('GITHUB_APP_ID')) return { kind: 'local' };
154
+ // Never GITHUB_TOKEN (Decision 4): the gh CLI and GitHub Actions export it.
155
+ const ghToken = get('CARACMS_GITHUB_TOKEN');
156
+ if (!production && !memory && !get('GITHUB_APP_ID') && !ghToken) return { kind: 'local' };
70
157
 
71
158
  const missing: string[] = [];
72
159
  const problems: string[] = [];
@@ -82,15 +169,38 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
82
169
 
83
170
  let github: LiveSettings['github'] = { kind: 'memory' };
84
171
  if (!memory || production) {
85
- const appId = need('GITHUB_APP_ID');
86
- const privateKey = need('GITHUB_APP_PRIVATE_KEY');
172
+ const appId = get('GITHUB_APP_ID');
87
173
  const repo = readRepo(env, problems);
88
- if (!repo) missing.push('RAILWAY_GIT_REPO_OWNER and RAILWAY_GIT_REPO_NAME (or GITHUB_REPO)');
89
- github = { kind: 'app', appId, privateKey, owner: repo?.owner ?? '', repo: repo?.repo ?? '' };
174
+ const owner = repo?.owner ?? '';
175
+ const name = repo?.repo ?? '';
176
+ if (appId && ghToken) problems.push(BOTH_CREDENTIALS_PROBLEM);
177
+ if (ghToken) {
178
+ if (CLASSIC_TOKEN.test(ghToken)) problems.push(CLASSIC_TOKEN_PROBLEM);
179
+ else if (!ghToken.startsWith('github_pat_')) problems.push(NOT_FINE_GRAINED_PROBLEM);
180
+ github = { kind: 'token', token: ghToken, owner, repo: name };
181
+ } else if (appId) {
182
+ github = { kind: 'app', appId, privateKey: need('GITHUB_APP_PRIVATE_KEY'), owner, repo: name };
183
+ } else {
184
+ missing.push('CARACMS_GITHUB_TOKEN (or GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY)');
185
+ }
186
+ if (!repo && !problems.some((p) => p.startsWith('GITHUB_REPO'))) {
187
+ missing.push(onRailway(env) ? 'RAILWAY_GIT_REPO_OWNER and RAILWAY_GIT_REPO_NAME (or GITHUB_REPO)' : 'GITHUB_REPO');
188
+ }
90
189
  }
91
190
  const secret = need('CARACMS_SECRET');
92
191
  const redisUrl = production ? need('REDIS_URL') : get('REDIS_URL');
93
192
 
193
+ // The address (Decision 7): known, and https:// (but for localhost), in production.
194
+ const address = addressFrom(env, baked.site);
195
+ if (production && !address) problems.push(NO_ADDRESS_PROBLEM);
196
+ if (production && address && address.startsWith('http:') && !isLocalhost(new URL(address).hostname)) problems.push(HTTP_ADDRESS_PROBLEM(address));
197
+
198
+ const deployHook = get('CARACMS_DEPLOY_HOOK_URL');
199
+ if (deployHook) {
200
+ if (get('CARACMS_RAILWAY_TOKEN')) problems.push(BOTH_REDEPLOYERS_PROBLEM);
201
+ if (!/^https?:\/\//i.test(deployHook)) problems.push('CARACMS_DEPLOY_HOOK_URL must be the hook\'s https:// address.');
202
+ }
203
+
94
204
  const token = get('CARACMS_RAILWAY_TOKEN');
95
205
  const serviceId = get('RAILWAY_SERVICE_ID');
96
206
  const environmentId = get('RAILWAY_ENVIRONMENT_ID');
@@ -119,8 +229,11 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
119
229
  .filter(Boolean),
120
230
  webhookSecret: get('GITHUB_WEBHOOK_SECRET'),
121
231
  railway: token && serviceId && environmentId ? { token, serviceId, environmentId } : null,
232
+ deployHook,
233
+ address,
122
234
  backup: user && password ? { user, password } : null,
123
- runningCommit: get('CARACMS_RUNNING_COMMIT') ?? get('RAILWAY_GIT_COMMIT_SHA'),
235
+ runningCommit: runningCommitFrom(env, baked.commit),
236
+ bakedCommit: baked.commit ?? null,
124
237
  timings,
125
238
  branches,
126
239
  production,
@@ -164,7 +277,7 @@ function isBranchName(name: string): boolean {
164
277
  );
165
278
  }
166
279
 
167
- /** The repo: GITHUB_REPO overrides Railway's own variables (Decision 3). */
280
+ /** The repo: GITHUB_REPO overrides Railway's own variables (M4 Decision 3; runs-anywhere Decision 6). */
168
281
  function readRepo(env: Env, problems: string[]): { owner: string; repo: string } | null {
169
282
  const override = env.GITHUB_REPO?.trim();
170
283
  if (override) {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The editor's notices in Redis (M4 Decision 7 and 10 step 1), in one place so the
3
+ * status cache hears of every write (Redis diet Decision 2).
4
+ *
5
+ * - **"Published as it stood"** (`caracms:notices`, a hash by notice id): Publish leaves
6
+ * one for each person who had a published document open; dismissing it deletes it.
7
+ * - **Dismissed "draft kept" notices** (`caracms:dismissed`, a set of ids): those are
8
+ * shared, worked out from git (`git.keptDraftNotices()`), so a dismissal is remembered
9
+ * here; a new conflict has a new id and shows again.
10
+ *
11
+ * Both expire a week after their last write (`autosaveTtlMs`), as a backstop.
12
+ */
13
+ import type { NoticeView } from '../editor/types.ts';
14
+ import type { Person } from './messages.ts';
15
+ import type { RedisClient } from './redis.ts';
16
+ import { landed, type StatusCache } from './status-cache.ts';
17
+ import type { LiveTimings } from './timings.ts';
18
+
19
+ /** "Aoife published Home as it stood at 14:20", for one person. */
20
+ export interface StoodNotice {
21
+ id: string;
22
+ /** The person it's for, lower-cased. */
23
+ for: string;
24
+ file: string;
25
+ title: string;
26
+ by: Person;
27
+ at: string;
28
+ }
29
+
30
+ export class Notices {
31
+ readonly redis: RedisClient;
32
+ readonly timings: LiveTimings;
33
+ private readonly cache: StatusCache | null;
34
+ readonly noticesKey: string;
35
+ readonly dismissedKey: string;
36
+
37
+ constructor(redis: RedisClient, timings: LiveTimings, prefix = 'caracms:', cache: StatusCache | null = null) {
38
+ this.redis = redis;
39
+ this.timings = timings;
40
+ this.cache = cache;
41
+ this.noticesKey = `${prefix}notices`;
42
+ this.dismissedKey = `${prefix}dismissed`;
43
+ }
44
+
45
+ /** Leave these "published as it stood" notices. */
46
+ async addStood(notices: readonly StoodNotice[]): Promise<void> {
47
+ if (notices.length === 0) return;
48
+ const fields = Object.fromEntries(notices.map((n) => [n.id, JSON.stringify(n)]));
49
+ await landed(this.cache, this.noticesKey, () => this.redis.hset(this.noticesKey, fields));
50
+ this.cache?.hash(this.noticesKey, fields);
51
+ await this.redis.pexpire(this.noticesKey, this.timings.autosaveTtlMs);
52
+ }
53
+
54
+ /** A "published as it stood" notice is done with. */
55
+ async remove(id: string): Promise<void> {
56
+ await landed(this.cache, this.noticesKey, () => this.redis.hdel(this.noticesKey, id));
57
+ this.cache?.hash(this.noticesKey, {}, [id]);
58
+ }
59
+
60
+ /** A shared "draft kept" notice was dismissed. */
61
+ async dismiss(id: string): Promise<void> {
62
+ await landed(this.cache, this.dismissedKey, () => this.redis.sadd(this.dismissedKey, id));
63
+ this.cache?.add(this.dismissedKey, id);
64
+ await this.redis.pexpire(this.dismissedKey, this.timings.autosaveTtlMs);
65
+ }
66
+
67
+ /** This person's notices, read from Redis now. `kept` and `titleFor` are git's (`keptDraftNotices`, `titleFor`). */
68
+ async of(person: Person, kept: readonly { file: string; commit: string }[], titleFor: (file: string) => string): Promise<NoticeView[]> {
69
+ const [dismissed, stood] = await this.redis.batch([
70
+ ['smembers', this.dismissedKey],
71
+ ['hgetall', this.noticesKey],
72
+ ] as const);
73
+ return Notices.forPerson(person, kept, titleFor, new Set(dismissed), Object.values(stood));
74
+ }
75
+
76
+ /** This person's notices from what was read: the dismissed ids, and the stood notices' values. */
77
+ static forPerson(
78
+ person: Person,
79
+ kept: readonly { file: string; commit: string }[],
80
+ titleFor: (file: string) => string,
81
+ dismissed: ReadonlySet<string>,
82
+ stood: Iterable<string>,
83
+ ): NoticeView[] {
84
+ const out: NoticeView[] = [];
85
+ for (const n of kept) {
86
+ const id = `kept:${n.file}:${n.commit}`;
87
+ if (!dismissed.has(id)) out.push({ id, kind: 'draft-kept', file: n.file, title: titleFor(n.file) });
88
+ }
89
+ const email = person.email.toLowerCase();
90
+ for (const raw of stood) {
91
+ try {
92
+ const n = JSON.parse(raw) as StoodNotice;
93
+ if (n.for === email) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
94
+ } catch {
95
+ /* skip */
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ }
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
- const lost = code === 'CARACMS_TIMEOUT' || /connection is closed|stream isn't writeable|command timed out/i.test(String((error as Error)?.message));
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) => run(true, (c) => c.get(key)),
269
- mget: (keys) => (keys.length ? run(true, (c) => c.mget(...keys)) : Promise.resolve([])),
270
- set: (key, value, o = {}) =>
271
- run(!o.nx, async (c) => {
272
- const args: (string | number)[] = [];
273
- if (o.px !== undefined) args.push('PX', Math.max(1, Math.round(o.px)));
274
- if (o.nx) args.push('NX');
275
- const reply = await (c.set as (...a: unknown[]) => Promise<string | null>)(key, value, ...args);
276
- return reply === 'OK';
277
- }),
278
- getdel: (key) => run(false, (c) => c.getdel(key)),
279
- del: (...keys) => (keys.length ? run(true, (c) => c.del(...keys)) : Promise.resolve(0)),
280
- incr: (key) => run(false, (c) => c.incr(key)),
281
- pexpire: (key, ms) => run(true, async (c) => (await c.pexpire(key, Math.max(1, Math.round(ms)))) === 1),
282
- pttl: (key) => run(true, (c) => c.pttl(key)),
283
- hget: (key, field) => run(true, (c) => c.hget(key, field)),
284
- hset: (key, fields) => run(true, (c) => c.hset(key, fields)),
285
- hdel: (key, ...fields) => (fields.length ? run(true, (c) => c.hdel(key, ...fields)) : Promise.resolve(0)),
286
- hgetall: (key) => run(true, (c) => c.hgetall(key)),
287
- sadd: (key, ...members) => (members.length ? run(true, (c) => c.sadd(key, ...members)) : Promise.resolve(0)),
288
- srem: (key, ...members) => (members.length ? run(true, (c) => c.srem(key, ...members)) : Promise.resolve(0)),
289
- smembers: (key) => run(true, (c) => c.smembers(key)),
290
- scan: (pattern) =>
291
- run(true, async (c) => {
292
- const keys = new Set<string>();
293
- let cursor = '0';
294
- do {
295
- const [next, batch] = await c.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
296
- cursor = next;
297
- for (const k of batch) keys.add(k);
298
- } while (cursor !== '0');
299
- return [...keys];
300
- }),
301
- compareAndSet: (key, expected, value, o = {}) =>
302
- run(false, async (c) =>
303
- (await c.eval(CAS_SET, 1, key, expected === null ? '1' : '0', expected ?? '', value, o.px === undefined ? '' : String(Math.max(1, Math.round(o.px))))) === 1,
304
- ),
305
- compareAndDelete: (key, expected) => run(false, async (c) => (await c.eval(CAS_DEL, 1, key, expected)) === 1),
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;
@@ -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 { appId, privateKey, owner, repo } = settings.github;
73
- backend = new GitHubBackend(new GitHubClient({ appId, privateKey, owner, repo, now }, tracker), settings.branches);
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
- railway: settings.railway ? { ...settings.railway, fetch: options.railwayFetch } : null,
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).