@abraca/resend 2.16.0 → 2.17.1
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/dist/abracadabra-resend.cjs +60 -24
- package/dist/abracadabra-resend.cjs.map +1 -1
- package/dist/abracadabra-resend.esm.js +60 -24
- package/dist/abracadabra-resend.esm.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/package.json +3 -3
- package/src/outbox-watcher.ts +96 -12
- package/src/server.ts +35 -19
package/dist/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ declare class AbracadabraResendServer {
|
|
|
18
18
|
private _connection;
|
|
19
19
|
private childCache;
|
|
20
20
|
private evictionTimer;
|
|
21
|
+
private heartbeatTimer;
|
|
21
22
|
private _userId;
|
|
22
23
|
private _signFn;
|
|
23
24
|
private _reconnecting;
|
|
@@ -123,11 +124,15 @@ declare class OutboxWatcher {
|
|
|
123
124
|
private readonly bootstrap;
|
|
124
125
|
private readonly defaultFrom;
|
|
125
126
|
private readonly inFlight;
|
|
126
|
-
private readonly handled;
|
|
127
127
|
private observer;
|
|
128
128
|
private treeMap;
|
|
129
129
|
private rootDoc;
|
|
130
130
|
private txHandler;
|
|
131
|
+
private subdocsHandler;
|
|
132
|
+
private updateHandler;
|
|
133
|
+
private reconnectedHandler;
|
|
134
|
+
private reconnectProvider;
|
|
135
|
+
private readonly subdocHandlers;
|
|
131
136
|
constructor(opts: OutboxWatcherOptions);
|
|
132
137
|
start(): void;
|
|
133
138
|
stop(): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abraca/resend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.1",
|
|
4
4
|
"description": "Resend bridge for Abracadabra — Inbox + Outbox documents wired to email send/receive",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"resend": "^4.0.0",
|
|
34
34
|
"yjs": "^13.6.8",
|
|
35
35
|
"zod": "^4.3.6",
|
|
36
|
-
"@abraca/
|
|
37
|
-
"@abraca/
|
|
36
|
+
"@abraca/dabra": "2.17.1",
|
|
37
|
+
"@abraca/convert": "2.17.1"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"test": "node --no-warnings --conditions=source --experimental-transform-types --test 'tests/*.test.ts'"
|
package/src/outbox-watcher.ts
CHANGED
|
@@ -54,12 +54,41 @@ export class OutboxWatcher {
|
|
|
54
54
|
private readonly sender: ResendSender;
|
|
55
55
|
private readonly bootstrap: BootstrapResult;
|
|
56
56
|
private readonly defaultFrom: string;
|
|
57
|
+
// Transient mid-dispatch guard ONLY: holds ids whose send is in flight RIGHT
|
|
58
|
+
// NOW, so a burst of observe events doesn't fire a second concurrent send
|
|
59
|
+
// before `meta.resendId` lands. Cleared the instant a dispatch settles — it
|
|
60
|
+
// is NEVER a durable "already sent" marker. The ONLY durable sent-state is
|
|
61
|
+
// `meta.resendId` on the document (set on success); a failed send is durably
|
|
62
|
+
// recorded by the move to the Failed column + `meta.error`. There is no
|
|
63
|
+
// in-memory "handled" set: a card re-queued into Ready (resendId cleared) is
|
|
64
|
+
// re-sent, and the bridge's view never diverges from the persisted tree.
|
|
57
65
|
private readonly inFlight = new Set<string>();
|
|
58
|
-
private readonly handled = new Set<string>();
|
|
59
66
|
private observer: ((events: Y.YEvent<any>[]) => void) | null = null;
|
|
60
67
|
private treeMap: Y.Map<any> | null = null;
|
|
61
68
|
private rootDoc: Y.Doc | null = null;
|
|
62
69
|
private txHandler: ((tx: Y.Transaction) => void) | null = null;
|
|
70
|
+
private subdocsHandler:
|
|
71
|
+
| ((changes: {
|
|
72
|
+
added: Set<Y.Doc>;
|
|
73
|
+
removed: Set<Y.Doc>;
|
|
74
|
+
loaded: Set<Y.Doc>;
|
|
75
|
+
}) => void)
|
|
76
|
+
| null = null;
|
|
77
|
+
private updateHandler:
|
|
78
|
+
| ((update: Uint8Array, origin: unknown) => void)
|
|
79
|
+
| null = null;
|
|
80
|
+
private reconnectedHandler: (() => void) | null = null;
|
|
81
|
+
// The provider we attached `reconnected` to, kept so stop() can detach.
|
|
82
|
+
private reconnectProvider: { off(ev: string, cb: () => void): void } | null =
|
|
83
|
+
null;
|
|
84
|
+
// Per-subdoc afterTransaction handlers. Tracked so we (a) detach them on
|
|
85
|
+
// stop() and (b) never stack a second listener when the same subdoc reloads
|
|
86
|
+
// (the old code re-`on`'d every subdoc on every `subdocs` event — an
|
|
87
|
+
// unbounded listener leak that grew for the life of the daemon).
|
|
88
|
+
private readonly subdocHandlers = new Map<
|
|
89
|
+
Y.Doc,
|
|
90
|
+
(tx: Y.Transaction) => void
|
|
91
|
+
>();
|
|
63
92
|
|
|
64
93
|
constructor(opts: OutboxWatcherOptions) {
|
|
65
94
|
this.server = opts.server;
|
|
@@ -114,31 +143,65 @@ export class OutboxWatcher {
|
|
|
114
143
|
|
|
115
144
|
// Belt-and-suspenders: subdocs. abracadabra-rs may route some space-tree
|
|
116
145
|
// edits through subdoc payloads that don't fire transactions on the root
|
|
117
|
-
// doc directly. Watch every subdoc's transactions too
|
|
118
|
-
|
|
146
|
+
// doc directly. Watch every subdoc's transactions too — but dedup, so a
|
|
147
|
+
// subdoc that loads/unloads/reloads doesn't accumulate listeners.
|
|
148
|
+
const onSubdocs = (changes: {
|
|
149
|
+
added: Set<Y.Doc>;
|
|
150
|
+
removed: Set<Y.Doc>;
|
|
151
|
+
loaded: Set<Y.Doc>;
|
|
152
|
+
}) => {
|
|
119
153
|
console.error(
|
|
120
|
-
`[abracadabra-resend] subdocs: added=${changes.added.size} loaded=${changes.loaded.size}`,
|
|
154
|
+
`[abracadabra-resend] subdocs: added=${changes.added.size} loaded=${changes.loaded.size} removed=${changes.removed.size}`,
|
|
121
155
|
);
|
|
156
|
+
for (const sub of changes.removed) {
|
|
157
|
+
const handler = this.subdocHandlers.get(sub);
|
|
158
|
+
if (handler) {
|
|
159
|
+
sub.off("afterTransaction", handler);
|
|
160
|
+
this.subdocHandlers.delete(sub);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
122
163
|
for (const sub of [...changes.added, ...changes.loaded]) {
|
|
123
|
-
|
|
164
|
+
if (this.subdocHandlers.has(sub)) continue;
|
|
165
|
+
const handler = (tx: Y.Transaction) => {
|
|
124
166
|
console.error(
|
|
125
167
|
`[abracadabra-resend] subdoc afterTransaction (guid=${sub.guid}, local=${tx.local})`,
|
|
126
168
|
);
|
|
127
169
|
void this.scan("subdoc");
|
|
128
|
-
}
|
|
170
|
+
};
|
|
171
|
+
sub.on("afterTransaction", handler);
|
|
172
|
+
this.subdocHandlers.set(sub, handler);
|
|
129
173
|
}
|
|
130
174
|
};
|
|
131
175
|
rootDoc.on("subdocs", onSubdocs);
|
|
176
|
+
this.subdocsHandler = onSubdocs;
|
|
132
177
|
|
|
133
178
|
// Raw doc update — fires for every binary Yjs update applied to the doc,
|
|
134
179
|
// from any origin. If THIS doesn't fire when a remote writer changes
|
|
135
180
|
// state, the bridge's WS isn't delivering updates.
|
|
136
|
-
|
|
181
|
+
const onUpdate = (_update: Uint8Array, origin: unknown) => {
|
|
137
182
|
console.error(
|
|
138
183
|
`[abracadabra-resend] root update applied (origin=${String(origin)})`,
|
|
139
184
|
);
|
|
140
185
|
void this.scan("update");
|
|
141
|
-
}
|
|
186
|
+
};
|
|
187
|
+
rootDoc.on("update", onUpdate);
|
|
188
|
+
this.updateHandler = onUpdate;
|
|
189
|
+
|
|
190
|
+
// Re-scan whenever the provider's socket reconnects. The doc (and these
|
|
191
|
+
// observers) survive a reconnect untouched, and the post-reconnect sync
|
|
192
|
+
// re-fires them for anything that changed while we were offline — but an
|
|
193
|
+
// explicit, idempotent catch-up scan guarantees we don't sit on a Ready
|
|
194
|
+
// doc that landed during the outage.
|
|
195
|
+
const provider = this.server.rootProvider;
|
|
196
|
+
if (provider && typeof (provider as any).on === "function") {
|
|
197
|
+
const onReconnected = () => {
|
|
198
|
+
console.error("[abracadabra-resend] provider reconnected — re-scanning");
|
|
199
|
+
void this.scan("reconnected");
|
|
200
|
+
};
|
|
201
|
+
(provider as any).on("reconnected", onReconnected);
|
|
202
|
+
this.reconnectedHandler = onReconnected;
|
|
203
|
+
this.reconnectProvider = provider as any;
|
|
204
|
+
}
|
|
142
205
|
|
|
143
206
|
console.error(
|
|
144
207
|
`[abracadabra-resend] Outbox watcher attached (ready column ${this.bootstrap.columns.Ready})`,
|
|
@@ -149,10 +212,27 @@ export class OutboxWatcher {
|
|
|
149
212
|
if (this.rootDoc && this.txHandler) {
|
|
150
213
|
this.rootDoc.off("afterTransaction", this.txHandler);
|
|
151
214
|
}
|
|
215
|
+
if (this.rootDoc && this.subdocsHandler) {
|
|
216
|
+
this.rootDoc.off("subdocs", this.subdocsHandler);
|
|
217
|
+
}
|
|
218
|
+
if (this.rootDoc && this.updateHandler) {
|
|
219
|
+
this.rootDoc.off("update", this.updateHandler);
|
|
220
|
+
}
|
|
221
|
+
for (const [sub, handler] of this.subdocHandlers) {
|
|
222
|
+
sub.off("afterTransaction", handler);
|
|
223
|
+
}
|
|
224
|
+
this.subdocHandlers.clear();
|
|
152
225
|
if (this.treeMap && this.observer) {
|
|
153
226
|
this.treeMap.unobserveDeep(this.observer);
|
|
154
227
|
}
|
|
228
|
+
if (this.reconnectProvider && this.reconnectedHandler) {
|
|
229
|
+
this.reconnectProvider.off("reconnected", this.reconnectedHandler);
|
|
230
|
+
}
|
|
155
231
|
this.txHandler = null;
|
|
232
|
+
this.subdocsHandler = null;
|
|
233
|
+
this.updateHandler = null;
|
|
234
|
+
this.reconnectedHandler = null;
|
|
235
|
+
this.reconnectProvider = null;
|
|
156
236
|
this.rootDoc = null;
|
|
157
237
|
this.observer = null;
|
|
158
238
|
this.treeMap = null;
|
|
@@ -189,9 +269,12 @@ export class OutboxWatcher {
|
|
|
189
269
|
}
|
|
190
270
|
if (e.parentId !== readyColId) return;
|
|
191
271
|
inReadyCount++;
|
|
192
|
-
|
|
272
|
+
// Transient: a send for this id is already in flight in THIS process.
|
|
273
|
+
if (this.inFlight.has(id)) return;
|
|
274
|
+
// Durable: already sent (resendId persisted on the doc). Never re-send;
|
|
275
|
+
// just reconcile its column to Sent. This is the single source of truth
|
|
276
|
+
// for "done" — survives restarts, lives in document metadata.
|
|
193
277
|
if (typeof e.meta.resendId === "string" && e.meta.resendId.length > 0) {
|
|
194
|
-
this.handled.add(id);
|
|
195
278
|
this.moveTo(id, this.bootstrap.columns.Sent);
|
|
196
279
|
return;
|
|
197
280
|
}
|
|
@@ -219,14 +302,15 @@ export class OutboxWatcher {
|
|
|
219
302
|
const { id: resendId } = await this.sender.send(payload, {
|
|
220
303
|
"X-Abra-Doc-Id": id,
|
|
221
304
|
});
|
|
222
|
-
|
|
305
|
+
// Durable sent-marker: persisted on the doc, the only record of "sent".
|
|
223
306
|
this.patchMeta(id, { resendId, sentAt: Date.now(), error: null });
|
|
224
307
|
this.moveTo(id, this.bootstrap.columns.Sent);
|
|
225
308
|
console.error(
|
|
226
309
|
`[abracadabra-resend] sent "${payload.subject}" (resend=${resendId}, doc=${id})`,
|
|
227
310
|
);
|
|
228
311
|
} catch (err: any) {
|
|
229
|
-
|
|
312
|
+
// Durable failure record: meta.error + the move to the Failed column
|
|
313
|
+
// (below) — left for human triage. No in-memory marker.
|
|
230
314
|
const message =
|
|
231
315
|
err instanceof RenderError
|
|
232
316
|
? err.message
|
package/src/server.ts
CHANGED
|
@@ -48,6 +48,7 @@ export class AbracadabraResendServer {
|
|
|
48
48
|
private _connection: SpaceConnection | null = null;
|
|
49
49
|
private childCache = new Map<string, CachedProvider>();
|
|
50
50
|
private evictionTimer: ReturnType<typeof setInterval> | null = null;
|
|
51
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
51
52
|
private _userId: string | null = null;
|
|
52
53
|
private _signFn: ((challenge: string) => Promise<string>) | null = null;
|
|
53
54
|
private _reconnecting: Promise<void> | null = null;
|
|
@@ -152,6 +153,16 @@ export class AbracadabraResendServer {
|
|
|
152
153
|
console.error("[abracadabra-resend] Space doc synced");
|
|
153
154
|
|
|
154
155
|
this.evictionTimer = setInterval(() => this.evictIdle(), 60_000);
|
|
156
|
+
|
|
157
|
+
// Connection heartbeat. The bridge is a long-running daemon that can sit
|
|
158
|
+
// idle for hours; its JWT will expire and the socket can drop with no
|
|
159
|
+
// dispatch to trigger a heal. `ensureConnected` is a no-op while healthy
|
|
160
|
+
// (just refreshes the token if it's near expiry), and revives the socket
|
|
161
|
+
// on the SAME doc — keeping the outbox watcher's observers alive — if it
|
|
162
|
+
// died. This is what makes "once connected, STAY connected" true when idle.
|
|
163
|
+
this.heartbeatTimer = setInterval(() => {
|
|
164
|
+
void this.ensureConnected();
|
|
165
|
+
}, 30_000);
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
private async _connectToSpace(docId: string): Promise<SpaceConnection> {
|
|
@@ -206,35 +217,36 @@ export class AbracadabraResendServer {
|
|
|
206
217
|
const conn = this._connection;
|
|
207
218
|
if (!conn) return;
|
|
208
219
|
if (this._wsConnected(conn.provider)) return;
|
|
220
|
+
|
|
221
|
+
// Give the SDK's own auto-reconnect a bounded window before forcing
|
|
222
|
+
// one ourselves.
|
|
209
223
|
try {
|
|
210
224
|
await waitForSync(conn.provider, 6000);
|
|
211
225
|
} catch {
|
|
212
|
-
/* fall through to
|
|
226
|
+
/* fall through to forced reconnect */
|
|
213
227
|
}
|
|
214
228
|
if (this._wsConnected(conn.provider)) return;
|
|
215
229
|
|
|
230
|
+
// Still dead → force a reconnect on the SAME Y.Doc. Critically we do
|
|
231
|
+
// NOT destroy + recreate the provider: the outbox watcher's
|
|
232
|
+
// observeDeep / afterTransaction listeners are attached to THIS doc,
|
|
233
|
+
// and swapping the doc out from under them is exactly what made the
|
|
234
|
+
// bridge go silent ("works until it doesn't") after a drop. The doc
|
|
235
|
+
// survives, observers keep firing, and step 1's token refresh lets
|
|
236
|
+
// the recycled socket re-authenticate cleanly. Cached child providers
|
|
237
|
+
// multiplex this same socket and re-sync on reopen, so we keep them.
|
|
216
238
|
console.error(
|
|
217
|
-
"[abracadabra-resend]
|
|
239
|
+
"[abracadabra-resend] Socket dead — forcing reconnect on the same doc…",
|
|
218
240
|
);
|
|
219
|
-
|
|
241
|
+
conn.provider.reconnect();
|
|
220
242
|
try {
|
|
221
|
-
conn.provider
|
|
222
|
-
|
|
223
|
-
/* already gone */
|
|
224
|
-
}
|
|
225
|
-
for (const [, cached] of this.childCache) {
|
|
226
|
-
try {
|
|
227
|
-
cached.provider.destroy();
|
|
228
|
-
} catch {
|
|
229
|
-
/* already gone */
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
this.childCache.clear();
|
|
233
|
-
try {
|
|
234
|
-
await this._connectToSpace(docId);
|
|
235
|
-
console.error("[abracadabra-resend] Space provider rebuilt + synced");
|
|
243
|
+
await waitForSync(conn.provider, 10000);
|
|
244
|
+
console.error("[abracadabra-resend] Reconnected + re-synced");
|
|
236
245
|
} catch (e) {
|
|
237
|
-
console.error(
|
|
246
|
+
console.error(
|
|
247
|
+
"[abracadabra-resend] Reconnect did not re-sync in time:",
|
|
248
|
+
e,
|
|
249
|
+
);
|
|
238
250
|
}
|
|
239
251
|
} finally {
|
|
240
252
|
this._reconnecting = null;
|
|
@@ -301,6 +313,10 @@ export class AbracadabraResendServer {
|
|
|
301
313
|
clearInterval(this.evictionTimer);
|
|
302
314
|
this.evictionTimer = null;
|
|
303
315
|
}
|
|
316
|
+
if (this.heartbeatTimer) {
|
|
317
|
+
clearInterval(this.heartbeatTimer);
|
|
318
|
+
this.heartbeatTimer = null;
|
|
319
|
+
}
|
|
304
320
|
for (const [, cached] of this.childCache) {
|
|
305
321
|
try {
|
|
306
322
|
cached.provider.destroy();
|