@steve31415/baselib 3.47.0 → 3.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,8 +6,9 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
6
6
  `~/migration/research/base-services-design.md`.
7
7
 
8
8
  Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `connector`, `s2s`, `db`, `http`, `sync`,
9
- `app-update`, `llm`, `email`, `twitter`, and `hub` (the connection hub's server core and app notifier). Browser exports: `log-browser`, `rum`,
10
- `slow-fetch`, `sync-browser`, and `app-update-browser`.
9
+ `app-update`, `llm`, `email`, `twitter`, `hub` (the connection hub's server core and app notifier), `code-manifest` and
10
+ `client-version`. Browser exports: `log-browser`, `rum`, `slow-fetch`, `sync-browser`, `app-update-browser`, and
11
+ `extension-self-update`.
11
12
 
12
13
  `sync` provides the Postgres event-log and server protocol primitives;
13
14
  `sync-browser` provides the IndexedDB outbox and offline-first client.
@@ -36,6 +37,15 @@ app's hooks module (`DeployHooks` in `src/deploy/types.ts`) extends both,
36
37
  first step of any production investigation (same doc, "Investigating
37
38
  production state").
38
39
 
40
+ Mac-run code (`~/plasticine-way/mac/sync/DESIGN.md`): `code-manifest` and the
41
+ `pw-code-manifest` bin write and check a directory's `mac-code.json` (its
42
+ tracked files' sha256 and one id); `extension-self-update` reloads an unpacked
43
+ extension into code that verifies on disk and that its server says it
44
+ deployed; `client-version` counts how long a Mac-run client has stayed behind
45
+ while reporting continuously, for the "stayed behind" WARN. `pw-deploy` marks
46
+ a running deploy in the git common dir (`pw-deploy.lock`) so the Mac's sync
47
+ job does not fast-forward the checkout under the build.
48
+
39
49
  `twitter` provides Mirror-first tweet lookup, TwitterAPI.io batch/search/
40
50
  article calls, and thread assembly through direct reply edges. It includes
41
51
  one level of quotes, article bodies and URL expansions. Use one shared
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // `pw-code-manifest` — write or check a directory's mac-code.json (../code-manifest.ts;
3
+ // plasticine-way mac/sync/DESIGN.md §3.3).
4
+ //
5
+ // pw-code-manifest write <dir> [--exclude <pattern>]... regenerate it
6
+ // pw-code-manifest check <dir> [--exclude <pattern>]... exit 1 when it is stale (run by verify)
7
+ // pw-code-manifest id <dir> [--exclude <pattern>]... print the id of the files as they are now
8
+ import { runCodeManifestCli } from '../code-manifest.js';
9
+ process.exitCode = await runCodeManifestCli(process.argv.slice(2), process.cwd());
@@ -0,0 +1,52 @@
1
+ /** Behind for this long, counted as above, before the first WARN. Expected is under 15 minutes. */
2
+ export declare const BEHIND_WARN_MS: number;
3
+ /** Further counted time before the WARN repeats. */
4
+ export declare const BEHIND_REWARN_MS: number;
5
+ export interface BehindState {
6
+ /** Counted time behind in this episode. */
7
+ behindMs: number;
8
+ /** Server time of the last report in this episode. */
9
+ lastAt: number;
10
+ /** behindMs at the last WARN in this episode, or null before the first. */
11
+ warnedAtMs: number | null;
12
+ }
13
+ export type BehindCause = 'switch_not_taken' | 'checkout_not_current';
14
+ /**
15
+ * Why a client is behind, or null when it is not. `loaded`: the code the client
16
+ * runs (null or absent when it does not report one, e.g. a version from before
17
+ * code reports, which counts as behind). `disk`: the verified code on the Mac's
18
+ * disk (null when it does not verify). `shipped`: the code this server deployed.
19
+ */
20
+ export declare function behindCause(report: {
21
+ loaded: string | null | undefined;
22
+ disk: string | null | undefined;
23
+ shipped: string;
24
+ }): BehindCause | null;
25
+ /** The gap cap for a client that reports every `intervalMs`. */
26
+ export declare function gapCapFor(intervalMs: number): number;
27
+ /** Fold one report into the episode. Returns null when the client is not behind (the episode ends). */
28
+ export declare function accrueBehind(prev: BehindState | null, report: {
29
+ behind: boolean;
30
+ now: number;
31
+ gapCapMs: number;
32
+ }): BehindState | null;
33
+ /** Whether this state warrants a WARN now; when it does, the returned state records it. */
34
+ export declare function shouldWarn(state: BehindState, limits?: {
35
+ thresholdMs?: number;
36
+ rewarnMs?: number;
37
+ }): {
38
+ warn: boolean;
39
+ state: BehindState;
40
+ };
41
+ /** accrueBehind then shouldWarn: the one call an app makes per report. */
42
+ export declare function recordBehind(prev: BehindState | null, report: {
43
+ behind: boolean;
44
+ now: number;
45
+ intervalMs: number;
46
+ }, limits?: {
47
+ thresholdMs?: number;
48
+ rewarnMs?: number;
49
+ }): {
50
+ state: BehindState | null;
51
+ warn: boolean;
52
+ };
@@ -0,0 +1,54 @@
1
+ // "Stayed behind" accounting for code that runs on Steve's Mac (plasticine-way
2
+ // mac/sync/DESIGN.md §3.6). Pure: each app persists the state per device and
3
+ // calls this from the request that carries the device's report.
4
+ //
5
+ // A client is behind when the code it runs is not the code the server
6
+ // deployed. The time that counts is time the client spent behind while
7
+ // REPORTING CONTINUOUSLY: a report adds the gap since the previous one only
8
+ // when that gap is at most `gapCapMs` (1.5 x the client's report interval).
9
+ // A laptop asleep, offline or travelling contributes nothing, however long
10
+ // (Steve, 2026-11-01: the laptop can easily be offline for 12 hours or more).
11
+ // All times are the server's clock.
12
+ /** Behind for this long, counted as above, before the first WARN. Expected is under 15 minutes. */
13
+ export const BEHIND_WARN_MS = 4 * 60 * 60 * 1000;
14
+ /** Further counted time before the WARN repeats. */
15
+ export const BEHIND_REWARN_MS = 24 * 60 * 60 * 1000;
16
+ /**
17
+ * Why a client is behind, or null when it is not. `loaded`: the code the client
18
+ * runs (null or absent when it does not report one, e.g. a version from before
19
+ * code reports, which counts as behind). `disk`: the verified code on the Mac's
20
+ * disk (null when it does not verify). `shipped`: the code this server deployed.
21
+ */
22
+ export function behindCause(report) {
23
+ if (report.loaded === report.shipped)
24
+ return null;
25
+ return report.disk === report.shipped ? 'switch_not_taken' : 'checkout_not_current';
26
+ }
27
+ /** The gap cap for a client that reports every `intervalMs`. */
28
+ export function gapCapFor(intervalMs) {
29
+ return Math.round(intervalMs * 1.5);
30
+ }
31
+ /** Fold one report into the episode. Returns null when the client is not behind (the episode ends). */
32
+ export function accrueBehind(prev, report) {
33
+ if (!report.behind)
34
+ return null;
35
+ if (!prev)
36
+ return { behindMs: 0, lastAt: report.now, warnedAtMs: null };
37
+ const gap = report.now - prev.lastAt;
38
+ const counted = gap > 0 && gap <= report.gapCapMs ? gap : 0;
39
+ return { behindMs: prev.behindMs + counted, lastAt: Math.max(prev.lastAt, report.now), warnedAtMs: prev.warnedAtMs };
40
+ }
41
+ /** Whether this state warrants a WARN now; when it does, the returned state records it. */
42
+ export function shouldWarn(state, limits = {}) {
43
+ const threshold = limits.thresholdMs ?? BEHIND_WARN_MS;
44
+ const rewarn = limits.rewarnMs ?? BEHIND_REWARN_MS;
45
+ const due = state.warnedAtMs === null ? state.behindMs >= threshold : state.behindMs - state.warnedAtMs >= rewarn;
46
+ return due ? { warn: true, state: { ...state, warnedAtMs: state.behindMs } } : { warn: false, state };
47
+ }
48
+ /** accrueBehind then shouldWarn: the one call an app makes per report. */
49
+ export function recordBehind(prev, report, limits = {}) {
50
+ const state = accrueBehind(prev, { behind: report.behind, now: report.now, gapCapMs: gapCapFor(report.intervalMs) });
51
+ if (!state)
52
+ return { state: null, warn: false };
53
+ return shouldWarn(state, limits);
54
+ }
@@ -0,0 +1,37 @@
1
+ export declare const CODE_MANIFEST_FILE = "mac-code.json";
2
+ export interface CodeManifest {
3
+ id: string;
4
+ files: Record<string, string>;
5
+ }
6
+ export interface CodeManifestOptions {
7
+ /** Paths to leave out, relative to the directory: `dir/` (a prefix), or a
8
+ * glob where `*` matches within one path segment and `**` across segments. */
9
+ exclude?: string[];
10
+ }
11
+ /** The id over a file list (the browser twin is in extension-self-update). */
12
+ export declare function codeIdOf(files: Record<string, string>): string;
13
+ export declare function isExcluded(path: string, exclude?: string[]): boolean;
14
+ /** Build the manifest for `dir` from git's tracked files as they are in the work tree. */
15
+ export declare function buildCodeManifest(dir: string, options?: CodeManifestOptions): Promise<CodeManifest>;
16
+ export declare function serializeCodeManifest(manifest: CodeManifest): string;
17
+ export declare function writeCodeManifest(dir: string, options?: CodeManifestOptions): Promise<CodeManifest>;
18
+ export interface CodeManifestCheck {
19
+ ok: boolean;
20
+ /** Paths added, removed or changed since the committed manifest was written. */
21
+ changed: string[];
22
+ committedId: string | null;
23
+ currentId: string;
24
+ }
25
+ /** Is the committed manifest current? (Run by each repo's verify.) */
26
+ export declare function checkCodeManifest(dir: string, options?: CodeManifestOptions): Promise<CodeManifestCheck>;
27
+ /** The committed manifest in `dir`, or null when absent or malformed. */
28
+ export declare function readCodeManifest(dir: string): Promise<CodeManifest | null>;
29
+ /**
30
+ * For a Node consumer reading its own directory (no git needed): the
31
+ * manifest's id when every listed file is present and matches and the id is
32
+ * the id of the list; null otherwise (a checkout in progress, a local edit, a
33
+ * missing or hand-edited manifest).
34
+ */
35
+ export declare function verifyCodeManifestOnDisk(dir: string): Promise<string | null>;
36
+ /** The `pw-code-manifest` command (src/bin/pw-code-manifest.ts). Returns the exit code. */
37
+ export declare function runCodeManifestCli(argv: string[], cwd: string, out?: (line: string) => void, err?: (line: string) => void): Promise<number>;
@@ -0,0 +1,176 @@
1
+ // Code manifests: a committed list of a directory's files and their sha256, with
2
+ // one id over the list (plasticine-way mac/sync/DESIGN.md §3.3).
3
+ //
4
+ // Steve's Mac runs extensions and daemons straight from git checkouts. Each of
5
+ // those consumers switches to new code only when the code on disk verifies
6
+ // against `mac-code.json` AND its id is the one the app's server says it
7
+ // deployed. So the id must be computable three ways that agree: here (the
8
+ // generator, the verify-time check, and server builds), in the browser
9
+ // (`extension-self-update`), and in a Node consumer reading its own files
10
+ // (`verifyCodeManifestOnDisk`).
11
+ //
12
+ // The id is sha256 over the sorted lines `<path>\0<sha256>\n`, first 16 hex
13
+ // characters. Paths are relative to the directory, `/`-separated, and are
14
+ // git's tracked files only, so untracked leftovers (.DS_Store, *.orig) never
15
+ // change it. The manifest file itself is never listed.
16
+ import { execFile } from 'node:child_process';
17
+ import { createHash } from 'node:crypto';
18
+ import { readFile, rename, writeFile } from 'node:fs/promises';
19
+ import { join } from 'node:path';
20
+ export const CODE_MANIFEST_FILE = 'mac-code.json';
21
+ function sha256Hex(data) {
22
+ return createHash('sha256').update(data).digest('hex');
23
+ }
24
+ /** The id over a file list (the browser twin is in extension-self-update). */
25
+ export function codeIdOf(files) {
26
+ const lines = Object.keys(files)
27
+ .sort()
28
+ .map((path) => `${path}\0${files[path]}\n`)
29
+ .join('');
30
+ return sha256Hex(lines).slice(0, 16);
31
+ }
32
+ function globToRegExp(glob) {
33
+ let out = '';
34
+ for (let i = 0; i < glob.length; i++) {
35
+ const c = glob[i];
36
+ if (c === '*' && glob[i + 1] === '*') {
37
+ out += '.*';
38
+ i++;
39
+ if (glob[i + 1] === '/')
40
+ i++;
41
+ }
42
+ else if (c === '*') {
43
+ out += '[^/]*';
44
+ }
45
+ else {
46
+ out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
47
+ }
48
+ }
49
+ return new RegExp(`^${out}$`);
50
+ }
51
+ export function isExcluded(path, exclude = []) {
52
+ return exclude.some((pattern) => pattern.endsWith('/') ? path.startsWith(pattern) : globToRegExp(pattern).test(path));
53
+ }
54
+ function gitLsFiles(dir) {
55
+ return new Promise((resolve, reject) => {
56
+ execFile('git', ['ls-files', '-z', '--', '.'], { cwd: dir, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => {
57
+ if (error)
58
+ reject(new Error(`git ls-files failed in ${dir}: ${error.message}`));
59
+ else
60
+ resolve(stdout.split('\0').filter((p) => p !== ''));
61
+ });
62
+ });
63
+ }
64
+ /** Build the manifest for `dir` from git's tracked files as they are in the work tree. */
65
+ export async function buildCodeManifest(dir, options = {}) {
66
+ const paths = (await gitLsFiles(dir))
67
+ .filter((path) => path !== CODE_MANIFEST_FILE && !isExcluded(path, options.exclude))
68
+ .sort();
69
+ const files = {};
70
+ for (const path of paths)
71
+ files[path] = sha256Hex(await readFile(join(dir, path)));
72
+ return { id: codeIdOf(files), files };
73
+ }
74
+ export function serializeCodeManifest(manifest) {
75
+ return `${JSON.stringify({ id: manifest.id, files: manifest.files }, null, 2)}\n`;
76
+ }
77
+ export async function writeCodeManifest(dir, options = {}) {
78
+ const manifest = await buildCodeManifest(dir, options);
79
+ const target = join(dir, CODE_MANIFEST_FILE);
80
+ await writeFile(`${target}.tmp`, serializeCodeManifest(manifest));
81
+ await rename(`${target}.tmp`, target);
82
+ return manifest;
83
+ }
84
+ /** Is the committed manifest current? (Run by each repo's verify.) */
85
+ export async function checkCodeManifest(dir, options = {}) {
86
+ const current = await buildCodeManifest(dir, options);
87
+ const committed = await readCodeManifest(dir);
88
+ const before = committed?.files ?? {};
89
+ const changed = [...new Set([...Object.keys(before), ...Object.keys(current.files)])]
90
+ .filter((path) => before[path] !== current.files[path])
91
+ .sort();
92
+ return { ok: committed !== null && changed.length === 0 && committed.id === current.id, changed, committedId: committed?.id ?? null, currentId: current.id };
93
+ }
94
+ /** The committed manifest in `dir`, or null when absent or malformed. */
95
+ export async function readCodeManifest(dir) {
96
+ let raw;
97
+ try {
98
+ raw = await readFile(join(dir, CODE_MANIFEST_FILE), 'utf8');
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ try {
104
+ const parsed = JSON.parse(raw);
105
+ if (!parsed || typeof parsed !== 'object')
106
+ return null;
107
+ const { id, files } = parsed;
108
+ if (typeof id !== 'string' || !files || typeof files !== 'object')
109
+ return null;
110
+ for (const value of Object.values(files))
111
+ if (typeof value !== 'string')
112
+ return null;
113
+ return { id, files: files };
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ /**
120
+ * For a Node consumer reading its own directory (no git needed): the
121
+ * manifest's id when every listed file is present and matches and the id is
122
+ * the id of the list; null otherwise (a checkout in progress, a local edit, a
123
+ * missing or hand-edited manifest).
124
+ */
125
+ export async function verifyCodeManifestOnDisk(dir) {
126
+ const manifest = await readCodeManifest(dir);
127
+ if (!manifest || codeIdOf(manifest.files) !== manifest.id)
128
+ return null;
129
+ for (const [path, expected] of Object.entries(manifest.files)) {
130
+ let bytes;
131
+ try {
132
+ bytes = await readFile(join(dir, path));
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ if (sha256Hex(bytes) !== expected)
138
+ return null;
139
+ }
140
+ return manifest.id;
141
+ }
142
+ /** The `pw-code-manifest` command (src/bin/pw-code-manifest.ts). Returns the exit code. */
143
+ export async function runCodeManifestCli(argv, cwd, out = (line) => console.log(line), err = (line) => console.error(line)) {
144
+ const [command, dirArg, ...rest] = argv;
145
+ const exclude = [];
146
+ for (let i = 0; i < rest.length; i++) {
147
+ if (rest[i] === '--exclude' && rest[i + 1] !== undefined)
148
+ exclude.push(rest[++i]);
149
+ else {
150
+ err(`pw-code-manifest: unexpected argument ${rest[i]}`);
151
+ return 2;
152
+ }
153
+ }
154
+ if (!dirArg || !['write', 'check', 'id'].includes(command ?? '')) {
155
+ err('usage: pw-code-manifest write|check|id <dir> [--exclude <pattern>]...');
156
+ return 2;
157
+ }
158
+ const dir = join(cwd, dirArg);
159
+ if (command === 'write') {
160
+ const manifest = await writeCodeManifest(dir, { exclude });
161
+ out(`${join(dirArg, CODE_MANIFEST_FILE)}: ${manifest.id} (${Object.keys(manifest.files).length} files)`);
162
+ return 0;
163
+ }
164
+ if (command === 'id') {
165
+ out((await buildCodeManifest(dir, { exclude })).id);
166
+ return 0;
167
+ }
168
+ const check = await checkCodeManifest(dir, { exclude });
169
+ if (check.ok) {
170
+ out(`${join(dirArg, CODE_MANIFEST_FILE)} is current (${check.currentId})`);
171
+ return 0;
172
+ }
173
+ err(`${join(dirArg, CODE_MANIFEST_FILE)} is stale${check.committedId ? '' : ' or missing'}: ${check.changed.slice(0, 20).join(', ') || '(id mismatch)'}`);
174
+ err(`Regenerate it: npx pw-code-manifest write ${dirArg}${exclude.map((p) => ` --exclude '${p}'`).join('')}`);
175
+ return 1;
176
+ }
@@ -0,0 +1,5 @@
1
+ export declare const DEPLOY_MARKER_FILE = "pw-deploy.lock";
2
+ /** The git common dir of `repoRoot`, without running git; null outside a checkout. */
3
+ export declare function gitCommonDir(repoRoot: string): Promise<string | null>;
4
+ /** Write the marker; returns a remover (a no-op outside a git checkout). Never throws. */
5
+ export declare function writeDeployMarker(repoRoot: string, now?: Date): Promise<() => Promise<void>>;
@@ -0,0 +1,63 @@
1
+ // The "a deploy is running from this checkout" marker (plasticine-way
2
+ // mac/sync/DESIGN.md §3.2).
3
+ //
4
+ // pw-deploy checks the tree once at preflight and then builds from the live
5
+ // work tree for minutes. On Steve's Mac a LaunchAgent fast-forwards the main
6
+ // checkouts of Mac-run repos (the containers' ~ is that folder), which could
7
+ // change files under a running build. So pw-deploy writes this marker into the
8
+ // git common dir — shared by the Mac's git and the containers', never part of
9
+ // the work tree — and removes it when the deploy ends; the job skips a repo
10
+ // whose marker is younger than 2 hours.
11
+ import { readFile, rm, stat, writeFile } from 'node:fs/promises';
12
+ import { hostname } from 'node:os';
13
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
14
+ export const DEPLOY_MARKER_FILE = 'pw-deploy.lock';
15
+ /** The git common dir of `repoRoot`, without running git; null outside a checkout. */
16
+ export async function gitCommonDir(repoRoot) {
17
+ const dotGit = join(repoRoot, '.git');
18
+ let info;
19
+ try {
20
+ info = await stat(dotGit);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ if (info.isDirectory())
26
+ return dotGit;
27
+ // A worktree: `.git` is a file "gitdir: <path>", and that dir names the common dir in `commondir`.
28
+ const pointer = (await readFile(dotGit, 'utf8')).match(/^gitdir:\s*(.+)\s*$/m)?.[1];
29
+ if (!pointer)
30
+ return null;
31
+ const gitDir = isAbsolute(pointer) ? pointer : resolve(repoRoot, pointer);
32
+ try {
33
+ const common = (await readFile(join(gitDir, 'commondir'), 'utf8')).trim();
34
+ return isAbsolute(common) ? common : resolve(gitDir, common);
35
+ }
36
+ catch {
37
+ return dirname(dirname(gitDir));
38
+ }
39
+ }
40
+ /** Write the marker; returns a remover (a no-op outside a git checkout). Never throws. */
41
+ export async function writeDeployMarker(repoRoot, now = new Date()) {
42
+ const dir = await gitCommonDir(repoRoot).catch(() => null);
43
+ if (!dir)
44
+ return async () => { };
45
+ const path = join(dir, DEPLOY_MARKER_FILE);
46
+ const body = `${JSON.stringify({ pid: process.pid, host: hostname(), repo: repoRoot, startedAt: now.toISOString() })}\n`;
47
+ try {
48
+ await writeFile(path, body);
49
+ }
50
+ catch {
51
+ return async () => { };
52
+ }
53
+ return async () => {
54
+ // Remove only our own marker: a second deploy may have replaced it.
55
+ try {
56
+ if ((await readFile(path, 'utf8')) === body)
57
+ await rm(path, { force: true });
58
+ }
59
+ catch {
60
+ // already gone
61
+ }
62
+ };
63
+ }
@@ -13,6 +13,7 @@ import { must, mustJson } from './exec.js';
13
13
  import { openEvidence, PhaseTimer } from './evidence.js';
14
14
  import { acquireLock, clearHold, publishRetainedAssets, readHold, readReleaseRecord, releaseLock, verifyLock, writeReleaseRecord, } from './gcs.js';
15
15
  import { consistentBaseSha, readServingState, SHA40 } from './plan.js';
16
+ import { writeDeployMarker } from './deploy-marker.js';
16
17
  const RELEASE_DEADLINE_MS = 25 * 60_000;
17
18
  const IMAGE_DIGEST = /@sha256:[0-9a-f]{64}$/;
18
19
  const MAX_DIRTY_PATHS = 20;
@@ -40,6 +41,8 @@ export async function runDeploy(options) {
40
41
  let trafficMoved = false;
41
42
  let evidenceDir = '';
42
43
  let lock = null;
44
+ // Tells the Mac's checkout-sync job not to fast-forward this checkout under the build (deploy-marker.ts).
45
+ const removeDeployMarker = await writeDeployMarker(options.repoRoot);
43
46
  try {
44
47
  // ---- preflight ----
45
48
  const config = await loadConfig(options.repoRoot);
@@ -440,5 +443,6 @@ export async function runDeploy(options) {
440
443
  finally {
441
444
  if (lock)
442
445
  await releaseLock(runner, lock);
446
+ await removeDeployMarker();
443
447
  }
444
448
  }
@@ -0,0 +1,68 @@
1
+ export declare const STABLE_MS: number;
2
+ export declare const RETRY_MS: number;
3
+ export declare const BUSY_LEASE_MS: number;
4
+ export declare const CODE_MANIFEST_FILE = "mac-code.json";
5
+ /** chrome.storage.session: { code, since } — the shipped code first seen verified on disk. */
6
+ export declare const SEEN_KEY = "selfUpdateSeen";
7
+ /** chrome.storage.local: { code, at } — the last reload this extension asked for. */
8
+ export declare const ATTEMPT_KEY = "selfUpdateAttempt";
9
+ /** chrome.storage.local: { code, version } — the code Chrome loaded (written when it loads). */
10
+ export declare const LOADED_KEY = "selfUpdateLoaded";
11
+ /** chrome.storage.session: epoch ms until which the extension is busy (an open overlay renews it). */
12
+ export declare const BUSY_LEASE_KEY = "selfUpdateBusyUntil";
13
+ interface Seen {
14
+ code: string;
15
+ since: number;
16
+ }
17
+ interface Attempt {
18
+ code: string;
19
+ at: number;
20
+ }
21
+ export declare function sha256Hex(data: ArrayBuffer | string): Promise<string>;
22
+ /** The id over a file list; the same algorithm as baselib code-manifest's codeIdOf. */
23
+ export declare function codeIdOf(files: Record<string, string>): Promise<string>;
24
+ /**
25
+ * The code on disk now: the manifest's id when every listed file is present and
26
+ * matches and the id is the id of the list; null otherwise (a merge in
27
+ * progress, a local edit, no manifest). An extension URL reads the folder as
28
+ * it is now (measured 2026-10-31).
29
+ */
30
+ export declare function diskCode(): Promise<string | null>;
31
+ /**
32
+ * Record the code Chrome just loaded. Call from runtime.onInstalled and
33
+ * runtime.onStartup — the moments Chrome reads the extension from disk — and
34
+ * nowhere else: a worker restarting after eviction runs the code loaded
35
+ * earlier, whatever the disk holds now.
36
+ */
37
+ export declare function recordLoadedCode(): Promise<string>;
38
+ /** The code this extension runs: the record from its last load (re-taken when the record predates this manifest). */
39
+ export declare function loadedCode(): Promise<string>;
40
+ /** Mark the extension busy for BUSY_LEASE_MS (an open overlay calls this through the worker every ~10 s). */
41
+ export declare function extendBusyLease(now?: number): Promise<void>;
42
+ export declare function busyLeaseActive(now?: number): Promise<boolean>;
43
+ /** Is one of the extension's own pages open in a tab (an options or approval page a reload would close)? */
44
+ export declare function extensionPageOpen(): Promise<boolean>;
45
+ export type UpdateAction = 'none' | 'wait' | 'reload';
46
+ /** The pure decision. `seen` and `attempt` are the stored records (or undefined). */
47
+ export declare function decideUpdate(input: {
48
+ loaded: string;
49
+ disk: string | null;
50
+ shipped: string | null | undefined;
51
+ seen: Seen | undefined;
52
+ attempt: Attempt | undefined;
53
+ now: number;
54
+ }): {
55
+ action: UpdateAction;
56
+ seen: Seen | null;
57
+ };
58
+ /**
59
+ * Check and reload when the rules allow. `shipped`: the code the server last
60
+ * said it deployed (null when unknown). `busy`: the extension's own work in
61
+ * flight; the busy lease and open extension pages are checked here too.
62
+ */
63
+ export declare function maybeSelfUpdate(options: {
64
+ busy: boolean;
65
+ shipped: string | null | undefined;
66
+ log?: (message: string) => void;
67
+ }): Promise<UpdateAction | 'busy'>;
68
+ export {};
@@ -0,0 +1,171 @@
1
+ // Self-update for an unpacked Chrome extension that Steve's Mac loads from a
2
+ // git checkout (plasticine-way mac/sync/DESIGN.md §3.4). Generalised from
3
+ // Reader's extension/self-update.js (1.3.7, 2026-10-31).
4
+ //
5
+ // The Mac job keeps the checkout at origin/main; Chrome reads an unpacked
6
+ // extension's code only when it loads it, so the worker must reload itself.
7
+ // It does so only into code that (1) verifies file by file against the
8
+ // committed `mac-code.json` on disk, (2) is the code the app's server says it
9
+ // deployed (`shipped`, from the server's answer to the extension's report),
10
+ // and (3) differs from the code this worker loaded. Then: only once that code
11
+ // has been on disk STABLE_MS, never while busy, and at most once per code per
12
+ // RETRY_MS so a reload that does not take cannot loop. chrome.runtime.reload()
13
+ // reloads from the folder and keeps chrome.storage; Chrome honours it only
14
+ // with Developer mode on, which Load unpacked needs anyway.
15
+ //
16
+ // Plain browser ESM with no imports: Lurch bundles it; Reader and Snip (no
17
+ // build step) commit a byte-identical copy of the compiled file, checked by a
18
+ // test in each repo.
19
+ export const STABLE_MS = 60 * 1000;
20
+ export const RETRY_MS = 6 * 60 * 60 * 1000;
21
+ export const BUSY_LEASE_MS = 30 * 1000;
22
+ export const CODE_MANIFEST_FILE = 'mac-code.json';
23
+ /** chrome.storage.session: { code, since } — the shipped code first seen verified on disk. */
24
+ export const SEEN_KEY = 'selfUpdateSeen';
25
+ /** chrome.storage.local: { code, at } — the last reload this extension asked for. */
26
+ export const ATTEMPT_KEY = 'selfUpdateAttempt';
27
+ /** chrome.storage.local: { code, version } — the code Chrome loaded (written when it loads). */
28
+ export const LOADED_KEY = 'selfUpdateLoaded';
29
+ /** chrome.storage.session: epoch ms until which the extension is busy (an open overlay renews it). */
30
+ export const BUSY_LEASE_KEY = 'selfUpdateBusyUntil';
31
+ function hex(bytes) {
32
+ return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, '0')).join('');
33
+ }
34
+ export async function sha256Hex(data) {
35
+ const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
36
+ return hex(await crypto.subtle.digest('SHA-256', bytes));
37
+ }
38
+ /** The id over a file list; the same algorithm as baselib code-manifest's codeIdOf. */
39
+ export async function codeIdOf(files) {
40
+ const lines = Object.keys(files)
41
+ .sort()
42
+ .map((path) => `${path}\0${files[path]}\n`)
43
+ .join('');
44
+ return (await sha256Hex(lines)).slice(0, 16);
45
+ }
46
+ async function readFile(path) {
47
+ try {
48
+ const res = await fetch(chrome.runtime.getURL(path), { cache: 'no-store' });
49
+ if (!res.ok)
50
+ return null;
51
+ return await res.arrayBuffer();
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /**
58
+ * The code on disk now: the manifest's id when every listed file is present and
59
+ * matches and the id is the id of the list; null otherwise (a merge in
60
+ * progress, a local edit, no manifest). An extension URL reads the folder as
61
+ * it is now (measured 2026-10-31).
62
+ */
63
+ export async function diskCode() {
64
+ const raw = await readFile(CODE_MANIFEST_FILE);
65
+ if (!raw)
66
+ return null;
67
+ let manifest;
68
+ try {
69
+ manifest = JSON.parse(new TextDecoder().decode(raw));
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ const { id, files } = manifest;
75
+ if (typeof id !== 'string' || !files || typeof files !== 'object')
76
+ return null;
77
+ const list = files;
78
+ for (const value of Object.values(list))
79
+ if (typeof value !== 'string')
80
+ return null;
81
+ if ((await codeIdOf(list)) !== id)
82
+ return null;
83
+ for (const [path, expected] of Object.entries(list)) {
84
+ const bytes = await readFile(path);
85
+ if (!bytes || (await sha256Hex(bytes)) !== expected)
86
+ return null;
87
+ }
88
+ return id;
89
+ }
90
+ /**
91
+ * Record the code Chrome just loaded. Call from runtime.onInstalled and
92
+ * runtime.onStartup — the moments Chrome reads the extension from disk — and
93
+ * nowhere else: a worker restarting after eviction runs the code loaded
94
+ * earlier, whatever the disk holds now.
95
+ */
96
+ export async function recordLoadedCode() {
97
+ const code = (await diskCode()) ?? 'unverified';
98
+ const record = { code, version: chrome.runtime.getManifest().version };
99
+ await chrome.storage.local.set({ [LOADED_KEY]: record });
100
+ return code;
101
+ }
102
+ /** The code this extension runs: the record from its last load (re-taken when the record predates this manifest). */
103
+ export async function loadedCode() {
104
+ const { [LOADED_KEY]: stored } = await chrome.storage.local.get(LOADED_KEY);
105
+ const record = stored;
106
+ if (record && typeof record.code === 'string' && record.version === chrome.runtime.getManifest().version)
107
+ return record.code;
108
+ return recordLoadedCode();
109
+ }
110
+ /** Mark the extension busy for BUSY_LEASE_MS (an open overlay calls this through the worker every ~10 s). */
111
+ export async function extendBusyLease(now = Date.now()) {
112
+ await chrome.storage.session.set({ [BUSY_LEASE_KEY]: now + BUSY_LEASE_MS });
113
+ }
114
+ export async function busyLeaseActive(now = Date.now()) {
115
+ const { [BUSY_LEASE_KEY]: until } = await chrome.storage.session.get(BUSY_LEASE_KEY);
116
+ return typeof until === 'number' && until > now;
117
+ }
118
+ /** Is one of the extension's own pages open in a tab (an options or approval page a reload would close)? */
119
+ export async function extensionPageOpen() {
120
+ if (!chrome.runtime.getContexts)
121
+ return false;
122
+ try {
123
+ return (await chrome.runtime.getContexts({ contextTypes: ['TAB'] })).length > 0;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /** The pure decision. `seen` and `attempt` are the stored records (or undefined). */
130
+ export function decideUpdate(input) {
131
+ const { loaded, disk, shipped, seen, attempt, now } = input;
132
+ if (!shipped || !disk || disk !== shipped || disk === loaded)
133
+ return { action: 'none', seen: null };
134
+ const since = seen && seen.code === disk ? seen.since : now;
135
+ const record = { code: disk, since };
136
+ if (now - since < STABLE_MS)
137
+ return { action: 'wait', seen: record };
138
+ if (attempt && attempt.code === disk && now - attempt.at < RETRY_MS)
139
+ return { action: 'none', seen: record };
140
+ return { action: 'reload', seen: record };
141
+ }
142
+ /**
143
+ * Check and reload when the rules allow. `shipped`: the code the server last
144
+ * said it deployed (null when unknown). `busy`: the extension's own work in
145
+ * flight; the busy lease and open extension pages are checked here too.
146
+ */
147
+ export async function maybeSelfUpdate(options) {
148
+ const loaded = await loadedCode();
149
+ const disk = await diskCode();
150
+ const { [SEEN_KEY]: seen } = await chrome.storage.session.get(SEEN_KEY);
151
+ const { [ATTEMPT_KEY]: attempt } = await chrome.storage.local.get(ATTEMPT_KEY);
152
+ const now = Date.now();
153
+ const decision = decideUpdate({ loaded, disk, shipped: options.shipped, seen: seen, attempt: attempt, now });
154
+ const prior = seen;
155
+ if (decision.seen) {
156
+ if (!prior || prior.code !== decision.seen.code)
157
+ await chrome.storage.session.set({ [SEEN_KEY]: decision.seen });
158
+ }
159
+ else if (prior) {
160
+ await chrome.storage.session.remove(SEEN_KEY);
161
+ }
162
+ if (decision.action !== 'reload' || !decision.seen)
163
+ return decision.action;
164
+ const target = decision.seen.code;
165
+ if (options.busy || (await busyLeaseActive(now)) || (await extensionPageOpen()))
166
+ return 'busy';
167
+ options.log?.(`code ${target} is deployed and on disk (running ${loaded}): reloading.`);
168
+ await chrome.storage.local.set({ [ATTEMPT_KEY]: { code: target, at: now } });
169
+ chrome.runtime.reload();
170
+ return 'reload';
171
+ }
@@ -45,6 +45,10 @@ export declare class HubNotifyBatch {
45
45
  private readonly notifier;
46
46
  private readonly hints;
47
47
  constructor(notifier: HubNotifier);
48
+ /** Coalesced per (user, kind), as design §5.3 says, except `notification`,
49
+ * whose payloads differ in meaning: only exact duplicates of those fold.
50
+ * A folded `work` keeps the first hint's ref (refs are optional pickup
51
+ * shortcuts; the daemon re-reads its queue either way). */
48
52
  add(hint: AppHint): void;
49
53
  /** Send every distinct hint (concurrently, each within the notifier's
50
54
  * bound) and empty the batch. Never throws. */
@@ -81,6 +81,8 @@ export class HubNotifier {
81
81
  body: JSON.stringify({ ...hint, app: this.app }),
82
82
  signal: AbortSignal.timeout(this.timeoutMs),
83
83
  });
84
+ // An unread body holds the connection until garbage collection.
85
+ await res.body?.cancel().catch(() => { });
84
86
  if (res.status === 202)
85
87
  return null;
86
88
  // 400/401/403/413: the hint or the grant is wrong, not the network.
@@ -97,8 +99,12 @@ export class HubNotifyBatch {
97
99
  constructor(notifier) {
98
100
  this.notifier = notifier;
99
101
  }
102
+ /** Coalesced per (user, kind), as design §5.3 says, except `notification`,
103
+ * whose payloads differ in meaning: only exact duplicates of those fold.
104
+ * A folded `work` keeps the first hint's ref (refs are optional pickup
105
+ * shortcuts; the daemon re-reads its queue either way). */
100
106
  add(hint) {
101
- const key = JSON.stringify([hint.user, hint.kind, hint.ref ?? null, hint.payload ?? null]);
107
+ const key = hint.kind === 'notification' ? JSON.stringify([hint.user, hint.kind, hint.ref ?? null, hint.payload ?? null]) : JSON.stringify([hint.user, hint.kind]);
102
108
  if (!this.hints.has(key))
103
109
  this.hints.set(key, hint);
104
110
  }
@@ -21,6 +21,15 @@ export declare const DAEMON_LOOKBACK_MAX_MS = 60000;
21
21
  /** 401s per client address per minute on the token path before further
22
22
  * tokens from it are refused without a database lookup. */
23
23
  export declare const TOKEN_FAILURES_PER_MIN = 20;
24
+ /** Cross-copy publishes in flight at once; past it a publish is dropped (a
25
+ * lost hint is tolerated) so a slow database cannot queue publishes ahead
26
+ * of the token lookups that share the pool. */
27
+ export declare const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
28
+ /** The largest frame a browser may send (its frames are `connect` and `ping`). */
29
+ export declare const HUB_SOCKET_MAX_PAYLOAD = 4096;
30
+ /** A socket that has sent nothing (clients ping every 30 s) for this long is
31
+ * closed: a half-dead socket would otherwise hold a slot for an hour. */
32
+ export declare const HUB_SOCKET_IDLE_MS = 180000;
24
33
  export interface HubOptions {
25
34
  pool: pg.Pool;
26
35
  logger: Logger;
@@ -40,6 +49,13 @@ export interface HubOptions {
40
49
  * origins (local development and Playwright). */
41
50
  allowLocalOrigins?: boolean;
42
51
  upgradeWebSocket: UpgradeWebSocket;
52
+ /** The upgrader's `ws` server (`createNodeWebSocket(...).wss`): the hub
53
+ * caps its incoming frame size (ws's default is 100 MiB). */
54
+ webSocketServer?: {
55
+ options: {
56
+ maxPayload?: number;
57
+ };
58
+ };
43
59
  /** Passed through to requireIdentity (authUrl, whoamiFetch, verifyClient,
44
60
  * rateLimitPerMin…). The hub admits invited users for sockets. */
45
61
  identity?: Partial<Omit<RequireIdentityOptions, 'audience' | 'grants' | 'logger' | 'userAccess'>>;
@@ -47,6 +63,11 @@ export interface HubOptions {
47
63
  bridge?: Partial<Pick<DbBroadcastBridgeOptions, 'heartbeatMs' | 'heartbeatTimeoutMs' | 'reconnectDelaysMs'>>;
48
64
  /** Monotonic milliseconds (tests). */
49
65
  now?: () => number;
66
+ /** Tests: the idle-socket limit and sweep interval. */
67
+ socketIdle?: {
68
+ idleMs: number;
69
+ sweepMs: number;
70
+ };
50
71
  }
51
72
  export interface Hub {
52
73
  /** Start the cross-copy bridge. Call before the server accepts
@@ -58,7 +58,18 @@ export const DAEMON_LOOKBACK_MAX_MS = 60_000;
58
58
  /** 401s per client address per minute on the token path before further
59
59
  * tokens from it are refused without a database lookup. */
60
60
  export const TOKEN_FAILURES_PER_MIN = 20;
61
+ /** Cross-copy publishes in flight at once; past it a publish is dropped (a
62
+ * lost hint is tolerated) so a slow database cannot queue publishes ahead
63
+ * of the token lookups that share the pool. */
64
+ export const HUB_PUBLISHES_IN_FLIGHT_MAX = 16;
65
+ /** The largest frame a browser may send (its frames are `connect` and `ping`). */
66
+ export const HUB_SOCKET_MAX_PAYLOAD = 4096;
67
+ /** A socket that has sent nothing (clients ping every 30 s) for this long is
68
+ * closed: a half-dead socket would otherwise hold a slot for an hour. */
69
+ export const HUB_SOCKET_IDLE_MS = 180_000;
70
+ const HUB_SOCKET_SWEEP_MS = 30_000;
61
71
  const TOKEN_FORMAT = /^hubt_[A-Za-z0-9_-]{43}$/;
72
+ const WEBSOCKET_KEY = /^[+/0-9A-Za-z]{22}==$/;
62
73
  const TOKEN_HASH_FORMAT = /^[0-9a-f]{64}$/;
63
74
  const DAEMON_ID_FORMAT = /^[A-Za-z0-9_-]{8,64}$/;
64
75
  export function createHub(app, opts) {
@@ -74,6 +85,8 @@ export function createHub(app, opts) {
74
85
  userAccess: 'invited',
75
86
  });
76
87
  const serviceAccount = new RegExp(`^([a-z][a-z0-9-]*)-svc@${escapeRegExp(opts.serviceAccountDomain)}$`);
88
+ if (opts.webSocketServer)
89
+ opts.webSocketServer.options.maxPayload = HUB_SOCKET_MAX_PAYLOAD;
77
90
  // app → user → sockets
78
91
  const sockets = new Map();
79
92
  const waits = new Map();
@@ -82,6 +95,8 @@ export function createHub(app, opts) {
82
95
  // when the bridge last recovered: hints sent while it was deaf are unknowable
83
96
  let lastBlanketAt = -Infinity;
84
97
  const publishes = new Set();
98
+ let droppedPublishes = 0;
99
+ let lastDropWarnAt = -Infinity;
85
100
  const tokenFailures = new FailureCounter(TOKEN_FAILURES_PER_MIN);
86
101
  let stopping = false;
87
102
  const bridge = new DbBroadcastBridge({
@@ -141,6 +156,15 @@ export function createHub(app, opts) {
141
156
  waiter.finish(workResponse());
142
157
  }
143
158
  function publish(hint) {
159
+ if (publishes.size >= HUB_PUBLISHES_IN_FLIGHT_MAX) {
160
+ droppedPublishes++;
161
+ if (now() - lastDropWarnAt >= 10_000) {
162
+ lastDropWarnAt = now();
163
+ logger.warn('hub publishes dropped', { dropped: droppedPublishes, inFlight: publishes.size });
164
+ droppedPublishes = 0;
165
+ }
166
+ return;
167
+ }
144
168
  const p = withinMs(bridge.notify({ hint }), HUB_PUBLISH_TIMEOUT_MS, 'hub publish')
145
169
  .catch((error) => {
146
170
  logger.warn('hub publish failed', { app: hint.app, kind: hint.kind, error: serializeError(error) });
@@ -184,6 +208,8 @@ export function createHub(app, opts) {
184
208
  return c.body(null, 202);
185
209
  });
186
210
  app.post('/api/register-daemon', identity, async (c) => {
211
+ if (stopping)
212
+ return c.json({ error: 'hub restarting' }, 503);
187
213
  const caller = callerApp(c);
188
214
  if (!caller)
189
215
  return c.json({ error: 'app service accounts only' }, 403);
@@ -228,6 +254,9 @@ export function createHub(app, opts) {
228
254
  /** Resolves the waiting app: from a daemon hub token, or (no token) from
229
255
  * `?app=` for a granted service caller. Cookies are refused (review B1). */
230
256
  const daemonAuth = createMiddleware(async (c, next) => {
257
+ // The lookback and the wait both count from arrival, before the token
258
+ // lookup or identity check, whose time would otherwise open a gap.
259
+ c.set('hubArrivedAt', now());
231
260
  const token = c.req.header('x-hub-token');
232
261
  if (token === undefined) {
233
262
  const { admitted, response } = await runIdentity(c);
@@ -280,13 +309,16 @@ export function createHub(app, opts) {
280
309
  if (stopping)
281
310
  return c.body(null, 204);
282
311
  // A work hint between the daemon's direct request and this wait being
283
- // parked would otherwise be lost to the 30 s timer (review S2).
284
- const t = now();
312
+ // parked would otherwise be lost to the 30 s timer (review S2). The
313
+ // daemon's lookbackMs covers from its direct request's start to its
314
+ // arrival here; this copy has seen every hint since arrival.
315
+ const since = c.var.hubArrivedAt - lookbackMs;
285
316
  const last = lastWork.get(appName);
286
- if (lookbackMs > 0 && last && t - last.at <= lookbackMs)
317
+ if (last && last.at >= since)
287
318
  return workResponse(last.ref);
288
- if (lookbackMs > 0 && t - lastBlanketAt <= lookbackMs)
319
+ if (lastBlanketAt >= since)
289
320
  return workResponse();
321
+ const remainingMs = Math.max(0, waitSeconds * 1000 - (now() - c.var.hubArrivedAt));
290
322
  return new Promise((resolve) => {
291
323
  let set = waits.get(appName);
292
324
  if (!set)
@@ -306,7 +338,7 @@ export function createHub(app, opts) {
306
338
  },
307
339
  };
308
340
  const onAbort = () => waiter.finish(new Response(null, { status: 204 }));
309
- const timer = setTimeout(() => waiter.finish(new Response(null, { status: 204 })), waitSeconds * 1000);
341
+ const timer = setTimeout(() => waiter.finish(new Response(null, { status: 204 })), remainingMs);
310
342
  waiters.add(waiter);
311
343
  if (signal.aborted)
312
344
  onAbort();
@@ -322,6 +354,15 @@ export function createHub(app, opts) {
322
354
  const appName = c.req.query('app');
323
355
  if (!isAppName(appName))
324
356
  return c.json({ error: 'app required' }, 400);
357
+ // Only a well-formed handshake may reach the upgrader: @hono/node-ws
358
+ // keeps the request of an upgrade that ws aborts (a bad key or version)
359
+ // for the life of the process.
360
+ const version = c.req.header('sec-websocket-version');
361
+ if ((c.req.header('upgrade') ?? '').toLowerCase() !== 'websocket' ||
362
+ !WEBSOCKET_KEY.test(c.req.header('sec-websocket-key') ?? '') ||
363
+ (version !== '13' && version !== '8')) {
364
+ return c.json({ error: 'websocket handshake required' }, 400);
365
+ }
325
366
  if (!originAllowed(c.req.header('origin'), appName, opts)) {
326
367
  logger.warn('hub socket origin refused', { app: appName, origin: c.req.header('origin') ?? null });
327
368
  return c.json({ error: 'origin not allowed' }, 403);
@@ -332,6 +373,12 @@ export function createHub(app, opts) {
332
373
  const { admitted, response } = await runIdentity(c);
333
374
  if (!admitted)
334
375
  refusals.set(c, response?.status ?? 401);
376
+ // The client may have gone while its identity was checked; the upgrader
377
+ // would then keep its request forever (as above). Nothing awaits between
378
+ // this check and the upgrade.
379
+ const incoming = c.env?.incoming;
380
+ if (incoming?.socket && !(incoming.socket.readable && incoming.socket.writable))
381
+ return c.body(null, 400); // no one reads it
335
382
  await next();
336
383
  });
337
384
  app.get('/api/ws', originCheck, socketIdentity, opts.upgradeWebSocket((c) => {
@@ -367,7 +414,7 @@ export function createHub(app, opts) {
367
414
  ws.close(4403, 'user identity required');
368
415
  return;
369
416
  }
370
- socket = { ws, app: appName, user };
417
+ socket = { ws, app: appName, user, lastHeardAt: now() };
371
418
  let users = sockets.get(appName);
372
419
  if (!users)
373
420
  sockets.set(appName, (users = new Map()));
@@ -379,6 +426,7 @@ export function createHub(app, opts) {
379
426
  onMessage: (evt, ws) => {
380
427
  if (!socket)
381
428
  return;
429
+ socket.lastHeardAt = now();
382
430
  let message;
383
431
  try {
384
432
  message = JSON.parse(String(evt.data));
@@ -397,10 +445,34 @@ export function createHub(app, opts) {
397
445
  onError: remove,
398
446
  };
399
447
  }));
448
+ const idle = opts.socketIdle ?? { idleMs: HUB_SOCKET_IDLE_MS, sweepMs: HUB_SOCKET_SWEEP_MS };
449
+ let sweeper;
450
+ function sweepIdleSockets() {
451
+ const t = now();
452
+ for (const users of sockets.values()) {
453
+ for (const set of users.values()) {
454
+ for (const socket of set) {
455
+ if (t - socket.lastHeardAt < idle.idleMs)
456
+ continue;
457
+ try {
458
+ socket.ws.close(4408, 'idle');
459
+ }
460
+ catch {
461
+ // already closing
462
+ }
463
+ }
464
+ }
465
+ }
466
+ }
400
467
  return {
401
- start: () => bridge.start(),
468
+ async start() {
469
+ sweeper = setInterval(sweepIdleSockets, idle.sweepMs);
470
+ sweeper.unref?.();
471
+ await bridge.start();
472
+ },
402
473
  async stop() {
403
474
  stopping = true;
475
+ clearInterval(sweeper);
404
476
  for (const users of sockets.values()) {
405
477
  for (const set of users.values()) {
406
478
  for (const socket of set) {
@@ -417,7 +489,9 @@ export function createHub(app, opts) {
417
489
  for (const set of waits.values())
418
490
  for (const waiter of [...set])
419
491
  waiter.finish(new Response(null, { status: 204 }));
420
- await Promise.allSettled([...publishes]);
492
+ // A notify racing shutdown may add a publish while these settle.
493
+ while (publishes.size > 0)
494
+ await Promise.allSettled([...publishes]);
421
495
  await bridge.stop();
422
496
  },
423
497
  stats() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.47.0",
3
+ "version": "3.48.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -88,6 +88,18 @@
88
88
  "./connector": {
89
89
  "types": "./dist/connector.d.ts",
90
90
  "default": "./dist/connector.js"
91
+ },
92
+ "./code-manifest": {
93
+ "types": "./dist/code-manifest.d.ts",
94
+ "default": "./dist/code-manifest.js"
95
+ },
96
+ "./client-version": {
97
+ "types": "./dist/client-version.d.ts",
98
+ "default": "./dist/client-version.js"
99
+ },
100
+ "./extension-self-update": {
101
+ "types": "./dist/extension-self-update.d.ts",
102
+ "default": "./dist/extension-self-update.js"
91
103
  }
92
104
  },
93
105
  "bin": {
@@ -95,7 +107,8 @@
95
107
  "pw-deploy": "./dist/bin/pw-deploy.js",
96
108
  "pw-logs": "./dist/bin/pw-logs.js",
97
109
  "pw-rollback": "./dist/bin/pw-rollback.js",
98
- "pw-sql": "./dist/bin/pw-sql.js"
110
+ "pw-sql": "./dist/bin/pw-sql.js",
111
+ "pw-code-manifest": "./dist/bin/pw-code-manifest.js"
99
112
  },
100
113
  "engines": {
101
114
  "node": "22.x"