@songsid/agend 2.2.0-beta.1 → 2.2.0-beta.3
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 +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/agent-cli-instructions.md +10 -7
- package/dist/agent-endpoint.js +21 -9
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-whats-new.d.ts +47 -0
- package/dist/agent-whats-new.js +141 -0
- package/dist/agent-whats-new.js.map +1 -0
- package/dist/auth-api.d.ts +2 -2
- package/dist/auth-api.js +4 -2
- package/dist/auth-api.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.d.ts +5 -0
- package/dist/backend/kiro-engine-ledger.js +20 -0
- package/dist/backend/kiro-engine-ledger.js.map +1 -1
- package/dist/backend/kiro-identity.d.ts +6 -0
- package/dist/backend/kiro-identity.js +19 -5
- package/dist/backend/kiro-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +14 -2
- package/dist/backend/kiro.js +84 -9
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +4 -2
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +11 -1
- package/dist/channel/adapters/discord.js +23 -1
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +19 -1
- package/dist/channel/adapters/telegram.js +60 -9
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-server.js +9 -1
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +11 -0
- package/dist/channel/mcp-tools.js +23 -12
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/telegram-markdown.d.ts +6 -0
- package/dist/channel/telegram-markdown.js +104 -0
- package/dist/channel/telegram-markdown.js.map +1 -0
- package/dist/channel/tool-router.js +9 -3
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +14 -2
- package/dist/cli.js +55 -16
- package/dist/cli.js.map +1 -1
- package/dist/command-table.js +1 -1
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +49 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +15 -2
- package/dist/cross-instance-envelope.js +27 -5
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +48 -0
- package/dist/daemon.js +250 -22
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +17 -2
- package/dist/delivery-outbox.js +13 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +18 -1
- package/dist/delivery-reconciliation.js +67 -2
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/fleet-manager.d.ts +63 -1
- package/dist/fleet-manager.js +314 -20
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +9 -6
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +11 -2
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
- package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
- package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
- package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
- package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -3
- package/dist/general-knowledge/steering/core-rules.md +8 -7
- package/dist/instance-avatar.d.ts +36 -0
- package/dist/instance-avatar.js +57 -0
- package/dist/instance-avatar.js.map +1 -0
- package/dist/instance-config-impact.js +5 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +4 -0
- package/dist/instance-lifecycle.js +3 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.js +10 -7
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +36 -26
- package/dist/locale.js.map +1 -1
- package/dist/next-steps.js +1 -1
- package/dist/next-steps.js.map +1 -1
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +28 -15
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +10 -2
- package/dist/outbound-schemas.js +15 -5
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +7 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/service-activation.js +4 -3
- package/dist/service-activation.js.map +1 -1
- package/dist/settings-api.js +71 -21
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +70 -1
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +26 -4
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.js +7 -0
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +1 -1
- package/dist/status-emojis.d.ts +10 -3
- package/dist/status-emojis.js +11 -2
- package/dist/status-emojis.js.map +1 -1
- package/dist/tips.js +4 -4
- package/dist/tips.js.map +1 -1
- package/dist/transcript-delta-reader.d.ts +3 -3
- package/dist/transcript-delta-reader.js +8 -8
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-monitor.d.ts +8 -0
- package/dist/transcript-monitor.js +26 -2
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +54 -0
- package/dist/transcript-sources.js +188 -27
- package/dist/transcript-sources.js.map +1 -1
- package/dist/transcript-turns.d.ts +15 -2
- package/dist/transcript-turns.js +15 -2
- package/dist/transcript-turns.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/ui/chat-emojis.js +33 -0
- package/dist/ui/chat-render.js +12 -0
- package/dist/ui/chat-store.js +1 -1
- package/dist/ui/chat-strings.js +12 -12
- package/dist/ui/chat-thread.js +23 -4
- package/dist/ui/panel-chat.js +16 -3
- package/dist/ui/panel-needs.js +5 -2
- package/dist/ui/panel-settings.js +129 -31
- package/dist/ui/settings-dialogs.js +7 -2
- package/dist/ui/settings-model.js +2 -1
- package/dist/ui/settings-strings.js +44 -4
- package/dist/ui/shared/app-i18n.js +37 -15
- package/dist/ui/shared/app-prefs.js +249 -0
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-shell.js +17 -4
- package/dist/ui/shared/app-store.js +3 -1
- package/dist/ui/shared/app.css +55 -0
- package/dist/ui/shared/app.js +6 -1
- package/dist/ui/shared/header-tools.js +55 -5
- package/dist/ui/shared/instance-nav.js +169 -32
- package/dist/ui/shared/panel-view.js +6 -0
- package/dist/ui/shared/signin.js +7 -7
- package/dist/ui/shared/theme.js +39 -7
- package/dist/ui/shared/ui-icons.js +1 -0
- package/dist/ui/shared/usage-dialog.js +13 -4
- package/dist/ui/shared/view-strings.js +22 -4
- package/dist/ui/signin.html +1 -1
- package/dist/update-install.d.ts +15 -1
- package/dist/update-install.js +6 -0
- package/dist/update-install.js.map +1 -1
- package/dist/update-preflight.d.ts +100 -0
- package/dist/update-preflight.js +264 -0
- package/dist/update-preflight.js.map +1 -0
- package/dist/updater-caller.d.ts +31 -0
- package/dist/updater-caller.js +109 -0
- package/dist/updater-caller.js.map +1 -0
- package/dist/view-api.d.ts +1 -1
- package/dist/view-api.js +38 -8
- package/dist/view-api.js.map +1 -1
- package/dist/wake-coordinator.d.ts +11 -0
- package/dist/wake-coordinator.js +38 -4
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-api.d.ts +21 -1
- package/dist/web-api.js +157 -1
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +2 -2
- package/dist/web-auth.js +7 -4
- package/dist/web-auth.js.map +1 -1
- package/dist/web-host-guard.d.ts +3 -11
- package/dist/web-host-guard.js +3 -12
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-images.d.ts +47 -0
- package/dist/web-images.js +169 -0
- package/dist/web-images.js.map +1 -0
- package/dist/web-prefs.d.ts +63 -0
- package/dist/web-prefs.js +263 -0
- package/dist/web-prefs.js.map +1 -0
- package/dist/web-session.d.ts +36 -1
- package/dist/web-session.js +53 -6
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +21 -0
- package/dist/web-settings-patch.js +74 -0
- package/dist/web-settings-patch.js.map +1 -0
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/workflow-templates/default.md +1 -1
- package/package.json +1 -1
package/dist/web-host-guard.d.ts
CHANGED
|
@@ -64,17 +64,9 @@ export declare function isHostAllowed(hostHeader: string | string[] | undefined,
|
|
|
64
64
|
*/
|
|
65
65
|
export declare function applyWebSecurityHeaders(res: ServerResponse): void;
|
|
66
66
|
/**
|
|
67
|
-
* The
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* source only: script still cannot connect or post anywhere but this origin. What it gives up is that Discord sees the
|
|
71
|
-
* viewer's address when the editor is open (the request carries no referrer — EmojiImg sets no-referrer).
|
|
72
|
-
*/
|
|
73
|
-
export declare const DISCORD_EMOJI_IMG_SRC = "https://cdn.discordapp.com/emojis/";
|
|
74
|
-
/**
|
|
75
|
-
* The panel policy for one response: WEB_CONTENT_SECURITY_POLICY with this response's nonce for scripts and styles,
|
|
76
|
-
* Discord's emoji CDN for images (DISCORD_EMOJI_IMG_SRC),
|
|
77
|
-
* and — for /ui only, when a preview origin was chosen for this load (#1306) — `frame-src <preview origin>/frame`:
|
|
67
|
+
* The panel policy for one response: WEB_CONTENT_SECURITY_POLICY with this response's nonce for scripts and styles —
|
|
68
|
+
* images stay 'self' (#1630: Discord's emoji pictures come from the dashboard's own /ui/img/emoji/, so no remote image
|
|
69
|
+
* source is admitted any more, and Discord never sees a viewer's address) — and — for /ui only, when a preview origin was chosen for this load (#1306) — `frame-src <preview origin>/frame`:
|
|
78
70
|
* path-scoped, so nothing else of that origin can be framed or navigated to. Without it, frames fall back to
|
|
79
71
|
* default-src 'self'.
|
|
80
72
|
*/
|
package/dist/web-host-guard.js
CHANGED
|
@@ -103,24 +103,15 @@ export function applyWebSecurityHeaders(res) {
|
|
|
103
103
|
res.setHeader("Cache-Control", "no-store");
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
|
-
* The
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* source only: script still cannot connect or post anywhere but this origin. What it gives up is that Discord sees the
|
|
110
|
-
* viewer's address when the editor is open (the request carries no referrer — EmojiImg sets no-referrer).
|
|
111
|
-
*/
|
|
112
|
-
export const DISCORD_EMOJI_IMG_SRC = "https://cdn.discordapp.com/emojis/";
|
|
113
|
-
/**
|
|
114
|
-
* The panel policy for one response: WEB_CONTENT_SECURITY_POLICY with this response's nonce for scripts and styles,
|
|
115
|
-
* Discord's emoji CDN for images (DISCORD_EMOJI_IMG_SRC),
|
|
116
|
-
* and — for /ui only, when a preview origin was chosen for this load (#1306) — `frame-src <preview origin>/frame`:
|
|
106
|
+
* The panel policy for one response: WEB_CONTENT_SECURITY_POLICY with this response's nonce for scripts and styles —
|
|
107
|
+
* images stay 'self' (#1630: Discord's emoji pictures come from the dashboard's own /ui/img/emoji/, so no remote image
|
|
108
|
+
* source is admitted any more, and Discord never sees a viewer's address) — and — for /ui only, when a preview origin was chosen for this load (#1306) — `frame-src <preview origin>/frame`:
|
|
117
109
|
* path-scoped, so nothing else of that origin can be framed or navigated to. Without it, frames fall back to
|
|
118
110
|
* default-src 'self'.
|
|
119
111
|
*/
|
|
120
112
|
export function panelContentSecurityPolicy(nonce, opts = {}) {
|
|
121
113
|
let policy = rewriteDirective(WEB_CONTENT_SECURITY_POLICY, "script-src 'self'", `script-src 'self' 'nonce-${nonce}'`);
|
|
122
114
|
policy = rewriteDirective(policy, "style-src 'self'", `style-src 'self' 'nonce-${nonce}'`);
|
|
123
|
-
policy = rewriteDirective(policy, "img-src 'self' data: blob:", `img-src 'self' data: blob: ${DISCORD_EMOJI_IMG_SRC}`);
|
|
124
115
|
if (opts.frameSrc)
|
|
125
116
|
policy = `${policy}; frame-src ${opts.frameSrc}`;
|
|
126
117
|
// #1589: the app shell's audio/video — exactly <dashboard origin>/ui/file/ (or 'none'); without it media falls back
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web-host-guard.js","sourceRoot":"","sources":["../src/web-host-guard.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"web-host-guard.js","sourceRoot":"","sources":["../src/web-host-guard.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { StickerPreview } from "./channel/types.js";
|
|
2
|
+
export declare const WEB_IMAGE_MAX_AGE_MS: number;
|
|
3
|
+
export declare const WEB_IMAGE_RETRY_MS: number;
|
|
4
|
+
/** Files not refreshed for this long (an emoji no longer used, a removed connection) are deleted on the next write. */
|
|
5
|
+
export declare const WEB_IMAGE_PRUNE_MS: number;
|
|
6
|
+
export declare const WEB_IMAGE_MAX_BYTES: number;
|
|
7
|
+
/** The picture type of an uploaded file by its extension (View's profile avatars), or null for anything else. */
|
|
8
|
+
export declare function imageMimeForFile(name: string): string | null;
|
|
9
|
+
export interface CachedImage {
|
|
10
|
+
readonly bytes: Buffer;
|
|
11
|
+
readonly mime: string;
|
|
12
|
+
readonly version: string;
|
|
13
|
+
}
|
|
14
|
+
/** What a fetch found: the picture, or null when there is none (the bot has no avatar). Throws when it failed. */
|
|
15
|
+
export type ImageLoader = () => Promise<StickerPreview | null>;
|
|
16
|
+
export declare class WebImageCache {
|
|
17
|
+
private readonly opts;
|
|
18
|
+
private readonly inflight;
|
|
19
|
+
/** key → when it may be asked again (monotonic ms), after a failure or a "none". */
|
|
20
|
+
private readonly quietUntil;
|
|
21
|
+
constructor(opts: {
|
|
22
|
+
dir: string;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
mono?: () => number;
|
|
25
|
+
maxAgeMs?: number;
|
|
26
|
+
retryMs?: number;
|
|
27
|
+
onError?: (key: string, err: unknown) => void;
|
|
28
|
+
});
|
|
29
|
+
private get now();
|
|
30
|
+
private get mono();
|
|
31
|
+
private base;
|
|
32
|
+
private find;
|
|
33
|
+
private stale;
|
|
34
|
+
/** What is on disk now (fresh or stale), as a version for the page's URL; a stale or missing one is refreshed in the background. */
|
|
35
|
+
peek(key: string, loader: ImageLoader): {
|
|
36
|
+
version: string;
|
|
37
|
+
} | null;
|
|
38
|
+
/** The bytes on disk, or null. Never fetches. */
|
|
39
|
+
read(key: string): CachedImage | null;
|
|
40
|
+
/** The bytes, fetching first when nothing is on disk yet (a stale file is served and refreshed behind). */
|
|
41
|
+
get(key: string, loader: ImageLoader): Promise<CachedImage | null>;
|
|
42
|
+
/** One fetch per key at a time; none while the key is quiet (after a failure or a "none"). Never throws. */
|
|
43
|
+
refresh(key: string, loader: ImageLoader): Promise<void>;
|
|
44
|
+
private write;
|
|
45
|
+
private remove;
|
|
46
|
+
private prune;
|
|
47
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1630: images the web app shows that come from a chat platform — a bot's own avatar, a Discord server's custom
|
|
3
|
+
* emoji — fetched on the host with the token the adapter already holds, kept on disk, and served same-origin (the page
|
|
4
|
+
* never loads cdn.discordapp.com or a Telegram file URL, and CSP img-src stays 'self').
|
|
5
|
+
*
|
|
6
|
+
* - On disk under `<AGEND_HOME>/cache/<kind>/`, one file per key, named by a hash of the key (a key never becomes a
|
|
7
|
+
* path), written to a temp file and renamed.
|
|
8
|
+
* - Refreshed at most once a day: the file's age is wall-clock (mtime — it must survive a restart). A stale file is
|
|
9
|
+
* still served while the refresh runs in the background.
|
|
10
|
+
* - A failed fetch keeps the file it had; the key is then not retried for an hour. A fetch that says there is no image
|
|
11
|
+
* (a bot with no avatar) removes the file, and is not asked again for a day. Those two waits are in-memory durations:
|
|
12
|
+
* monotonic clock.
|
|
13
|
+
* - Never more than one fetch per key at a time. Nothing here blocks a caller: `peek` starts a refresh and returns
|
|
14
|
+
* what is on disk now; only `get` (a first request for an emoji nobody has fetched yet) waits for one.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { performance } from "node:perf_hooks";
|
|
20
|
+
export const WEB_IMAGE_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
21
|
+
export const WEB_IMAGE_RETRY_MS = 60 * 60_000;
|
|
22
|
+
/** Files not refreshed for this long (an emoji no longer used, a removed connection) are deleted on the next write. */
|
|
23
|
+
export const WEB_IMAGE_PRUNE_MS = 30 * WEB_IMAGE_MAX_AGE_MS;
|
|
24
|
+
export const WEB_IMAGE_MAX_BYTES = 256 * 1024;
|
|
25
|
+
const MIME = { png: "image/png", jpg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
|
|
26
|
+
const EXTS = Object.keys(MIME);
|
|
27
|
+
/** The picture type of an uploaded file by its extension (View's profile avatars), or null for anything else. */
|
|
28
|
+
export function imageMimeForFile(name) {
|
|
29
|
+
const ext = /\.([a-z]+)$/i.exec(name)?.[1]?.toLowerCase();
|
|
30
|
+
return ext === "jpeg" ? MIME.jpg : ext && Object.hasOwn(MIME, ext) ? MIME[ext] : null;
|
|
31
|
+
}
|
|
32
|
+
export class WebImageCache {
|
|
33
|
+
opts;
|
|
34
|
+
inflight = new Map();
|
|
35
|
+
/** key → when it may be asked again (monotonic ms), after a failure or a "none". */
|
|
36
|
+
quietUntil = new Map();
|
|
37
|
+
constructor(opts) {
|
|
38
|
+
this.opts = opts;
|
|
39
|
+
}
|
|
40
|
+
get now() { return (this.opts.now ?? Date.now)(); }
|
|
41
|
+
get mono() { return (this.opts.mono ?? (() => performance.now()))(); }
|
|
42
|
+
base(key) { return createHash("sha256").update(key).digest("hex").slice(0, 32); }
|
|
43
|
+
find(key) {
|
|
44
|
+
const base = this.base(key);
|
|
45
|
+
for (const ext of EXTS) {
|
|
46
|
+
const path = join(this.opts.dir, `${base}.${ext}`);
|
|
47
|
+
try {
|
|
48
|
+
if (existsSync(path))
|
|
49
|
+
return { path, ext, mtimeMs: statSync(path).mtimeMs };
|
|
50
|
+
}
|
|
51
|
+
catch { /* raced with a refresh */ }
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
stale(mtimeMs) { return this.now - mtimeMs >= (this.opts.maxAgeMs ?? WEB_IMAGE_MAX_AGE_MS); }
|
|
56
|
+
/** What is on disk now (fresh or stale), as a version for the page's URL; a stale or missing one is refreshed in the background. */
|
|
57
|
+
peek(key, loader) {
|
|
58
|
+
const found = this.find(key);
|
|
59
|
+
if (!found || this.stale(found.mtimeMs))
|
|
60
|
+
void this.refresh(key, loader);
|
|
61
|
+
return found ? { version: Math.floor(found.mtimeMs).toString(36) } : null;
|
|
62
|
+
}
|
|
63
|
+
/** The bytes on disk, or null. Never fetches. */
|
|
64
|
+
read(key) {
|
|
65
|
+
const found = this.find(key);
|
|
66
|
+
if (!found)
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
return { bytes: readFileSync(found.path), mime: MIME[found.ext], version: Math.floor(found.mtimeMs).toString(36) };
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The bytes, fetching first when nothing is on disk yet (a stale file is served and refreshed behind). */
|
|
76
|
+
async get(key, loader) {
|
|
77
|
+
const found = this.find(key);
|
|
78
|
+
if (!found)
|
|
79
|
+
await this.refresh(key, loader);
|
|
80
|
+
else if (this.stale(found.mtimeMs))
|
|
81
|
+
void this.refresh(key, loader);
|
|
82
|
+
return this.read(key);
|
|
83
|
+
}
|
|
84
|
+
/** One fetch per key at a time; none while the key is quiet (after a failure or a "none"). Never throws. */
|
|
85
|
+
refresh(key, loader) {
|
|
86
|
+
const running = this.inflight.get(key);
|
|
87
|
+
if (running)
|
|
88
|
+
return running;
|
|
89
|
+
const quiet = this.quietUntil.get(key);
|
|
90
|
+
if (quiet !== undefined && this.mono < quiet)
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
// Started on a later tick, after it is registered: a loader that throws at once must still release the key.
|
|
93
|
+
const run = Promise.resolve().then(async () => {
|
|
94
|
+
try {
|
|
95
|
+
const got = await loader();
|
|
96
|
+
if (!got) {
|
|
97
|
+
this.remove(key);
|
|
98
|
+
this.quietUntil.set(key, this.mono + (this.opts.maxAgeMs ?? WEB_IMAGE_MAX_AGE_MS));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!MIME[got.ext] || got.bytes.length === 0 || got.bytes.length > WEB_IMAGE_MAX_BYTES)
|
|
102
|
+
throw new Error("not a usable image");
|
|
103
|
+
this.write(key, got);
|
|
104
|
+
this.quietUntil.delete(key);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
this.quietUntil.set(key, this.mono + (this.opts.retryMs ?? WEB_IMAGE_RETRY_MS));
|
|
108
|
+
this.opts.onError?.(key, err);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (this.inflight.get(key) === run)
|
|
112
|
+
this.inflight.delete(key);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
this.inflight.set(key, run);
|
|
116
|
+
return run;
|
|
117
|
+
}
|
|
118
|
+
write(key, image) {
|
|
119
|
+
mkdirSync(this.opts.dir, { recursive: true, mode: 0o700 });
|
|
120
|
+
const base = this.base(key), path = join(this.opts.dir, `${base}.${image.ext}`);
|
|
121
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(tmp, image.bytes, { mode: 0o600 });
|
|
124
|
+
renameSync(tmp, path);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
try {
|
|
128
|
+
unlinkSync(tmp);
|
|
129
|
+
}
|
|
130
|
+
catch { /* never created */ }
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
for (const ext of EXTS)
|
|
134
|
+
if (ext !== image.ext) {
|
|
135
|
+
try {
|
|
136
|
+
unlinkSync(join(this.opts.dir, `${base}.${ext}`));
|
|
137
|
+
}
|
|
138
|
+
catch { /* none */ }
|
|
139
|
+
}
|
|
140
|
+
this.prune();
|
|
141
|
+
}
|
|
142
|
+
remove(key) {
|
|
143
|
+
const base = this.base(key);
|
|
144
|
+
for (const ext of EXTS) {
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(join(this.opts.dir, `${base}.${ext}`));
|
|
147
|
+
}
|
|
148
|
+
catch { /* none */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
prune() {
|
|
152
|
+
let names;
|
|
153
|
+
try {
|
|
154
|
+
names = readdirSync(this.opts.dir);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
for (const name of names) {
|
|
160
|
+
const path = join(this.opts.dir, name);
|
|
161
|
+
try {
|
|
162
|
+
if (this.now - statSync(path).mtimeMs > WEB_IMAGE_PRUNE_MS)
|
|
163
|
+
unlinkSync(path);
|
|
164
|
+
}
|
|
165
|
+
catch { /* raced */ }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=web-images.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web-images.js","sourceRoot":"","sources":["../src/web-images.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC5H,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAG9C,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,GAAG,EAAE,GAAG,MAAM,CAAC;AACrD,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,MAAM,CAAC;AAC9C,uHAAuH;AACvH,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,oBAAoB,CAAC;AAC5D,MAAM,CAAC,MAAM,mBAAmB,GAAG,GAAG,GAAG,IAAI,CAAC;AAE9C,MAAM,IAAI,GAAoD,EAAE,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,GAAG,EAAE,WAAW,EAAE,CAAC;AAC5I,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAA4B,CAAC;AAE1D,iHAAiH;AACjH,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,GAAG,GAAG,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC;IAC1D,OAAO,GAAG,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAA4B,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACjH,CAAC;AAMD,MAAM,OAAO,aAAa;IAKK;IAJZ,QAAQ,GAAG,IAAI,GAAG,EAAyB,CAAC;IAC7D,oFAAoF;IACnE,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAC;IAExD,YAA6B,IAO5B;QAP4B,SAAI,GAAJ,IAAI,CAOhC;IAAG,CAAC;IAEL,IAAY,GAAG,KAAa,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACnE,IAAY,IAAI,KAAa,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC9E,IAAI,CAAC,GAAW,IAAY,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAEjG,IAAI,CAAC,GAAW;QACtB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC5B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,GAAG,EAAE,CAAC,CAAC;YACnD,IAAI,CAAC;gBAAC,IAAI,UAAU,CAAC,IAAI,CAAC;oBAAE,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,0BAA0B,CAAC,CAAC;QAC3H,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IACO,KAAK,CAAC,OAAe,IAAa,OAAO,IAAI,CAAC,GAAG,GAAG,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,oBAAoB,CAAC,CAAC,CAAC,CAAC;IAEtH,oIAAoI;IACpI,IAAI,CAAC,GAAW,EAAE,MAAmB;QACnC,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC;YAAE,KAAK,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QACxE,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5E,CAAC;IAED,iDAAiD;IACjD,IAAI,CAAC,GAAW;QACd,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QACxB,IAAI,CAAC;YAAC,OAAO,EAAE,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC;QAAC,CAAC;QAC3H,MAAM,CAAC;YAAC,OAAO,IAAI,CAAC;QAAC,CAAC;IACxB,CAAC;IAED,2GAA2G;IAC3G,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,MAAmB;QACxC,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,KAAK;YAAE,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;aACvC,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC;YAAE,KAAK,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QACnE,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;IAED,4GAA4G;IAC5G,OAAO,CAAC,GAAW,EAAE,MAAmB;QACtC,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACvC,IAAI,OAAO;YAAE,OAAO,OAAO,CAAC;QAC5B,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACvC,IAAI,KAAK,KAAK,SAAS,IAAI,IAAI,CAAC,IAAI,GAAG,KAAK;YAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;QACvE,4GAA4G;QAC5G,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE;YAC5C,IAAI,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,MAAM,EAAE,CAAC;gBAC3B,IAAI,CAAC,GAAG,EAAE,CAAC;oBAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;oBAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,oBAAoB,CAAC,CAAC,CAAC;oBAAC,OAAO;gBAAC,CAAC;gBAC3H,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,mBAAmB;oBAAE,MAAM,IAAI,KAAK,CAAC,oBAAoB,CAAC,CAAC;gBAC9H,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;gBACrB,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC9B,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,IAAI,kBAAkB,CAAC,CAAC,CAAC;gBAChF,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;YAChC,CAAC;oBAAS,CAAC;gBACT,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,GAAG;oBAAE,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAChE,CAAC;QACH,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QAC5B,OAAO,GAAG,CAAC;IACb,CAAC;IAEO,KAAK,CAAC,GAAW,EAAE,KAAqB;QAC9C,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QAC3D,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC;QAChF,MAAM,GAAG,GAAG,GAAG,IAAI,IAAI,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC;QAChF,IAAI,CAAC;YACH,aAAa,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACjD,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACxB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC;gBAAC,UAAU,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,mBAAmB,CAAC,CAAC;YACtD,MAAM,GAAG,CAAC;QACZ,CAAC;QACD,KAAK,MAAM,GAAG,IAAI,IAAI;YAAE,IAAI,GAAG,KAAK,KAAK,CAAC,GAAG,EAAE,CAAC;gBAAC,IAAI,CAAC;oBAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC;gBAAC,CAAC;gBAAC,MAAM,CAAC,CAAC,UAAU,CAAC,CAAC;YAAC,CAAC;QAClI,IAAI,CAAC,KAAK,EAAE,CAAC;IACf,CAAC;IACO,MAAM,CAAC,GAAW;QACxB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAC5B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YAAC,IAAI,CAAC;gBAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,IAAI,GAAG,EAAE,CAAC,CAAC,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,UAAU,CAAC,CAAC;QAAC,CAAC;IAC7G,CAAC;IACO,KAAK;QACX,IAAI,KAAe,CAAC;QACpB,IAAI,CAAC;YAAC,KAAK,GAAG,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO;QAAC,CAAC;QAC7D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACvC,IAAI,CAAC;gBAAC,IAAI,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,GAAG,kBAAkB;oBAAE,UAAU,CAAC,IAAI,CAAC,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,WAAW,CAAC,CAAC;QAC7G,CAAC;IACH,CAAC;CACF"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export declare const WEB_PREFS_FILE = "web-prefs.json";
|
|
2
|
+
export declare const OPERATOR = "operator";
|
|
3
|
+
/** #1664: at most this many favorites. */
|
|
4
|
+
export declare const MAX_FAVORITES = 50;
|
|
5
|
+
export declare const WEB_PREF_KEYS: readonly string[];
|
|
6
|
+
export declare function isWebPrefKey(key: string): boolean;
|
|
7
|
+
export interface WebPrefsView {
|
|
8
|
+
/** Every key that is set, and its value (a key not set has its default: the page's own). */
|
|
9
|
+
readonly values: Record<string, unknown>;
|
|
10
|
+
readonly rev: number;
|
|
11
|
+
/** The file could not be read: these are the defaults, and writes are refused until it is fixed. */
|
|
12
|
+
readonly unreadable?: true;
|
|
13
|
+
}
|
|
14
|
+
export type WebPrefsWrite = {
|
|
15
|
+
ok: true;
|
|
16
|
+
view: WebPrefsView;
|
|
17
|
+
changed: string[];
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
status: 400 | 503;
|
|
21
|
+
error: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A request's `set`: own keys only, each known, each valid — or the first reason it is not. All or nothing.
|
|
25
|
+
* `exists`: whether an instance of that name is in the fleet now (#1664: a favorite must be one).
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseWebPrefsPatch(set: unknown, exists?: (name: string) => boolean): {
|
|
28
|
+
ok: true;
|
|
29
|
+
patch: Map<string, unknown>;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
export declare class WebPrefsStore {
|
|
35
|
+
private readonly dir;
|
|
36
|
+
private readonly opts;
|
|
37
|
+
private state;
|
|
38
|
+
/**
|
|
39
|
+
* `exists` (#1664): whether an instance of that name is in the fleet now — a favorite that is not is never served,
|
|
40
|
+
* and is never accepted.
|
|
41
|
+
*/
|
|
42
|
+
constructor(dir: string, opts?: {
|
|
43
|
+
now?: () => number;
|
|
44
|
+
write?: (path: string, data: string) => void;
|
|
45
|
+
exists?: (name: string) => boolean;
|
|
46
|
+
});
|
|
47
|
+
/** parseWebPrefsPatch against this fleet. */
|
|
48
|
+
parsePatch(set: unknown): ReturnType<typeof parseWebPrefsPatch>;
|
|
49
|
+
get path(): string;
|
|
50
|
+
private load;
|
|
51
|
+
read(identity?: string): WebPrefsView;
|
|
52
|
+
rev(): number;
|
|
53
|
+
/** #1664: an instance was removed — drop it from every favorites list (a write, and a new rev, only if one had it). */
|
|
54
|
+
forgetInstance(name: string): void;
|
|
55
|
+
private viewOf;
|
|
56
|
+
/**
|
|
57
|
+
* Set (or, with null, clear) the patch's keys. `onlyIfUnset`: a device's first upload of what it had locally — only
|
|
58
|
+
* the keys the host does not have yet are taken, so the first device to arrive wins and the host wins from then on.
|
|
59
|
+
*/
|
|
60
|
+
write(identity: string, patch: Map<string, unknown>, opts?: {
|
|
61
|
+
onlyIfUnset?: boolean;
|
|
62
|
+
}): WebPrefsWrite;
|
|
63
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1665: the web preferences that follow the person from device to device (theme, language, the sidebar's arrangement,
|
|
3
|
+
* the HTML-preview opt-in), kept on the host — design: the comment on #1665.
|
|
4
|
+
*
|
|
5
|
+
* - The tour and the first-run card stay per device (the user's call on #1665): they are not keys here.
|
|
6
|
+
* - One bucket per web identity. There is one today: every web session is the operator's (SessionRecord has no
|
|
7
|
+
* owner), so the file is keyed by identity and holds "operator" only; a per-owner key can come later unmigrated.
|
|
8
|
+
* - A fixed registry of keys, each with its own validator. Anything else is refused — "__proto__" and "constructor"
|
|
9
|
+
* are just unknown keys: every lookup is a Map, never an object's prototype chain.
|
|
10
|
+
* - The file (<home>/web-prefs.json, 0600) is replaced atomically. One that cannot be read or parsed is never
|
|
11
|
+
* overwritten: reads give the defaults, writes are refused, until a person looks at it.
|
|
12
|
+
* - `rev` rises with every change; the status frame carries it, so another device re-reads at its next poll.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
17
|
+
export const WEB_PREFS_FILE = "web-prefs.json";
|
|
18
|
+
export const OPERATOR = "operator";
|
|
19
|
+
const bad = (invalid) => ({ invalid });
|
|
20
|
+
const isInvalid = (v) => !!v && typeof v === "object" && Object.hasOwn(v, "invalid") && Object.keys(v).length === 1;
|
|
21
|
+
const plain = (v) => !!v && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
|
|
22
|
+
const shortText = (v, max) => typeof v === "string" && v.length <= max;
|
|
23
|
+
const name = (v) => typeof v === "string" && v.length >= 1 && v.length <= 128 && !/[\u0000-\u001f\u007f]/.test(v);
|
|
24
|
+
/** Only the listed fields, each present: an extra field is a value this version does not understand. */
|
|
25
|
+
function exactly(v, fields) {
|
|
26
|
+
const keys = Object.keys(v);
|
|
27
|
+
return keys.length === fields.length && fields.every((f) => Object.hasOwn(v, f));
|
|
28
|
+
}
|
|
29
|
+
function uniqueStrings(v, max, each, what) {
|
|
30
|
+
if (!Array.isArray(v) || v.length > max)
|
|
31
|
+
return bad(`${what}: a list of at most ${max}`);
|
|
32
|
+
if (!v.every(each))
|
|
33
|
+
return bad(`${what}: each a name of 1–128 characters`);
|
|
34
|
+
if (new Set(v).size !== v.length)
|
|
35
|
+
return bad(`${what}: no repeats`);
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
38
|
+
/** #1664: at most this many favorites. */
|
|
39
|
+
export const MAX_FAVORITES = 50;
|
|
40
|
+
const FAVORITES = "nav.favorites";
|
|
41
|
+
/** The status chips of the sidebar filter (src/ui/shared/instance-nav.js STATUS_FACETS). */
|
|
42
|
+
const STATUS_FACETS = new Set(["working", "needs", "idle", "stopped", "crashed"]);
|
|
43
|
+
/** Each key's validator: the value to store, or why it is refused. `null` (clear) never reaches one. */
|
|
44
|
+
const REGISTRY = new Map([
|
|
45
|
+
["theme", (v) => (v === "light" || v === "dark" || v === "system" ? v : bad("theme: light, dark or system"))],
|
|
46
|
+
["lang", (v) => (v === "en" || v === "zh-TW" ? v : bad("lang: en or zh-TW"))],
|
|
47
|
+
// The sidebar's drag order: the rows instance-nav.js keeps (group rows have no group_name).
|
|
48
|
+
["nav.order", (v) => {
|
|
49
|
+
if (!Array.isArray(v) || v.length > 1000)
|
|
50
|
+
return bad("nav.order: a list of at most 1000 rows");
|
|
51
|
+
for (const row of v) {
|
|
52
|
+
if (!plain(row) || !exactly(row, ["item_type", "item_name", "sort_index", "group_name"]))
|
|
53
|
+
return bad("nav.order: rows of item_type, item_name, sort_index, group_name");
|
|
54
|
+
const groupRow = row.item_type === "group";
|
|
55
|
+
if (!groupRow && row.item_type !== "instance")
|
|
56
|
+
return bad("nav.order: item_type group or instance");
|
|
57
|
+
if (!name(row.item_name))
|
|
58
|
+
return bad("nav.order: item_name of 1–128 characters");
|
|
59
|
+
if (!Number.isSafeInteger(row.sort_index) || row.sort_index < 0)
|
|
60
|
+
return bad("nav.order: sort_index a whole number from 0");
|
|
61
|
+
if (groupRow ? row.group_name !== null : !name(row.group_name))
|
|
62
|
+
return bad("nav.order: group_name null for a group, the group's name for an instance");
|
|
63
|
+
}
|
|
64
|
+
return v;
|
|
65
|
+
}],
|
|
66
|
+
["nav.collapsed", (v) => uniqueStrings(v, 200, name, "nav.collapsed")],
|
|
67
|
+
["nav.filter", (v) => {
|
|
68
|
+
if (!plain(v) || !exactly(v, ["q", "status", "cli"]))
|
|
69
|
+
return bad("nav.filter: { q, status, cli }");
|
|
70
|
+
if (!shortText(v.q, 200))
|
|
71
|
+
return bad("nav.filter: q at most 200 characters");
|
|
72
|
+
const status = uniqueStrings(v.status, STATUS_FACETS.size, (s) => typeof s === "string" && STATUS_FACETS.has(s), "nav.filter.status");
|
|
73
|
+
if (isInvalid(status))
|
|
74
|
+
return status;
|
|
75
|
+
const cli = uniqueStrings(v.cli, 32, (s) => typeof s === "string" && /^[a-z0-9][a-z0-9_-]{0,31}$/.test(s), "nav.filter.cli");
|
|
76
|
+
if (isInvalid(cli))
|
|
77
|
+
return cli;
|
|
78
|
+
return { q: v.q, status, cli };
|
|
79
|
+
}],
|
|
80
|
+
// #1664: the starred instances, in the order they were starred (or moved). Whether each one exists is checked where
|
|
81
|
+
// the fleet is known (parseWebPrefsPatch's `exists`); a deleted one is dropped on read and when it is removed.
|
|
82
|
+
[FAVORITES, (v) => uniqueStrings(v, MAX_FAVORITES, name, FAVORITES)],
|
|
83
|
+
// HTML previews allowed. Who may write it at all is the route's business (a signed-in session, never the public link).
|
|
84
|
+
["preview.optIn", (v) => (typeof v === "boolean" ? v : bad("preview.optIn: true or false"))],
|
|
85
|
+
]);
|
|
86
|
+
export const WEB_PREF_KEYS = [...REGISTRY.keys()];
|
|
87
|
+
export function isWebPrefKey(key) { return REGISTRY.has(key); }
|
|
88
|
+
/**
|
|
89
|
+
* A request's `set`: own keys only, each known, each valid — or the first reason it is not. All or nothing.
|
|
90
|
+
* `exists`: whether an instance of that name is in the fleet now (#1664: a favorite must be one).
|
|
91
|
+
*/
|
|
92
|
+
export function parseWebPrefsPatch(set, exists) {
|
|
93
|
+
if (!plain(set))
|
|
94
|
+
return { ok: false, error: "set: an object of preference keys" };
|
|
95
|
+
const patch = new Map();
|
|
96
|
+
const keys = Object.keys(set);
|
|
97
|
+
if (!keys.length)
|
|
98
|
+
return { ok: false, error: "set: at least one preference" };
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
const validate = REGISTRY.get(key);
|
|
101
|
+
if (!validate)
|
|
102
|
+
return { ok: false, error: `unknown_pref: ${key.slice(0, 64)}` };
|
|
103
|
+
const raw = set[key];
|
|
104
|
+
if (raw === null) {
|
|
105
|
+
patch.set(key, null);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const value = validate(raw);
|
|
109
|
+
if (isInvalid(value))
|
|
110
|
+
return { ok: false, error: value.invalid };
|
|
111
|
+
if (key === FAVORITES) {
|
|
112
|
+
const unknown = value.find((n) => !exists || !exists(n));
|
|
113
|
+
if (unknown !== undefined)
|
|
114
|
+
return { ok: false, error: `unknown_instance: ${unknown.slice(0, 64)}` };
|
|
115
|
+
}
|
|
116
|
+
patch.set(key, value);
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, patch };
|
|
119
|
+
}
|
|
120
|
+
export class WebPrefsStore {
|
|
121
|
+
dir;
|
|
122
|
+
opts;
|
|
123
|
+
state = null;
|
|
124
|
+
/**
|
|
125
|
+
* `exists` (#1664): whether an instance of that name is in the fleet now — a favorite that is not is never served,
|
|
126
|
+
* and is never accepted.
|
|
127
|
+
*/
|
|
128
|
+
constructor(dir, opts = {}) {
|
|
129
|
+
this.dir = dir;
|
|
130
|
+
this.opts = opts;
|
|
131
|
+
}
|
|
132
|
+
/** parseWebPrefsPatch against this fleet. */
|
|
133
|
+
parsePatch(set) { return parseWebPrefsPatch(set, this.opts.exists); }
|
|
134
|
+
get path() { return join(this.dir, WEB_PREFS_FILE); }
|
|
135
|
+
load() {
|
|
136
|
+
// A store that could not read its file tries again next time (a permission put back, a disk that recovered); one
|
|
137
|
+
// that did keeps what it read.
|
|
138
|
+
if (this.state && this.state !== "unreadable")
|
|
139
|
+
return this.state;
|
|
140
|
+
const empty = { buckets: new Map(), rev: 0 };
|
|
141
|
+
// Only a file that is definitely not there (ENOENT) is a new, empty store. Anything else — a read refused (EACCES),
|
|
142
|
+
// an I/O error, no place for the file at all, content of another shape — is "unreadable": the defaults are served
|
|
143
|
+
// and nothing is written, so the file is never replaced and a page or status frame never fails because of it.
|
|
144
|
+
try {
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = readFileSync(this.path, "utf8");
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (err?.code === "ENOENT")
|
|
151
|
+
return (this.state = empty);
|
|
152
|
+
return (this.state = "unreadable");
|
|
153
|
+
}
|
|
154
|
+
const doc = JSON.parse(raw);
|
|
155
|
+
if (!plain(doc) || doc.version !== 1 || !plain(doc.identities) || !Number.isSafeInteger(doc.rev))
|
|
156
|
+
return (this.state = "unreadable");
|
|
157
|
+
const buckets = new Map();
|
|
158
|
+
for (const id of Object.keys(doc.identities)) {
|
|
159
|
+
const raw = doc.identities[id];
|
|
160
|
+
if (!plain(raw) || !plain(raw.values) || !plain(raw.updated))
|
|
161
|
+
return (this.state = "unreadable");
|
|
162
|
+
const bucket = { values: new Map(), updated: new Map() };
|
|
163
|
+
// A key this version no longer knows, or a value it would refuse now, is left out (never served).
|
|
164
|
+
for (const key of Object.keys(raw.values)) {
|
|
165
|
+
const validate = REGISTRY.get(key);
|
|
166
|
+
if (!validate)
|
|
167
|
+
continue;
|
|
168
|
+
const value = validate(raw.values[key]);
|
|
169
|
+
if (isInvalid(value))
|
|
170
|
+
continue;
|
|
171
|
+
bucket.values.set(key, value);
|
|
172
|
+
const at = raw.updated[key];
|
|
173
|
+
bucket.updated.set(key, typeof at === "number" && Number.isFinite(at) ? at : 0);
|
|
174
|
+
}
|
|
175
|
+
buckets.set(id, bucket);
|
|
176
|
+
}
|
|
177
|
+
return (this.state = { buckets, rev: doc.rev });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return (this.state = "unreadable");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
read(identity = OPERATOR) {
|
|
184
|
+
const state = this.load();
|
|
185
|
+
if (state === "unreadable")
|
|
186
|
+
return { values: {}, rev: 0, unreadable: true };
|
|
187
|
+
return this.viewOf(state, identity);
|
|
188
|
+
}
|
|
189
|
+
rev() { const s = this.load(); return s === "unreadable" ? 0 : s.rev; }
|
|
190
|
+
/** #1664: an instance was removed — drop it from every favorites list (a write, and a new rev, only if one had it). */
|
|
191
|
+
forgetInstance(name) {
|
|
192
|
+
const state = this.load();
|
|
193
|
+
if (state === "unreadable")
|
|
194
|
+
return;
|
|
195
|
+
for (const [identity, bucket] of state.buckets) {
|
|
196
|
+
const list = bucket.values.get(FAVORITES);
|
|
197
|
+
if (list && list.includes(name))
|
|
198
|
+
this.write(identity, new Map([[FAVORITES, list.filter((n) => n !== name)]]));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
viewOf(state, identity) {
|
|
202
|
+
const values = Object.create(null);
|
|
203
|
+
const bucket = state.buckets.get(identity);
|
|
204
|
+
if (bucket)
|
|
205
|
+
for (const [k, v] of bucket.values)
|
|
206
|
+
values[k] = v;
|
|
207
|
+
// #1664: a favorite whose instance was deleted is dropped (paused or stopped ones are still instances: kept).
|
|
208
|
+
if (Object.hasOwn(values, FAVORITES)) {
|
|
209
|
+
const exists = this.opts.exists;
|
|
210
|
+
values[FAVORITES] = values[FAVORITES].filter((n) => !!exists && exists(n));
|
|
211
|
+
}
|
|
212
|
+
return { values: { ...values }, rev: state.rev };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Set (or, with null, clear) the patch's keys. `onlyIfUnset`: a device's first upload of what it had locally — only
|
|
216
|
+
* the keys the host does not have yet are taken, so the first device to arrive wins and the host wins from then on.
|
|
217
|
+
*/
|
|
218
|
+
write(identity, patch, opts = {}) {
|
|
219
|
+
const state = this.load();
|
|
220
|
+
if (state === "unreadable")
|
|
221
|
+
return { ok: false, status: 503, error: "prefs_unreadable" };
|
|
222
|
+
const prior = state.buckets.get(identity);
|
|
223
|
+
const next = { values: new Map(prior?.values), updated: new Map(prior?.updated) };
|
|
224
|
+
const at = (this.opts.now ?? Date.now)();
|
|
225
|
+
const changed = [];
|
|
226
|
+
for (const [key, value] of patch) {
|
|
227
|
+
if (opts.onlyIfUnset && next.values.has(key))
|
|
228
|
+
continue;
|
|
229
|
+
if (value === null) {
|
|
230
|
+
if (!next.values.has(key))
|
|
231
|
+
continue;
|
|
232
|
+
next.values.delete(key);
|
|
233
|
+
next.updated.delete(key);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
if (next.values.has(key) && JSON.stringify(next.values.get(key)) === JSON.stringify(value))
|
|
237
|
+
continue;
|
|
238
|
+
next.values.set(key, value);
|
|
239
|
+
next.updated.set(key, at);
|
|
240
|
+
}
|
|
241
|
+
changed.push(key);
|
|
242
|
+
}
|
|
243
|
+
if (!changed.length)
|
|
244
|
+
return { ok: true, view: this.viewOf(state, identity), changed };
|
|
245
|
+
const buckets = new Map(state.buckets);
|
|
246
|
+
buckets.set(identity, next);
|
|
247
|
+
const candidate = { buckets, rev: state.rev + 1 };
|
|
248
|
+
(this.opts.write ?? ((path, data) => atomicWriteFileSync(path, data, { mode: 0o600 })))(this.path, serialize(candidate));
|
|
249
|
+
// Only once it is on disk: a failed write leaves memory as the file is.
|
|
250
|
+
this.state = candidate;
|
|
251
|
+
return { ok: true, view: this.viewOf(candidate, identity), changed };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function serialize(state) {
|
|
255
|
+
const identities = {};
|
|
256
|
+
for (const [id, b] of state.buckets) {
|
|
257
|
+
identities[id] = { values: Object.fromEntries(b.values), updated: Object.fromEntries(b.updated) };
|
|
258
|
+
}
|
|
259
|
+
// Object.fromEntries with a "__proto__" entry defines an own property, but no such key ever gets in: ids are ours, keys
|
|
260
|
+
// are the registry's.
|
|
261
|
+
return JSON.stringify({ version: 1, rev: state.rev, identities }, null, 2) + "\n";
|
|
262
|
+
}
|
|
263
|
+
//# sourceMappingURL=web-prefs.js.map
|