patchcord 0.5.3 → 0.5.5
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/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/lib/ws.mjs +11 -3
- package/scripts/subscribe.mjs +64 -10
package/package.json
CHANGED
package/scripts/lib/ws.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function connect(urlStr, { headers = {} } = {}) {
|
|
|
68
68
|
socket.on("close", () => {
|
|
69
69
|
if (!closed) {
|
|
70
70
|
closed = true;
|
|
71
|
-
emitter.emit("close");
|
|
71
|
+
emitter.emit("close", { code: null, reason: "socket-ended" });
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
|
|
@@ -115,8 +115,16 @@ export function connect(urlStr, { headers = {} } = {}) {
|
|
|
115
115
|
if (opcode === 0x1) {
|
|
116
116
|
emitter.emit("message", payload.toString("utf8"));
|
|
117
117
|
} else if (opcode === 0x8) {
|
|
118
|
-
// close frame
|
|
119
|
-
|
|
118
|
+
// close frame — parse code/reason for diagnostics
|
|
119
|
+
let code = null;
|
|
120
|
+
let reason = "";
|
|
121
|
+
if (payload.length >= 2) {
|
|
122
|
+
code = payload.readUInt16BE(0);
|
|
123
|
+
if (payload.length > 2) {
|
|
124
|
+
reason = payload.slice(2).toString("utf8");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
emitter.emit("close", { code, reason });
|
|
120
128
|
closed = true;
|
|
121
129
|
try {
|
|
122
130
|
socket.write(encodeFrame(0x8, closePayload(1000, ""), true));
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -102,6 +102,27 @@ async function fetchTicket(baseUrl, token) {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Check if there are messages already pending in the inbox at the moment
|
|
106
|
+
// we connect (or reconnect). Realtime only delivers FUTURE INSERTs, so
|
|
107
|
+
// anything queued before we joined is invisible until the agent calls
|
|
108
|
+
// inbox() manually. Emit a stdout line when there's a pending queue so
|
|
109
|
+
// Monitor wakes the agent the same way a real arrival does.
|
|
110
|
+
async function drainQueueOnce(baseUrl, token) {
|
|
111
|
+
const res = await httpJson(`${baseUrl}/api/inbox?count_only=1&limit=100`, {
|
|
112
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
113
|
+
});
|
|
114
|
+
if (res.status !== 200) {
|
|
115
|
+
throw new Error(`inbox HTTP ${res.status}`);
|
|
116
|
+
}
|
|
117
|
+
let count = 0;
|
|
118
|
+
try {
|
|
119
|
+
count = JSON.parse(res.body).pending_count ?? 0;
|
|
120
|
+
} catch (_) {}
|
|
121
|
+
if (count > 0) {
|
|
122
|
+
process.stdout.write(`PATCHCORD: ${count} waiting in inbox\n`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
105
126
|
function writePidfile(path) {
|
|
106
127
|
try {
|
|
107
128
|
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
@@ -233,6 +254,16 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
233
254
|
})
|
|
234
255
|
);
|
|
235
256
|
}
|
|
257
|
+
|
|
258
|
+
// Drain any messages already in the queue when we connected.
|
|
259
|
+
// Realtime only delivers FUTURE INSERTs — anything pending before
|
|
260
|
+
// we joined (or that arrived during a reconnect gap) wouldn't
|
|
261
|
+
// otherwise wake the agent. Fire-and-forget: a transient HTTP
|
|
262
|
+
// failure here just means we miss queued messages this round;
|
|
263
|
+
// the next reconnect retries.
|
|
264
|
+
drainQueueOnce(baseUrl, token).catch((e) => {
|
|
265
|
+
process.stderr.write(`subscribe: queue check failed: ${e.message}\n`);
|
|
266
|
+
});
|
|
236
267
|
heartbeatTimer = setInterval(() => {
|
|
237
268
|
try {
|
|
238
269
|
ws.send(
|
|
@@ -246,14 +277,29 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
246
277
|
} catch (_) {}
|
|
247
278
|
}, HEARTBEAT_INTERVAL_MS);
|
|
248
279
|
|
|
249
|
-
const
|
|
250
|
-
(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
280
|
+
const scheduleRefresh = (ttlSec) => {
|
|
281
|
+
const refreshIn = Math.max((ttlSec - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000, 30_000);
|
|
282
|
+
refreshTimer = setTimeout(doRefresh, refreshIn);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const doRefresh = async () => {
|
|
286
|
+
if (settled) return;
|
|
254
287
|
try {
|
|
255
288
|
const fresh = await refreshTicket();
|
|
256
289
|
currentJwt = fresh.jwt;
|
|
290
|
+
// Socket-level auth update (phoenix topic) — what Supabase
|
|
291
|
+
// actually uses for the connection's own JWT expiry check.
|
|
292
|
+
// Without this, the server closes the socket at the original
|
|
293
|
+
// JWT's exp regardless of per-channel updates.
|
|
294
|
+
ws.send(
|
|
295
|
+
JSON.stringify({
|
|
296
|
+
topic: "phoenix",
|
|
297
|
+
event: "access_token",
|
|
298
|
+
payload: { access_token: currentJwt },
|
|
299
|
+
ref: String(ref++),
|
|
300
|
+
})
|
|
301
|
+
);
|
|
302
|
+
// Channel-level updates — matches supabase-js's setAuth() pattern.
|
|
257
303
|
for (const topic of fresh.topics) {
|
|
258
304
|
ws.send(
|
|
259
305
|
JSON.stringify({
|
|
@@ -265,11 +311,17 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
265
311
|
);
|
|
266
312
|
}
|
|
267
313
|
process.stderr.write("subscribe: token refreshed\n");
|
|
314
|
+
scheduleRefresh(fresh.jwt_expires_in);
|
|
268
315
|
} catch (e) {
|
|
269
|
-
|
|
270
|
-
|
|
316
|
+
// Transient network/server error — do NOT close the live
|
|
317
|
+
// connection. The current JWT is still valid for ~2 more min
|
|
318
|
+
// (JWT_REFRESH_SAFETY_MARGIN_SEC). Retry sooner.
|
|
319
|
+
process.stderr.write(`subscribe: token refresh failed, retrying in 30s: ${e.message}\n`);
|
|
320
|
+
refreshTimer = setTimeout(doRefresh, 30_000);
|
|
271
321
|
}
|
|
272
|
-
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
scheduleRefresh(ticket.jwt_expires_in);
|
|
273
325
|
});
|
|
274
326
|
|
|
275
327
|
ws.on("message", (raw) => {
|
|
@@ -297,8 +349,10 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
297
349
|
done(err);
|
|
298
350
|
});
|
|
299
351
|
|
|
300
|
-
ws.on("close", () => {
|
|
301
|
-
|
|
352
|
+
ws.on("close", (info) => {
|
|
353
|
+
const codeStr = info?.code != null ? `code=${info.code}` : "code=none";
|
|
354
|
+
const reasonStr = info?.reason ? ` reason=${JSON.stringify(info.reason)}` : "";
|
|
355
|
+
process.stderr.write(`subscribe: ws closed (${codeStr}${reasonStr})\n`);
|
|
302
356
|
done();
|
|
303
357
|
});
|
|
304
358
|
});
|