@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 +12 -2
- package/dist/bin/pw-code-manifest.d.ts +2 -0
- package/dist/bin/pw-code-manifest.js +9 -0
- package/dist/client-version.d.ts +52 -0
- package/dist/client-version.js +54 -0
- package/dist/code-manifest.d.ts +37 -0
- package/dist/code-manifest.js +176 -0
- package/dist/deploy/deploy-marker.d.ts +5 -0
- package/dist/deploy/deploy-marker.js +63 -0
- package/dist/deploy/deploy.js +4 -0
- package/dist/extension-self-update.d.ts +68 -0
- package/dist/extension-self-update.js +171 -0
- package/dist/hub/notify.d.ts +4 -0
- package/dist/hub/notify.js +7 -1
- package/dist/hub/server.d.ts +21 -0
- package/dist/hub/server.js +82 -8
- package/package.json +15 -2
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`,
|
|
10
|
-
`slow-fetch`, `sync-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,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
|
+
}
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/hub/notify.d.ts
CHANGED
|
@@ -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. */
|
package/dist/hub/notify.js
CHANGED
|
@@ -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
|
}
|
package/dist/hub/server.d.ts
CHANGED
|
@@ -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
|
package/dist/hub/server.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
317
|
+
if (last && last.at >= since)
|
|
287
318
|
return workResponse(last.ref);
|
|
288
|
-
if (
|
|
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 })),
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"
|