@lelouchhe/webagent 0.4.0 → 0.6.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 +1 -0
- package/config.toml +12 -3
- package/dist/fonts/temml/Temml.woff2 +0 -0
- package/dist/index.html +4 -4
- package/dist/js/app.HJKQURHR.js +4 -0
- package/dist/js/chunk.ON2W5X2T.js +173 -0
- package/dist/js/viewer.VY4PO2O5.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +4 -3
- package/dist/{styles.012p32dz.css → styles.01ky3cex.css} +416 -3
- package/lib/auth-middleware.js +3 -0
- package/lib/bridge-event-config.js +29 -0
- package/lib/bridge.js +9 -7
- package/lib/client-registry.js +104 -15
- package/lib/config.js +12 -5
- package/lib/event-handler.js +3 -1
- package/lib/image-dimensions.js +64 -0
- package/lib/model-picker.js +17 -0
- package/lib/preflight.js +41 -22
- package/lib/push-service.js +21 -69
- package/lib/routes.js +140 -19
- package/lib/server.js +19 -23
- package/lib/session-manager.js +1 -1
- package/lib/startup-checks.js +1 -0
- package/lib/store.js +15 -3
- package/lib/title-service.js +3 -19
- package/package.json +3 -2
- package/dist/js/app.GSAIYHML.js +0 -4
- package/dist/js/chunk.CGWFHJI2.js +0 -76
- package/dist/js/viewer.6DT53STL.js +0 -1
package/lib/client-registry.js
CHANGED
|
@@ -3,28 +3,37 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tracks per-client metadata that survives SSE disconnect:
|
|
5
5
|
* - capabilities advertised by the client on /hello.
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
6
|
+
* - visible / active / visibleSince: identity-layer visibility state used
|
|
7
|
+
* by TTS dispatch (voice branch) and push suppression (main).
|
|
8
8
|
*
|
|
9
|
-
* Lifecycle: clients call /hello on SSE connect (register) and
|
|
10
|
-
*
|
|
11
|
-
* in the registry until an explicit
|
|
12
|
-
* responsibility). This lets visibility
|
|
9
|
+
* Lifecycle: clients call /hello on SSE connect (register) and POST
|
|
10
|
+
* /visibility on visibilitychange + 15s heartbeat. SSE disconnect does
|
|
11
|
+
* not remove a client — it stays in the registry until an explicit
|
|
12
|
+
* /goodbye or TTL eviction (caller's responsibility). This lets visibility
|
|
13
|
+
* state outlive transient drops.
|
|
13
14
|
*/
|
|
14
15
|
export class ClientRegistry {
|
|
15
16
|
clients = new Map();
|
|
17
|
+
visibilityTtlMs;
|
|
18
|
+
now;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
|
|
21
|
+
this.now = options.now ?? Date.now;
|
|
22
|
+
}
|
|
16
23
|
register(id, data) {
|
|
17
24
|
const existing = this.clients.get(id);
|
|
18
25
|
if (existing) {
|
|
19
26
|
existing.capabilities = data.capabilities;
|
|
20
|
-
existing.lastSeen =
|
|
27
|
+
existing.lastSeen = this.now();
|
|
21
28
|
return existing;
|
|
22
29
|
}
|
|
23
30
|
const entry = {
|
|
24
31
|
id,
|
|
25
32
|
capabilities: data.capabilities,
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
visible: false,
|
|
34
|
+
active: null,
|
|
35
|
+
visibleSince: 0,
|
|
36
|
+
lastSeen: this.now(),
|
|
28
37
|
};
|
|
29
38
|
this.clients.set(id, entry);
|
|
30
39
|
return entry;
|
|
@@ -32,24 +41,104 @@ export class ClientRegistry {
|
|
|
32
41
|
remove(id) {
|
|
33
42
|
this.clients.delete(id);
|
|
34
43
|
}
|
|
35
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Atomic visibility setter. Mirrors PushService.updateClient semantics:
|
|
46
|
+
* - `visible` omitted = preserve; bool = set (and stamp/clear visibleSince).
|
|
47
|
+
* - `active` omitted = preserve; null = clear; string = replace.
|
|
48
|
+
* - Returns `becameVisibleFor=X` only on first transition into
|
|
49
|
+
* (visible:true, active:X) — heartbeat refreshes return null so
|
|
50
|
+
* callers can fire edge-triggered side effects exactly once.
|
|
51
|
+
* - Session-switch while visible (active X→Y) restarts the TTL clock
|
|
52
|
+
* even when the patch doesn't carry an explicit visible:true.
|
|
53
|
+
*
|
|
54
|
+
* No-op on unknown client.
|
|
55
|
+
*/
|
|
56
|
+
setVisibility(id, patch) {
|
|
36
57
|
const entry = this.clients.get(id);
|
|
37
58
|
if (!entry)
|
|
38
|
-
return;
|
|
39
|
-
entry.
|
|
40
|
-
|
|
59
|
+
return { becameVisibleFor: null };
|
|
60
|
+
const wasVisibleForSession = entry.visible && entry.active != null ? entry.active : null;
|
|
61
|
+
if (patch.visible !== undefined) {
|
|
62
|
+
entry.visible = patch.visible;
|
|
63
|
+
entry.visibleSince = patch.visible ? this.now() : 0;
|
|
64
|
+
}
|
|
65
|
+
if (patch.active !== undefined) {
|
|
66
|
+
entry.active = patch.active;
|
|
67
|
+
}
|
|
68
|
+
const becameVisibleFor = entry.visible &&
|
|
69
|
+
entry.active != null &&
|
|
70
|
+
entry.active !== wasVisibleForSession
|
|
71
|
+
? entry.active
|
|
72
|
+
: null;
|
|
73
|
+
if (becameVisibleFor) {
|
|
74
|
+
// Any transition into "visible + active=X" restarts TTL — including
|
|
75
|
+
// session-switches that arrive without an explicit visible:true.
|
|
76
|
+
entry.visibleSince = this.now();
|
|
77
|
+
}
|
|
78
|
+
entry.lastSeen = this.now();
|
|
79
|
+
return { becameVisibleFor };
|
|
80
|
+
}
|
|
81
|
+
/** Is this specific client currently visible & viewing `sessionId` & fresh? */
|
|
82
|
+
isVisibleForSession(id, sessionId) {
|
|
83
|
+
const entry = this.clients.get(id);
|
|
84
|
+
if (!entry)
|
|
85
|
+
return false;
|
|
86
|
+
if (!entry.visible)
|
|
87
|
+
return false;
|
|
88
|
+
if (entry.active !== sessionId)
|
|
89
|
+
return false;
|
|
90
|
+
if (this.now() - entry.visibleSince > this.visibilityTtlMs)
|
|
91
|
+
return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** Is at least one fresh visible client viewing `sessionId`? */
|
|
95
|
+
isSessionVisibleToAnyClient(sessionId) {
|
|
96
|
+
const now = this.now();
|
|
97
|
+
for (const e of this.clients.values()) {
|
|
98
|
+
if (!e.visible)
|
|
99
|
+
continue;
|
|
100
|
+
if (e.active !== sessionId)
|
|
101
|
+
continue;
|
|
102
|
+
if (now - e.visibleSince > this.visibilityTtlMs)
|
|
103
|
+
continue;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/** Is this specific client currently fresh-visible (any session)? */
|
|
109
|
+
isClientVisible(id) {
|
|
110
|
+
const entry = this.clients.get(id);
|
|
111
|
+
if (!entry)
|
|
112
|
+
return false;
|
|
113
|
+
if (!entry.visible)
|
|
114
|
+
return false;
|
|
115
|
+
if (this.now() - entry.visibleSince > this.visibilityTtlMs)
|
|
116
|
+
return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
/** Is at least one fresh visible client connected (any session)? */
|
|
120
|
+
hasAnyVisibleClient() {
|
|
121
|
+
const now = this.now();
|
|
122
|
+
for (const e of this.clients.values()) {
|
|
123
|
+
if (!e.visible)
|
|
124
|
+
continue;
|
|
125
|
+
if (now - e.visibleSince > this.visibilityTtlMs)
|
|
126
|
+
continue;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
41
130
|
}
|
|
42
131
|
updateCapabilities(id, caps) {
|
|
43
132
|
const entry = this.clients.get(id);
|
|
44
133
|
if (!entry)
|
|
45
134
|
return;
|
|
46
135
|
entry.capabilities = caps;
|
|
47
|
-
entry.lastSeen =
|
|
136
|
+
entry.lastSeen = this.now();
|
|
48
137
|
}
|
|
49
138
|
touch(id) {
|
|
50
139
|
const entry = this.clients.get(id);
|
|
51
140
|
if (entry)
|
|
52
|
-
entry.lastSeen =
|
|
141
|
+
entry.lastSeen = this.now();
|
|
53
142
|
}
|
|
54
143
|
get(id) {
|
|
55
144
|
return this.clients.get(id);
|
package/lib/config.js
CHANGED
|
@@ -3,6 +3,13 @@ import { parse as parseTOML } from "smol-toml";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
export const ConfigSchema = z.object({
|
|
5
5
|
port: z.number().int().positive().default(6800),
|
|
6
|
+
// Network interface to bind. Default "127.0.0.1" = loopback only
|
|
7
|
+
// (no LAN exposure). Set to "0.0.0.0" to listen on all IPv4
|
|
8
|
+
// interfaces, "::" for IPv6/dual-stack, or a specific NIC IP
|
|
9
|
+
// (e.g. "192.168.1.10") to bind one interface on a multi-homed
|
|
10
|
+
// host. Note: "localhost" works but resolves via DNS and may
|
|
11
|
+
// pick IPv6 (`::1`) over IPv4 — prefer the explicit IP form.
|
|
12
|
+
host: z.string().default("127.0.0.1"),
|
|
6
13
|
data_dir: z.string().default("data"),
|
|
7
14
|
default_cwd: z.string().default(process.cwd()),
|
|
8
15
|
public_dir: z.string().default("dist"),
|
|
@@ -35,7 +42,7 @@ export const ConfigSchema = z.object({
|
|
|
35
42
|
}),
|
|
36
43
|
// [title] — title generation sub-session configuration.
|
|
37
44
|
//
|
|
38
|
-
// `
|
|
45
|
+
// `models` is an array of case-insensitive substring patterns. When the
|
|
39
46
|
// title sub-session is created, we look at the model list the agent
|
|
40
47
|
// reports (ACP `availableModels`) and pick the first model whose id
|
|
41
48
|
// matches any pattern in order. Match → call `setConfigOption` with
|
|
@@ -50,17 +57,17 @@ export const ConfigSchema = z.object({
|
|
|
50
57
|
// - "flash" → Google Gemini (gemini-*-flash)
|
|
51
58
|
// - "lite" → Cohere, generic
|
|
52
59
|
//
|
|
53
|
-
// Set `
|
|
60
|
+
// Set `models = []` to disable substring matching entirely and always
|
|
54
61
|
// inherit the agent's default model. To pin one specific model, pass a
|
|
55
|
-
// single-element array: `
|
|
62
|
+
// single-element array: `models = ["claude-haiku-4.5"]`.
|
|
56
63
|
title: z
|
|
57
64
|
.object({
|
|
58
|
-
|
|
65
|
+
models: z
|
|
59
66
|
.array(z.string())
|
|
60
67
|
.default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
|
|
61
68
|
})
|
|
62
69
|
.default({
|
|
63
|
-
|
|
70
|
+
models: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
|
|
64
71
|
}),
|
|
65
72
|
// [debug] — frontend log level.
|
|
66
73
|
// level ∈ off | debug | info | warn | error. Default "off".
|
package/lib/event-handler.js
CHANGED
|
@@ -14,7 +14,9 @@ function handleConfigLikeEvent(event, sessions, store) {
|
|
|
14
14
|
if (event.configOptions.length)
|
|
15
15
|
sessions.cachedConfigOptions = event.configOptions;
|
|
16
16
|
for (const opt of event.configOptions) {
|
|
17
|
-
|
|
17
|
+
if (typeof opt.currentValue === "string") {
|
|
18
|
+
store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
|
|
19
|
+
}
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
function handleMessageChunk(event, sessions) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function valid(width, height) {
|
|
2
|
+
if (!Number.isInteger(width) || !Number.isInteger(height))
|
|
3
|
+
return null;
|
|
4
|
+
if (width <= 0 || height <= 0)
|
|
5
|
+
return null;
|
|
6
|
+
return { width, height };
|
|
7
|
+
}
|
|
8
|
+
export function readImageDimensions(buf) {
|
|
9
|
+
return (readPngDimensions(buf) ?? readGifDimensions(buf) ?? readJpegDimensions(buf));
|
|
10
|
+
}
|
|
11
|
+
function readPngDimensions(buf) {
|
|
12
|
+
// PNG: signature + IHDR width/height at fixed offsets.
|
|
13
|
+
if (buf.length >= 24 &&
|
|
14
|
+
buf[0] === 0x89 &&
|
|
15
|
+
buf[1] === 0x50 &&
|
|
16
|
+
buf[2] === 0x4e &&
|
|
17
|
+
buf[3] === 0x47 &&
|
|
18
|
+
buf.toString("ascii", 12, 16) === "IHDR") {
|
|
19
|
+
return valid(buf.readUInt32BE(16), buf.readUInt32BE(20));
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
function readGifDimensions(buf) {
|
|
24
|
+
// GIF87a/GIF89a: logical screen width/height, little-endian.
|
|
25
|
+
if (buf.length >= 10 &&
|
|
26
|
+
(buf.toString("ascii", 0, 6) === "GIF87a" ||
|
|
27
|
+
buf.toString("ascii", 0, 6) === "GIF89a")) {
|
|
28
|
+
return valid(buf.readUInt16LE(6), buf.readUInt16LE(8));
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function isJpegSofMarker(marker) {
|
|
33
|
+
return ((marker >= 0xc0 && marker <= 0xc3) ||
|
|
34
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
35
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
36
|
+
(marker >= 0xcd && marker <= 0xcf));
|
|
37
|
+
}
|
|
38
|
+
function readJpegDimensions(buf) {
|
|
39
|
+
// JPEG: scan marker segments until a SOF marker carrying dimensions.
|
|
40
|
+
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
41
|
+
let offset = 2;
|
|
42
|
+
while (offset + 3 < buf.length) {
|
|
43
|
+
if (buf[offset] !== 0xff) {
|
|
44
|
+
offset++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
while (offset < buf.length && buf[offset] === 0xff)
|
|
48
|
+
offset++;
|
|
49
|
+
const marker = buf[offset++];
|
|
50
|
+
if (marker === 0xd9 || marker === 0xda)
|
|
51
|
+
break;
|
|
52
|
+
if (offset + 1 >= buf.length)
|
|
53
|
+
break;
|
|
54
|
+
const length = buf.readUInt16BE(offset);
|
|
55
|
+
if (length < 2 || offset + length > buf.length)
|
|
56
|
+
break;
|
|
57
|
+
if (isJpegSofMarker(marker) && length >= 7) {
|
|
58
|
+
return valid(buf.readUInt16BE(offset + 5), buf.readUInt16BE(offset + 3));
|
|
59
|
+
}
|
|
60
|
+
offset += length;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Find the first available model whose id matches any pattern. */
|
|
2
|
+
export function pickModelByPatterns(configOptions, patterns) {
|
|
3
|
+
const normalized = patterns
|
|
4
|
+
.map((p) => p.trim().toLowerCase())
|
|
5
|
+
.filter((p) => p.length > 0);
|
|
6
|
+
if (normalized.length === 0)
|
|
7
|
+
return null;
|
|
8
|
+
const modelOpt = configOptions.find((c) => c.id === "model" && "options" in c);
|
|
9
|
+
if (!modelOpt || modelOpt.options.length === 0)
|
|
10
|
+
return null;
|
|
11
|
+
for (const pattern of normalized) {
|
|
12
|
+
const hit = modelOpt.options.find((o) => o.value.toLowerCase().includes(pattern));
|
|
13
|
+
if (hit)
|
|
14
|
+
return hit.value;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
package/lib/preflight.js
CHANGED
|
@@ -120,52 +120,71 @@ function checkAgent(agentCmd) {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
/**
|
|
123
|
-
* Probe whether `port` can be bound
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
123
|
+
* Probe whether `host:port` can be bound (matching what `server.listen`
|
|
124
|
+
* actually uses). Listens, then closes immediately. There's a tiny
|
|
125
|
+
* race window between close and the real server.listen() — that's
|
|
126
|
+
* fine for diagnostics: the goal is a friendly hint, not a hard
|
|
127
|
+
* guarantee.
|
|
128
128
|
*
|
|
129
129
|
* Port 0 means "let the OS pick"; we treat it as always-free.
|
|
130
|
+
*
|
|
131
|
+
* Probing the same host as the real server matters: a foreign
|
|
132
|
+
* listener on 0.0.0.0:PORT occupies 127.0.0.1:PORT too (more-specific
|
|
133
|
+
* bind fails when wildcard already bound), so binding the configured
|
|
134
|
+
* host catches conflicts and surfaces EADDRNOTAVAIL when the user
|
|
135
|
+
* typo'd an IP that isn't on any local interface.
|
|
130
136
|
*/
|
|
131
|
-
async function checkPort(port) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
// (server.ts uses "0.0.0.0"). Probing 127.0.0.1 lets a foreign
|
|
137
|
-
// listener on 0.0.0.0:PORT slip past preflight and only surface as
|
|
138
|
-
// EADDRINUSE during the real server.listen() — exactly the case
|
|
139
|
-
// we're trying to catch.
|
|
137
|
+
async function checkPort(port, host) {
|
|
138
|
+
const label = `${host}:${port}`;
|
|
139
|
+
// port=0 still gets probed: the OS picks any free port, but bind can
|
|
140
|
+
// still fail with EADDRNOTAVAIL if `host` isn't on any local
|
|
141
|
+
// interface — that's exactly the typo we want to catch.
|
|
140
142
|
const result = await new Promise((settle) => {
|
|
141
143
|
const probe = createServer();
|
|
142
144
|
probe.once("error", (err) => {
|
|
143
145
|
settle({ code: err.code ?? "unknown" });
|
|
144
146
|
});
|
|
145
|
-
probe.listen(port,
|
|
147
|
+
probe.listen(port, host, () => {
|
|
148
|
+
const addr = probe.address();
|
|
149
|
+
const assigned = typeof addr === "object" && addr ? addr.port : port;
|
|
146
150
|
probe.close(() => {
|
|
147
|
-
settle({});
|
|
151
|
+
settle({ assigned });
|
|
148
152
|
});
|
|
149
153
|
});
|
|
150
154
|
});
|
|
151
155
|
if (!result.code) {
|
|
152
|
-
|
|
156
|
+
if (port === 0) {
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
name: "port",
|
|
160
|
+
detail: `${host}:0 (OS-assigned → ${result.assigned})`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, name: "port", detail: label };
|
|
153
164
|
}
|
|
154
165
|
if (result.code === "EADDRINUSE") {
|
|
155
166
|
return {
|
|
156
167
|
ok: false,
|
|
157
168
|
name: "port",
|
|
158
|
-
detail: `${
|
|
159
|
-
hint:
|
|
169
|
+
detail: `${label} (in use)`,
|
|
170
|
+
hint: `${label} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
|
|
160
171
|
? `netstat -ano | findstr :${port}`
|
|
161
172
|
: `lsof -nP -iTCP:${port} -sTCP:LISTEN`}\nor change \`port\` in config.toml to a free port.`,
|
|
162
173
|
};
|
|
163
174
|
}
|
|
175
|
+
if (result.code === "EADDRNOTAVAIL") {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
name: "port",
|
|
179
|
+
detail: `${label} (${result.code})`,
|
|
180
|
+
hint: `cannot bind ${label}: ${result.code}\nthe host '${host}' is not assigned to any local interface.\ncheck \`host\` in config.toml — typical values are "127.0.0.1" (loopback) or "0.0.0.0" (all interfaces).`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
164
183
|
return {
|
|
165
184
|
ok: false,
|
|
166
185
|
name: "port",
|
|
167
|
-
detail: `${
|
|
168
|
-
hint: `cannot bind
|
|
186
|
+
detail: `${label} (${result.code})`,
|
|
187
|
+
hint: `cannot bind ${label}: ${result.code}\ncheck firewall / permissions, or change \`host\`/\`port\` in config.toml.`,
|
|
169
188
|
};
|
|
170
189
|
}
|
|
171
190
|
/**
|
|
@@ -179,7 +198,7 @@ export async function runPreflight(opts) {
|
|
|
179
198
|
checks.push(checkDataDir(opts.data_dir));
|
|
180
199
|
const agent = checkAgent(opts.agent_cmd);
|
|
181
200
|
checks.push(agent);
|
|
182
|
-
checks.push(await checkPort(opts.port));
|
|
201
|
+
checks.push(await checkPort(opts.port, opts.host));
|
|
183
202
|
for (const c of checks) {
|
|
184
203
|
if (c.ok)
|
|
185
204
|
printOk(c);
|
package/lib/push-service.js
CHANGED
|
@@ -46,18 +46,14 @@ export function isAppleEndpoint(endpoint) {
|
|
|
46
46
|
return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
|
|
47
47
|
}
|
|
48
48
|
function emptyClientState() {
|
|
49
|
-
return {
|
|
49
|
+
return { endpoint: null };
|
|
50
50
|
}
|
|
51
51
|
export class PushService {
|
|
52
52
|
store;
|
|
53
53
|
vapidKeys;
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* partial updates. v2 merges them; `visibleSince` stamps when the client
|
|
58
|
-
* transitioned to visible so the server can TTL-expire "ghost" visibility
|
|
59
|
-
* records left by iOS PWA process suspension (where the client never gets
|
|
60
|
-
* to POST visible:false).
|
|
55
|
+
* Per-client transport state — only the push endpoint. Visibility lives
|
|
56
|
+
* in ClientRegistry as of Plan C Step 4.
|
|
61
57
|
*/
|
|
62
58
|
clients = new Map();
|
|
63
59
|
/** endpoint → consecutive failure count (absent or 0 = healthy) */
|
|
@@ -65,7 +61,8 @@ export class PushService {
|
|
|
65
61
|
globalVisibilitySuppression;
|
|
66
62
|
visibilityTtlMs;
|
|
67
63
|
now;
|
|
68
|
-
|
|
64
|
+
clientRegistry;
|
|
65
|
+
constructor(store, dataDir, vapidSubject, options) {
|
|
69
66
|
this.store = store;
|
|
70
67
|
this.vapidKeys = this.loadOrGenerateKeys(dataDir);
|
|
71
68
|
webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
|
|
@@ -73,6 +70,7 @@ export class PushService {
|
|
|
73
70
|
options.globalVisibilitySuppression ?? true;
|
|
74
71
|
this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
|
|
75
72
|
this.now = options.now ?? (() => Date.now());
|
|
73
|
+
this.clientRegistry = options.clientRegistry;
|
|
76
74
|
}
|
|
77
75
|
// ---------------------------------------------------------------------------
|
|
78
76
|
// VAPID keys
|
|
@@ -123,43 +121,18 @@ export class PushService {
|
|
|
123
121
|
return { kind: "notify", title, body, tag, data: { sessionId } };
|
|
124
122
|
}
|
|
125
123
|
// ---------------------------------------------------------------------------
|
|
126
|
-
//
|
|
124
|
+
// Endpoint mapping (transport-only as of Plan C Step 4)
|
|
127
125
|
// ---------------------------------------------------------------------------
|
|
128
126
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* by omitting the key vs passing `null`. Returns an edge flag so the
|
|
132
|
-
* caller can fire edge-triggered side effects (e.g. sendClose) without
|
|
133
|
-
* double-firing on every heartbeat refresh.
|
|
127
|
+
* Set the push endpoint for a client. Identity-layer state (visibility,
|
|
128
|
+
* active session) goes through ClientRegistry.setVisibility, not here.
|
|
134
129
|
*/
|
|
135
130
|
updateClient(clientId, patch) {
|
|
136
131
|
const prev = this.clients.get(clientId) ?? emptyClientState();
|
|
137
|
-
const wasVisibleForSession = prev.visible && prev.sessionId != null ? prev.sessionId : null;
|
|
138
132
|
const next = { ...prev };
|
|
139
|
-
if (patch.visible !== undefined) {
|
|
140
|
-
next.visible = patch.visible;
|
|
141
|
-
next.visibleSince = patch.visible ? this.now() : 0;
|
|
142
|
-
}
|
|
143
|
-
if (patch.sessionId !== undefined)
|
|
144
|
-
next.sessionId = patch.sessionId;
|
|
145
133
|
if (patch.endpoint !== undefined)
|
|
146
134
|
next.endpoint = patch.endpoint;
|
|
147
|
-
const becameVisibleForSession = next.visible &&
|
|
148
|
-
next.sessionId != null &&
|
|
149
|
-
next.sessionId !== wasVisibleForSession
|
|
150
|
-
? next.sessionId
|
|
151
|
-
: null;
|
|
152
|
-
// Any transition into "visible + session X" restarts the TTL clock,
|
|
153
|
-
// including a session-switch that arrives without an explicit
|
|
154
|
-
// visible:true in the patch (e.g. a session_created POST that only
|
|
155
|
-
// carries sessionId). Otherwise the TTL would keep counting from the
|
|
156
|
-
// previous session's first-visible moment and could prematurely
|
|
157
|
-
// declare the newly-focused session "stale".
|
|
158
|
-
if (becameVisibleForSession) {
|
|
159
|
-
next.visibleSince = this.now();
|
|
160
|
-
}
|
|
161
135
|
this.clients.set(clientId, next);
|
|
162
|
-
return { becameVisibleForSession };
|
|
163
136
|
}
|
|
164
137
|
/**
|
|
165
138
|
* Read-only snapshot for tests and diagnostics. Do NOT mutate the
|
|
@@ -168,40 +141,29 @@ export class PushService {
|
|
|
168
141
|
getClientState(clientId) {
|
|
169
142
|
return this.clients.get(clientId) ?? null;
|
|
170
143
|
}
|
|
171
|
-
/** @deprecated Shim: prefer `updateClient({ visible })`. */
|
|
172
|
-
setClientVisibility(clientId, visible) {
|
|
173
|
-
this.updateClient(clientId, { visible });
|
|
174
|
-
}
|
|
175
|
-
/** @deprecated Shim: prefer `updateClient({ sessionId })`. */
|
|
176
|
-
setClientSession(clientId, sessionId) {
|
|
177
|
-
this.updateClient(clientId, { sessionId });
|
|
178
|
-
}
|
|
179
|
-
/** @deprecated Shim: prefer `updateClient({ endpoint })`. */
|
|
180
144
|
registerClient(clientId, endpoint) {
|
|
181
145
|
this.updateClient(clientId, { endpoint });
|
|
182
146
|
}
|
|
183
147
|
removeClient(clientId) {
|
|
184
148
|
this.clients.delete(clientId);
|
|
149
|
+
// Disconnect also wipes identity-layer state so visibility queries don't
|
|
150
|
+
// leak past the SSE lifetime. Production calls removeClient on SSE close
|
|
151
|
+
// (see sse-manager); tests expect the same.
|
|
152
|
+
this.clientRegistry.remove(clientId);
|
|
185
153
|
}
|
|
186
154
|
hasVisibleClient() {
|
|
187
|
-
|
|
188
|
-
for (const s of this.clients.values()) {
|
|
189
|
-
if (s.visible && now - s.visibleSince <= this.visibilityTtlMs)
|
|
190
|
-
return true;
|
|
191
|
-
}
|
|
192
|
-
return false;
|
|
155
|
+
return this.clientRegistry.hasAnyVisibleClient();
|
|
193
156
|
}
|
|
194
157
|
/** Check if a specific endpoint has at least one visible (non-stale) client. */
|
|
195
158
|
isEndpointVisible(endpoint) {
|
|
196
|
-
|
|
197
|
-
|
|
159
|
+
// Identity (visible) comes from registry, transport (endpoint↔clientId)
|
|
160
|
+
// stays with pushService.clients.
|
|
161
|
+
const reg = this.clientRegistry;
|
|
162
|
+
for (const [clientId, s] of this.clients) {
|
|
198
163
|
if (s.endpoint !== endpoint)
|
|
199
164
|
continue;
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
if (now - s.visibleSince > this.visibilityTtlMs)
|
|
203
|
-
continue;
|
|
204
|
-
return true;
|
|
165
|
+
if (reg.isClientVisible(clientId))
|
|
166
|
+
return true;
|
|
205
167
|
}
|
|
206
168
|
return false;
|
|
207
169
|
}
|
|
@@ -219,17 +181,7 @@ export class PushService {
|
|
|
219
181
|
isSessionVisibleToAnyClient(sessionId) {
|
|
220
182
|
if (!this.globalVisibilitySuppression)
|
|
221
183
|
return false;
|
|
222
|
-
|
|
223
|
-
for (const s of this.clients.values()) {
|
|
224
|
-
if (!s.visible)
|
|
225
|
-
continue;
|
|
226
|
-
if (s.sessionId !== sessionId)
|
|
227
|
-
continue;
|
|
228
|
-
if (now - s.visibleSince > this.visibilityTtlMs)
|
|
229
|
-
continue;
|
|
230
|
-
return true;
|
|
231
|
-
}
|
|
232
|
-
return false;
|
|
184
|
+
return this.clientRegistry.isSessionVisibleToAnyClient(sessionId);
|
|
233
185
|
}
|
|
234
186
|
// ---------------------------------------------------------------------------
|
|
235
187
|
// High-level: decide whether to push, and if so, send
|