caracms 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/documents/actions.ts +6 -4
- package/src/editor/client/documents/dialogs.tsx +2 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +13 -1
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +26 -1
package/src/live/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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
86
|
-
const privateKey = need('GITHUB_APP_PRIVATE_KEY');
|
|
172
|
+
const appId = get('GITHUB_APP_ID');
|
|
87
173
|
const repo = readRepo(env, problems);
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
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
|
-
|
|
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).
|