@crouton-kit/crouter 0.3.57 → 0.3.59

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.
@@ -0,0 +1,256 @@
1
+ // push-engine.ts — the server-side web-push DIFF ENGINE (design D8/push
2
+ // contract). SSE (`events.ts`) is a TRIGGER ONLY (`{kind, ts}`, never data);
3
+ // this module is the one authority that re-reads canvas + decks on that
4
+ // trigger, diffs against persisted last-seen state, and decides what (if
5
+ // anything) to push. It never invents a second attention signal — decks/canvas
6
+ // stay the truth.
7
+ //
8
+ // Two invalidation kinds drive two independent diff passes:
9
+ // - 'nodes' -> re-read the canvas roster, detect a conversation ROOT
10
+ // (parent === null) crossing into a done|dead|canceled status
11
+ // from a non-done status, emit one 'done' push per transition.
12
+ // - 'inbox' -> re-read pending asks (deck provenance), detect an ask id that
13
+ // is present now but was not known-notified before, emit one
14
+ // 'needs-you' push per NEW ask, coalesced per conversation.
15
+ //
16
+ // On server start, `seed()` records the current state and sends NOTHING — a
17
+ // restart is not an attention event.
18
+ //
19
+ // Last-seen state is only advanced past a suppressed (rate-capped) item when
20
+ // that item was not actually the reason for suppression; a rate-capped
21
+ // transition/ask is deliberately left out of the persisted state so the NEXT
22
+ // invalidation retries it rather than silently losing it forever.
23
+ import { isAllowedPushEndpoint } from './push-registry.js';
24
+ const DONE_STATUSES = new Set(['done', 'dead', 'canceled']);
25
+ /** Minimum interval between pushes for the same conversation root — coalesces
26
+ * a burst of distinct events (several new asks, or an ask+done in close
27
+ * succession) into at most one send per window, never a storm. */
28
+ const DEFAULT_RATE_WINDOW_MS = 30_000;
29
+ export class PushDiffEngine {
30
+ registry;
31
+ lastSeen;
32
+ readCanvasNodes;
33
+ readPendingAsks;
34
+ sendPush;
35
+ isTargetScope;
36
+ now;
37
+ rateWindowMs;
38
+ /** Drain-queue state: only one diff pass mutates last-seen at a time. An
39
+ * invalidation arriving mid-pass sets its pending flag and is reprocessed
40
+ * when the current pass finishes — no two passes race the same snapshot. */
41
+ draining = false;
42
+ pendingNodes = false;
43
+ pendingInbox = false;
44
+ /** Per-conversation rate-cap clock. Deliberately in-memory/process-lifetime
45
+ * only (not persisted) — a restart re-seeding silently is already the
46
+ * no-storm guarantee; the rate cap only smooths a live process's bursts. */
47
+ lastPushAt = new Map();
48
+ /** Per-(conversationId+kind) set of subscription ids already delivered for
49
+ * the CURRENT still-pending notification. A transient failure on one
50
+ * subscription must never cause a later retry to re-send to subscriptions
51
+ * that already got `'sent'` — this is exactly what remembers them across
52
+ * passes. Cleared the moment the event fully succeeds (or has no eligible
53
+ * subscriber left), since a later, distinct event reusing the same key
54
+ * must start fresh. */
55
+ deliveredForKey = new Map();
56
+ constructor(deps) {
57
+ this.registry = deps.registry;
58
+ this.lastSeen = deps.lastSeen;
59
+ this.readCanvasNodes = deps.readCanvasNodes;
60
+ this.readPendingAsks = deps.readPendingAsks;
61
+ this.sendPush = deps.sendPush;
62
+ this.isTargetScope = deps.isTargetScope;
63
+ this.now = deps.now ?? (() => Date.now());
64
+ this.rateWindowMs = deps.rateWindowMs ?? DEFAULT_RATE_WINDOW_MS;
65
+ }
66
+ /** Seed last-seen from a fresh canvas+deck read and send nothing. Call once
67
+ * on server start — a restart must never be an attention event. */
68
+ async seed() {
69
+ const [nodes, asks] = await Promise.all([this.readCanvasNodes(), this.readPendingAsks()]);
70
+ const roots = {};
71
+ for (const n of nodes) {
72
+ if (n.parent !== null)
73
+ continue;
74
+ roots[n.node_id] = { status: n.status, attention_count: n.attention_count };
75
+ }
76
+ const askState = {};
77
+ for (const a of asks)
78
+ askState[a.id] = { present: true };
79
+ this.lastSeen.replace({ schemaVersion: 1, updatedAt: this.now(), roots, asks: askState });
80
+ }
81
+ /** 'nodes' invalidation: re-read the canvas roster, emit a 'done' push for
82
+ * every conversation root that just crossed into a done|dead|canceled
83
+ * status from a non-done status. */
84
+ async onNodesInvalidation() {
85
+ this.pendingNodes = true;
86
+ await this.drain();
87
+ }
88
+ async onInboxInvalidation() {
89
+ this.pendingInbox = true;
90
+ await this.drain();
91
+ }
92
+ /** Serialize passes: if a pass is already running, just set the pending flag
93
+ * and return — the running drain loop will pick it up. Otherwise run pending
94
+ * passes until none remain. */
95
+ async drain() {
96
+ if (this.draining)
97
+ return;
98
+ this.draining = true;
99
+ try {
100
+ while (this.pendingNodes || this.pendingInbox) {
101
+ if (this.pendingNodes) {
102
+ this.pendingNodes = false;
103
+ await this.runNodesPass();
104
+ }
105
+ if (this.pendingInbox) {
106
+ this.pendingInbox = false;
107
+ await this.runInboxPass();
108
+ }
109
+ }
110
+ }
111
+ finally {
112
+ this.draining = false;
113
+ }
114
+ }
115
+ async runNodesPass() {
116
+ const nodes = await this.readCanvasNodes();
117
+ const prev = this.lastSeen.snapshot();
118
+ const nextRoots = {};
119
+ for (const n of nodes) {
120
+ if (n.parent !== null)
121
+ continue;
122
+ const before = prev.roots[n.node_id];
123
+ const isTransition = before !== undefined && !DONE_STATUSES.has(before.status) && DONE_STATUSES.has(n.status);
124
+ if (!isTransition) {
125
+ nextRoots[n.node_id] = { status: n.status, attention_count: n.attention_count };
126
+ continue;
127
+ }
128
+ const sent = await this.emit(n.node_id, {
129
+ conversationId: n.node_id,
130
+ kind: 'done',
131
+ title: 'Conversation finished',
132
+ snippet: `This conversation is now ${n.status}.`,
133
+ });
134
+ // Rate-capped: keep the PRE-transition state so the next 'nodes'
135
+ // invalidation still sees a non-done -> done edge and retries.
136
+ nextRoots[n.node_id] = sent ? { status: n.status, attention_count: n.attention_count } : before;
137
+ }
138
+ this.lastSeen.replace({ schemaVersion: prev.schemaVersion, updatedAt: this.now(), roots: nextRoots, asks: prev.asks });
139
+ }
140
+ /** 'inbox' invalidation: re-read pending asks, emit exactly one 'needs-you'
141
+ * push per conversation for its NEW (not-yet-notified) asks, coalesced. An
142
+ * ask no longer present (resolved/missing) is simply dropped, never
143
+ * notified. */
144
+ async runInboxPass() {
145
+ const asks = await this.readPendingAsks();
146
+ const prev = this.lastSeen.snapshot();
147
+ const nextAsks = {};
148
+ const newByConversation = new Map();
149
+ for (const ask of asks) {
150
+ if (prev.asks[ask.id]?.present === true) {
151
+ // Already known + already notified at some point — keep tracked, never
152
+ // re-notify the same unresolved ask.
153
+ nextAsks[ask.id] = { present: true };
154
+ continue;
155
+ }
156
+ const list = newByConversation.get(ask.conversationId) ?? [];
157
+ list.push(ask);
158
+ newByConversation.set(ask.conversationId, list);
159
+ }
160
+ for (const [conversationId, list] of newByConversation) {
161
+ const first = list[0];
162
+ const extra = list.length - 1;
163
+ const sent = await this.emit(conversationId, {
164
+ conversationId,
165
+ kind: 'needs-you',
166
+ askId: first.jobId,
167
+ title: first.conversationTitle,
168
+ snippet: extra > 0 ? `${first.title} (+${extra} more)` : first.title,
169
+ });
170
+ if (sent) {
171
+ for (const ask of list)
172
+ nextAsks[ask.id] = { present: true };
173
+ }
174
+ // Rate-capped: leave these ask ids OUT of nextAsks so the next 'inbox'
175
+ // invalidation still sees them as new and retries once the window clears.
176
+ }
177
+ this.lastSeen.replace({ schemaVersion: prev.schemaVersion, updatedAt: this.now(), roots: prev.roots, asks: nextAsks });
178
+ }
179
+ /** Send one push per ELIGIBLE subscription — one whose authScope is on the
180
+ * watched boundary (`isTargetScope`) AND that opted in for this payload kind.
181
+ * Respects the per-conversation rate cap; prunes any subscription the push
182
+ * service reports gone (404/410) or whose endpoint fails the push-service
183
+ * allowlist re-check. Tracks per-subscription delivery for this notification
184
+ * (`deliveredForKey`) so a retry after a transient failure on one
185
+ * subscription never re-sends to a subscription that already got `'sent'`.
186
+ * Returns true iff last-seen may advance: false when suppressed by the rate
187
+ * cap OR when a transient send error means the item must be retried next
188
+ * pass. A no-eligible pass returns true (nothing to retry). */
189
+ async emit(conversationId, payload) {
190
+ // Eligible = in the watched auth scope AND opted-in for this payload kind.
191
+ const eligible = this.registry.snapshot().filter((r) => {
192
+ if (!this.isTargetScope(r.authScope))
193
+ return false;
194
+ if (payload.kind === 'done' && !r.prefs.doneNotifications)
195
+ return false;
196
+ return true;
197
+ });
198
+ // Keyed by conversation + kind: a later, distinct event reusing this key
199
+ // (once cleared below) must start with a fresh delivered set.
200
+ const key = `${conversationId}:${payload.kind}`;
201
+ // No eligible subscriber -> the event is fully handled (nothing to retry);
202
+ // advance last-seen. Checked BEFORE the rate cap so a no-eligible pass never
203
+ // stalls the state on a phantom retry.
204
+ if (eligible.length === 0) {
205
+ this.deliveredForKey.delete(key);
206
+ return true;
207
+ }
208
+ const now = this.now();
209
+ const last = this.lastPushAt.get(conversationId) ?? 0;
210
+ if (now - last < this.rateWindowMs)
211
+ return false; // rate-capped -> retry next pass
212
+ this.lastPushAt.set(conversationId, now);
213
+ const delivered = this.deliveredForKey.get(key) ?? new Set();
214
+ let transientFailure = false;
215
+ for (const record of eligible) {
216
+ // Already delivered to this subscription for this event on an earlier
217
+ // pass — never re-send it (this is the fix for duplicate pushes on
218
+ // transient-failure retry: only the subscriptions that actually failed
219
+ // are retried).
220
+ if (delivered.has(record.id))
221
+ continue;
222
+ if (!isAllowedPushEndpoint(record.endpoint)) {
223
+ // Defense in depth: re-check the push-service allowlist immediately
224
+ // before every outbound connect (no DNS/host TOCTOU), even though
225
+ // normalizeEndpoint already enforced it at subscribe time. A record
226
+ // that fails this can never be sent to safely — prune it.
227
+ this.registry.deleteById(record.id);
228
+ continue;
229
+ }
230
+ let result;
231
+ try {
232
+ result = await this.sendPush(record, payload);
233
+ }
234
+ catch {
235
+ result = 'error';
236
+ }
237
+ if (result === 'gone')
238
+ this.registry.deleteById(record.id);
239
+ else if (result === 'sent')
240
+ delivered.add(record.id);
241
+ else
242
+ transientFailure = true;
243
+ }
244
+ // A transient send failure must NOT advance last-seen — retry next pass
245
+ // (design: "update last-seen only after a successful diff pass"), and the
246
+ // subscriptions already delivered above must stay remembered so that retry
247
+ // only reaches the ones that failed. A pruned 'gone'/disallowed record is
248
+ // not a failure: nothing left to retry for it.
249
+ if (transientFailure) {
250
+ this.deliveredForKey.set(key, delivered);
251
+ return false;
252
+ }
253
+ this.deliveredForKey.delete(key);
254
+ return true;
255
+ }
256
+ }
@@ -0,0 +1,89 @@
1
+ import type { NodeLifeStatus } from './web-client/shared/protocol.js';
2
+ export interface PushPreferences {
3
+ doneNotifications: boolean;
4
+ }
5
+ export interface PushSubscriptionRecord {
6
+ id: string;
7
+ endpoint: string;
8
+ keys: {
9
+ p256dh: string;
10
+ auth: string;
11
+ };
12
+ authScope: string;
13
+ createdAt: number;
14
+ lastSeenAt: number;
15
+ prefs: PushPreferences;
16
+ }
17
+ export interface PushRegistryFile {
18
+ schemaVersion: number;
19
+ records: PushSubscriptionRecord[];
20
+ }
21
+ export interface PushLastSeenRootState {
22
+ status: NodeLifeStatus;
23
+ attention_count: number;
24
+ }
25
+ export interface PushLastSeenAskState {
26
+ present: boolean;
27
+ }
28
+ export interface PushLastSeenFile {
29
+ schemaVersion: number;
30
+ updatedAt: number;
31
+ roots: Record<string, PushLastSeenRootState>;
32
+ asks: Record<string, PushLastSeenAskState>;
33
+ }
34
+ /** A locally generated VAPID keypair, persisted so the same identity signs
35
+ * every outbound push across restarts. The private key never leaves this
36
+ * file/process — only `publicKey` is ever served to a client. */
37
+ export interface VapidKeypairFile {
38
+ schemaVersion: number;
39
+ publicKey: string;
40
+ privateKey: string;
41
+ subject: string;
42
+ }
43
+ export declare class PushRequestError extends Error {
44
+ readonly statusCode: number;
45
+ constructor(statusCode: number, message: string);
46
+ }
47
+ export declare function registryPath(): string;
48
+ export declare function lastSeenPath(): string;
49
+ export declare function vapidPath(): string;
50
+ export declare function pushBodyMaxBytes(): number;
51
+ /** Re-checkable at any later point (e.g. immediately before an outbound send,
52
+ * not just at subscribe time) with no dependency on DNS resolution — the
53
+ * allowlist match is a pure string check, so there is nothing to TOCTOU. */
54
+ export declare function isAllowedPushEndpoint(endpoint: string): boolean;
55
+ export declare function normalizeEndpoint(endpoint: unknown): string;
56
+ export declare function subscriptionIdForEndpoint(endpoint: string): string;
57
+ export declare function parsePushSubscriptionBody(rawBody: string, authScope: string, now?: number): PushSubscriptionRecord;
58
+ export declare function parsePushDeleteBody(rawBody: string): {
59
+ endpoint: string;
60
+ };
61
+ /** Load the persisted VAPID keypair, generating and persisting a fresh one on
62
+ * first run. One keypair per server identity (design D8/push contract) —
63
+ * every subscription is signed with the same public key for the life of this
64
+ * `crtrHome()`. The private key never leaves this file/process. */
65
+ export declare function loadOrCreateVapidKeypair(path?: string, subject?: string): VapidKeypairFile;
66
+ export declare class PushRegistryStore {
67
+ private readonly path;
68
+ private state;
69
+ private readonly byId;
70
+ constructor(path?: string);
71
+ snapshot(): PushSubscriptionRecord[];
72
+ listByScope(authScope: string): PushSubscriptionRecord[];
73
+ upsert(record: PushSubscriptionRecord): PushSubscriptionRecord;
74
+ touchById(id: string, lastSeenAt: number): PushSubscriptionRecord | null;
75
+ /** Scoped delete for the HTTP DELETE verb: a caller may only remove a
76
+ * subscription it owns (its authScope matches the stored record's). Pruning a
77
+ * service-reported-gone endpoint is a separate, unscoped path (`deleteById`). */
78
+ deleteByEndpoint(endpoint: string, authScope: string): boolean;
79
+ deleteById(id: string): boolean;
80
+ private persist;
81
+ }
82
+ export declare class PushLastSeenStore {
83
+ private readonly path;
84
+ private state;
85
+ constructor(path?: string);
86
+ snapshot(): PushLastSeenFile;
87
+ replace(next: PushLastSeenFile): void;
88
+ private persist;
89
+ }