cursedbelt-server 1.1.0 → 2.0.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/dist/server/sync/http.d.ts +20 -3
- package/dist/server/sync/http.js +20 -14
- package/dist/server/sync/index.d.ts +10 -2
- package/dist/server/sync/index.js +9 -1
- package/dist/server/sync/planner.d.ts +38 -8
- package/dist/server/sync/planner.js +32 -8
- package/dist/server/sync/signal.d.ts +161 -0
- package/dist/server/sync/signal.js +348 -0
- package/dist/server/sync/timer.d.ts +63 -19
- package/dist/server/sync/timer.js +104 -45
- package/dist/server/sync/types.d.ts +0 -2
- package/package.json +1 -1
- package/src/noTimerDialsAPeer.spec.ts +469 -0
- package/src/server/sync/http.ts +31 -16
- package/src/server/sync/index.ts +23 -1
- package/src/server/sync/planner.spec.ts +33 -16
- package/src/server/sync/planner.ts +48 -11
- package/src/server/sync/signal.spec.ts +306 -0
- package/src/server/sync/signal.ts +422 -0
- package/src/server/sync/timer.spec.ts +97 -16
- package/src/server/sync/timer.ts +124 -47
- package/src/server/sync/types.ts +0 -2
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Hono } from "hono";
|
|
12
12
|
import type { OpLog } from "./opLog";
|
|
13
13
|
import type { ApplyOne, ApplyReport, RemoteApi } from "./types";
|
|
14
|
+
import { type SyncSignalHub } from "./signal";
|
|
14
15
|
/**
|
|
15
16
|
* Trim a page to the byte budget, keeping it a PREFIX so `cursor` stays the seq
|
|
16
17
|
* of the last op actually returned. Anything else silently drops ops: the caller
|
|
@@ -26,8 +27,6 @@ export interface ReceiverHooks {
|
|
|
26
27
|
/** Every authenticated hit — the only evidence a non-dialing half has that it is
|
|
27
28
|
* in step (vault's `lastPeerContactAt` lesson). */
|
|
28
29
|
onPeerContact?: (peerId: string) => void;
|
|
29
|
-
/** The "Sync now was pressed HERE" stamp the dialer polls. */
|
|
30
|
-
readRequest?: () => number | null;
|
|
31
30
|
}
|
|
32
31
|
export interface ReceiverOptions {
|
|
33
32
|
log: OpLog;
|
|
@@ -58,8 +57,26 @@ export interface ReceiverOptions {
|
|
|
58
57
|
*/
|
|
59
58
|
afterBatch?: (report: ApplyReport) => void | Promise<void>;
|
|
60
59
|
hooks?: ReceiverHooks;
|
|
60
|
+
/**
|
|
61
|
+
* Mount `GET /signal` — the long-lived stream that replaced the dialer's
|
|
62
|
+
* 20-second `GET /requested` probe. Omitted ⇒ the route does not exist, the
|
|
63
|
+
* orch-companion idiom: nothing to probe, nothing to authenticate against.
|
|
64
|
+
*
|
|
65
|
+
* Call {@link SyncSignalHub.announce} after a local write on THIS half and the
|
|
66
|
+
* dialing half reconciles within the round trip. See `./signal.ts`.
|
|
67
|
+
*/
|
|
68
|
+
signal?: SyncSignalHub;
|
|
61
69
|
}
|
|
62
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Build the receiver: GET /info, GET /pull, POST /push.
|
|
72
|
+
*
|
|
73
|
+
* 🔴 There was a fourth route, `GET /requested`, and it is gone (2026-09-15). It
|
|
74
|
+
* served a single integer — "was Sync now pressed here?" — and existed only to be
|
|
75
|
+
* asked, every 20 seconds, by a dialer that otherwise had nothing to say. That made
|
|
76
|
+
* it **68 % of `vault`'s entire traffic**. A receiver with news now says so over
|
|
77
|
+
* {@link import("./signal")} instead of waiting to be asked; deleting the route is
|
|
78
|
+
* what stops a stale consumer from keeping the poll alive against a new build.
|
|
79
|
+
*/
|
|
63
80
|
export declare function createSyncReceiver(options: ReceiverOptions): Hono;
|
|
64
81
|
/**
|
|
65
82
|
* The fetch-backed remote the dialer binds to. `basePath` is where the peer
|
package/dist/server/sync/http.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { Hono } from "hono";
|
|
12
12
|
import { createSyncApplier } from "./engine";
|
|
13
|
+
import { signalResponse } from "./signal";
|
|
13
14
|
const noStore = { "cache-control": "no-store" };
|
|
14
15
|
const SYNC_PAGE = 500;
|
|
15
16
|
/**
|
|
@@ -52,7 +53,16 @@ function peerIdOf(header, query) {
|
|
|
52
53
|
const raw = query ?? header ?? "";
|
|
53
54
|
return /^[0-9A-Za-z_-]{1,64}$/.test(raw) ? raw : "peer";
|
|
54
55
|
}
|
|
55
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Build the receiver: GET /info, GET /pull, POST /push.
|
|
58
|
+
*
|
|
59
|
+
* 🔴 There was a fourth route, `GET /requested`, and it is gone (2026-09-15). It
|
|
60
|
+
* served a single integer — "was Sync now pressed here?" — and existed only to be
|
|
61
|
+
* asked, every 20 seconds, by a dialer that otherwise had nothing to say. That made
|
|
62
|
+
* it **68 % of `vault`'s entire traffic**. A receiver with news now says so over
|
|
63
|
+
* {@link import("./signal")} instead of waiting to be asked; deleting the route is
|
|
64
|
+
* what stops a stale consumer from keeping the poll alive against a new build.
|
|
65
|
+
*/
|
|
56
66
|
export function createSyncReceiver(options) {
|
|
57
67
|
const { log, verifyToken } = options;
|
|
58
68
|
if ((options.applyOne === undefined) === (options.beginBatch === undefined)) {
|
|
@@ -84,7 +94,15 @@ export function createSyncReceiver(options) {
|
|
|
84
94
|
};
|
|
85
95
|
return c.json(body, 200, noStore);
|
|
86
96
|
});
|
|
87
|
-
|
|
97
|
+
const signal = options.signal;
|
|
98
|
+
if (signal !== undefined) {
|
|
99
|
+
api.get("/signal", (c) => signalResponse(signal, {
|
|
100
|
+
// The catch-up frame: a client reconnecting after a sleep learns the
|
|
101
|
+
// current head without a round trip of its own.
|
|
102
|
+
initial: { head: log.head(), instanceId: log.instanceId() },
|
|
103
|
+
signal: c.req.raw.signal,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
88
106
|
api.get("/pull", (c) => {
|
|
89
107
|
const afterRaw = c.req.query("after");
|
|
90
108
|
const after = afterRaw !== undefined && /^\d+$/.test(afterRaw) ? Number(afterRaw) : 0;
|
|
@@ -227,17 +245,5 @@ export function createHttpRemote(options) {
|
|
|
227
245
|
throw new Error(`sync/push: ${r.status}`);
|
|
228
246
|
return await readJson(r, "sync/push");
|
|
229
247
|
},
|
|
230
|
-
async requestedAt() {
|
|
231
|
-
try {
|
|
232
|
-
const r = await request("/requested");
|
|
233
|
-
if (!r.ok)
|
|
234
|
-
return null;
|
|
235
|
-
const body = await readJson(r, "sync/requested");
|
|
236
|
-
return typeof body.requestedAt === "number" ? body.requestedAt : null;
|
|
237
|
-
}
|
|
238
|
-
catch {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
},
|
|
242
248
|
};
|
|
243
249
|
}
|
|
@@ -34,8 +34,16 @@ export { createOpLog, type OpLog } from "./opLog";
|
|
|
34
34
|
export { createTokenStore, type DeviceToken, type TokenStore } from "./tokens";
|
|
35
35
|
export { createSyncApplier, pushToRemote, pullFromRemote, runSync, type EngineOptions, type ExportPolicy, type PeerRefusal, } from "./engine";
|
|
36
36
|
export { capPageBytes, createHttpRemote, createSyncReceiver, type ReceiverHooks, type ReceiverOptions, } from "./http";
|
|
37
|
-
export { DEFAULT_LOOP, isUnreachable, planNextSync, type SyncLoopConfig, type SyncLoopState, } from "./planner";
|
|
37
|
+
export { DEFAULT_LOOP, isUnreachable, planNextSync, type SyncLoopConfig, type SyncLoopState, type SyncPlan, } from "./planner";
|
|
38
|
+
export { SIGNAL_SSE_HEADERS, connectSyncSignal, createSyncSignalHub, encodeNewsFrame, parseNewsFrame, signalResponse, signalStream, type SignalClient, type SignalClientOptions, type SignalStreamOptions, type SyncNews, type SyncSignalHub, } from "./signal";
|
|
38
39
|
export { OFFLINE_STATUS, PEER_CONTACT_WRITE_MS, createSyncStatusReporter, type SyncStatus, type SyncStatusReporter, type SyncStatusStore, } from "./status";
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 🔴 `REQUEST_POLL_MS` was exported from here until 2026-09-15 and is GONE — that
|
|
42
|
+
* removal is why this package went to 2.0.0. It was a 20-second dial at a configured
|
|
43
|
+
* peer, and being *public API* is what made it dangerous: one more consumer and
|
|
44
|
+
* deleting it would have been a breaking change rather than an edit. `./timer`'s
|
|
45
|
+
* header has the ruling and the measurement; `./signal` is what replaced it.
|
|
46
|
+
*/
|
|
47
|
+
export { startSyncTimer, type SyncTimerDeps, type SyncTimerHandle, type WakeReason } from "./timer";
|
|
40
48
|
export { createSyncAlarm, type AlarmOutcome, type SyncAlarm, type SyncAlarmOptions } from "./alarm";
|
|
41
49
|
export { DEFAULT_HOLD_MS, createCommandQueue, createCommandReceiver, createCommandRemote, startCommandWorker, type CommandQueue, type CommandRemote, type CommandRoutesOptions, type CommandStatus, type CommandWorkerDeps, type CommandWorkerHandle, type SyncCommand, } from "./commands";
|
|
@@ -35,7 +35,15 @@ export { createTokenStore } from "./tokens";
|
|
|
35
35
|
export { createSyncApplier, pushToRemote, pullFromRemote, runSync, } from "./engine";
|
|
36
36
|
export { capPageBytes, createHttpRemote, createSyncReceiver, } from "./http";
|
|
37
37
|
export { DEFAULT_LOOP, isUnreachable, planNextSync, } from "./planner";
|
|
38
|
+
export { SIGNAL_SSE_HEADERS, connectSyncSignal, createSyncSignalHub, encodeNewsFrame, parseNewsFrame, signalResponse, signalStream, } from "./signal";
|
|
38
39
|
export { OFFLINE_STATUS, PEER_CONTACT_WRITE_MS, createSyncStatusReporter, } from "./status";
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 🔴 `REQUEST_POLL_MS` was exported from here until 2026-09-15 and is GONE — that
|
|
42
|
+
* removal is why this package went to 2.0.0. It was a 20-second dial at a configured
|
|
43
|
+
* peer, and being *public API* is what made it dangerous: one more consumer and
|
|
44
|
+
* deleting it would have been a breaking change rather than an edit. `./timer`'s
|
|
45
|
+
* header has the ruling and the measurement; `./signal` is what replaced it.
|
|
46
|
+
*/
|
|
47
|
+
export { startSyncTimer } from "./timer";
|
|
40
48
|
export { createSyncAlarm } from "./alarm";
|
|
41
49
|
export { DEFAULT_HOLD_MS, createCommandQueue, createCommandReceiver, createCommandRemote, startCommandWorker, } from "./commands";
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pure loop planner
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The pure loop planner: back off after a failure, else honor a debounced dirty
|
|
3
|
+
* flag, else **do not wake at all**. Pure so the timer is a thin wrapper and this
|
|
4
|
+
* is fake-clock testable.
|
|
5
|
+
*
|
|
6
|
+
* ── 🔴 There is no steady interval, and that is the whole point ──────────────────
|
|
7
|
+
*
|
|
8
|
+
* This used to end with `else: the steady interval` — `intervalMs: 5 * 60_000`, a
|
|
9
|
+
* dial at a configured peer every five minutes whether or not either side had
|
|
10
|
+
* anything to say. The owner ruled that out on 2026-09-15: *"There should be no
|
|
11
|
+
* polling in cb unless you can make some good case for it that beats the api
|
|
12
|
+
* option."* No such case was found, so the field is gone rather than defaulted to
|
|
13
|
+
* zero — a config key that reintroduces a poll is a config key somebody sets.
|
|
14
|
+
*
|
|
15
|
+
* What is left are the two wake-ups that have a REASON, and neither is a poll:
|
|
16
|
+
*
|
|
17
|
+
* · **debounce** — there are local ops to push. The side that has news says so;
|
|
18
|
+
* the wait only coalesces a burst.
|
|
19
|
+
* · **backoff** — the last attempt failed. This runs *only while disconnected*,
|
|
20
|
+
* never against a healthy peer, which is exactly the carve-out the ruling
|
|
21
|
+
* names for a reconnect timer.
|
|
22
|
+
*
|
|
23
|
+
* A clean, healthy loop returns `waitMs: null` — "nothing to do, do not book a
|
|
24
|
+
* timer" — and the process goes quiet until something happens. News from the far
|
|
25
|
+
* side arrives over `./signal`, not by asking.
|
|
5
26
|
*/
|
|
6
27
|
export interface SyncLoopConfig {
|
|
7
|
-
/** Steady poll cadence when reachable and idle. */
|
|
8
|
-
intervalMs: number;
|
|
9
28
|
/** How long after a local write to wait before syncing, so a burst coalesces. */
|
|
10
29
|
debounceMs: number;
|
|
11
30
|
/** First backoff step after a failure. */
|
|
@@ -22,10 +41,21 @@ export interface SyncLoopState {
|
|
|
22
41
|
/** When the oldest un-synced local write happened, or null if clean. */
|
|
23
42
|
dirtySince: number | null;
|
|
24
43
|
}
|
|
25
|
-
|
|
44
|
+
/**
|
|
45
|
+
* What the loop should do next.
|
|
46
|
+
*
|
|
47
|
+
* 🔴 `waitMs: null` means **do not schedule anything** — not "wait zero" and not
|
|
48
|
+
* "wait forever". It is the idle state, and a caller that turns it into a number
|
|
49
|
+
* has put the poll back.
|
|
50
|
+
*/
|
|
51
|
+
export interface SyncPlan {
|
|
26
52
|
runNow: boolean;
|
|
27
|
-
|
|
28
|
-
|
|
53
|
+
/** Milliseconds until the next wake-up, or `null` when there is no reason to wake. */
|
|
54
|
+
waitMs: number | null;
|
|
55
|
+
/** Why the loop will wake — `null` alongside `waitMs: null`. */
|
|
56
|
+
reason: "retry" | "dirty" | null;
|
|
57
|
+
}
|
|
58
|
+
export declare function planNextSync(state: SyncLoopState, now: number, cfg?: SyncLoopConfig): SyncPlan;
|
|
29
59
|
/** A peer that is asleep (the nightly EC2 window) or an internet-less Mac is NORMAL,
|
|
30
60
|
* not an error. It still feeds the backoff. */
|
|
31
61
|
export declare function isUnreachable(err: unknown): boolean;
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pure loop planner
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The pure loop planner: back off after a failure, else honor a debounced dirty
|
|
3
|
+
* flag, else **do not wake at all**. Pure so the timer is a thin wrapper and this
|
|
4
|
+
* is fake-clock testable.
|
|
5
|
+
*
|
|
6
|
+
* ── 🔴 There is no steady interval, and that is the whole point ──────────────────
|
|
7
|
+
*
|
|
8
|
+
* This used to end with `else: the steady interval` — `intervalMs: 5 * 60_000`, a
|
|
9
|
+
* dial at a configured peer every five minutes whether or not either side had
|
|
10
|
+
* anything to say. The owner ruled that out on 2026-09-15: *"There should be no
|
|
11
|
+
* polling in cb unless you can make some good case for it that beats the api
|
|
12
|
+
* option."* No such case was found, so the field is gone rather than defaulted to
|
|
13
|
+
* zero — a config key that reintroduces a poll is a config key somebody sets.
|
|
14
|
+
*
|
|
15
|
+
* What is left are the two wake-ups that have a REASON, and neither is a poll:
|
|
16
|
+
*
|
|
17
|
+
* · **debounce** — there are local ops to push. The side that has news says so;
|
|
18
|
+
* the wait only coalesces a burst.
|
|
19
|
+
* · **backoff** — the last attempt failed. This runs *only while disconnected*,
|
|
20
|
+
* never against a healthy peer, which is exactly the carve-out the ruling
|
|
21
|
+
* names for a reconnect timer.
|
|
22
|
+
*
|
|
23
|
+
* A clean, healthy loop returns `waitMs: null` — "nothing to do, do not book a
|
|
24
|
+
* timer" — and the process goes quiet until something happens. News from the far
|
|
25
|
+
* side arrives over `./signal`, not by asking.
|
|
5
26
|
*/
|
|
6
27
|
export const DEFAULT_LOOP = {
|
|
7
|
-
intervalMs: 5 * 60_000,
|
|
8
28
|
debounceMs: 3_000,
|
|
9
29
|
backoffBaseMs: 30_000,
|
|
10
30
|
backoffMaxMs: 30 * 60_000,
|
|
@@ -13,14 +33,18 @@ export function planNextSync(state, now, cfg = DEFAULT_LOOP) {
|
|
|
13
33
|
if (state.consecutiveFailures > 0) {
|
|
14
34
|
const step = Math.min(cfg.backoffMaxMs, cfg.backoffBaseMs * 2 ** (state.consecutiveFailures - 1));
|
|
15
35
|
const due = state.lastAttempt + step;
|
|
16
|
-
return now >= due
|
|
36
|
+
return now >= due
|
|
37
|
+
? { runNow: true, waitMs: 0, reason: "retry" }
|
|
38
|
+
: { runNow: false, waitMs: due - now, reason: "retry" };
|
|
17
39
|
}
|
|
18
40
|
if (state.dirtySince !== null) {
|
|
19
41
|
const due = state.dirtySince + cfg.debounceMs;
|
|
20
|
-
return now >= due
|
|
42
|
+
return now >= due
|
|
43
|
+
? { runNow: true, waitMs: 0, reason: "dirty" }
|
|
44
|
+
: { runNow: false, waitMs: due - now, reason: "dirty" };
|
|
21
45
|
}
|
|
22
|
-
|
|
23
|
-
return
|
|
46
|
+
// Clean and healthy: nothing to push, nothing to retry. Stay silent.
|
|
47
|
+
return { runNow: false, waitMs: null, reason: null };
|
|
24
48
|
}
|
|
25
49
|
/** A peer that is asleep (the nightly EC2 window) or an internet-less Mac is NORMAL,
|
|
26
50
|
* not an error. It still feeds the backoff. */
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The peer says it has news. Nobody asks.**
|
|
3
|
+
*
|
|
4
|
+
* This is the half of the replication story that deleting the dialer (`./timer`,
|
|
5
|
+
* `./http`'s old `GET /requested`) would otherwise have left open, and it is the
|
|
6
|
+
* reason that deletion costs nothing.
|
|
7
|
+
*
|
|
8
|
+
* ## The three ways, and why this is the one
|
|
9
|
+
*
|
|
10
|
+
* Push-from-the-Mac answers Mac → peer completely: the side that has news calls
|
|
11
|
+
* `markDirty()` and pushes. It does not answer **peer → Mac**, and something must:
|
|
12
|
+
*
|
|
13
|
+
* | | |
|
|
14
|
+
* |---|---|
|
|
15
|
+
* | the Mac polls an outbox | ❌ polling. Ruled out 2026-09-15 |
|
|
16
|
+
* | the peer dials the Mac | ❌ needs the Mac reachable — a tunnel, an open port, a laptop that cannot sleep. That is the thing we are trying to delete |
|
|
17
|
+
* | 🟢 **the Mac holds a stream open and the peer pushes down it** | ✅ the Mac is still purely a client: it dials **out**, listens on nothing, and no timer fires |
|
|
18
|
+
*
|
|
19
|
+
* So: one long-lived outbound connection, opened by the dialing half, carrying a
|
|
20
|
+
* `news` frame whenever the receiver's op log grows. The Mac's reaction is
|
|
21
|
+
* `syncNow()` — the push it would have made anyway, at the moment there is a reason
|
|
22
|
+
* for it instead of 4,320 times a day on the chance.
|
|
23
|
+
*
|
|
24
|
+
* ## 🔴 Two honest caveats, neither of which is a poll
|
|
25
|
+
*
|
|
26
|
+
* · **A sleeping Mac drops the connection.** On wake it reconnects and catches up
|
|
27
|
+
* from `since` — the same catch-up a push model needs anyway, so it costs
|
|
28
|
+
* nothing extra. `since` is the receiver's head as the client last saw it.
|
|
29
|
+
* · **The reconnect backoff is a timer.** It runs *only while disconnected*, never
|
|
30
|
+
* against a healthy peer, and it is written below as an `await sleep()` in a
|
|
31
|
+
* loop rather than a `setTimeout` that calls `fetch` — the shape matters,
|
|
32
|
+
* because the second one is indistinguishable from the poll this file exists to
|
|
33
|
+
* replace. A connected client issues **exactly one** outbound request for as
|
|
34
|
+
* long as it stays connected, and `signal.spec.ts` measures that over 24
|
|
35
|
+
* simulated hours.
|
|
36
|
+
*
|
|
37
|
+
* ## Why this is not `cursedbelt-cc`'s `serverPush.ts`
|
|
38
|
+
*
|
|
39
|
+
* `libs/cursedbelt-cc/src/server/stream/serverPush.ts` is the real tenant-scoped
|
|
40
|
+
* push tier — heartbeats, backpressure, SSE — and on shape it is exactly this.
|
|
41
|
+
* It is not imported here for a reason that survives the preference:
|
|
42
|
+
* `cursedbelt-server/sync` is a **leaf** whose only runtime dependency is `hono`
|
|
43
|
+
* (see `./index.ts`), and `cursedbelt-cc` is a separate published package with zero
|
|
44
|
+
* consumers today, parked whole. Importing it would make every app that wants an
|
|
45
|
+
* op log install the cc platform to get one — the precise trade `./index.ts` was
|
|
46
|
+
* split to refuse, and the one `leafSubpathsImportNothing.spec.ts` now gates.
|
|
47
|
+
*
|
|
48
|
+
* The hub below is therefore the same model at a tenth the surface: this channel
|
|
49
|
+
* carries one frame kind, to one peer, with no tenant dimension. If `cursedbelt-cc`
|
|
50
|
+
* ever gains consumers and the two packages can depend on each other, {@link
|
|
51
|
+
* SyncSignalHub} is a strict subset of `ServerPushHub` and swaps in without
|
|
52
|
+
* touching a producer.
|
|
53
|
+
*
|
|
54
|
+
* ## The Workers path, named and not required yet
|
|
55
|
+
*
|
|
56
|
+
* On Cloudflare the durable form of "the Mac holds a connection all day" is a
|
|
57
|
+
* **Durable Object with WebSocket Hibernation**: the socket stays open while the
|
|
58
|
+
* object is evicted from memory, so an idle connection is not billed for duration.
|
|
59
|
+
* That is what keeps this free at rest, and it is the shape task `195` (the Mac is
|
|
60
|
+
* a client, never an origin) lands on. Nothing here needs it today — SSE over the
|
|
61
|
+
* existing receiver works on Bun and on Workers alike — but the frame format below
|
|
62
|
+
* is deliberately transport-free so the move is a transport swap, not a protocol
|
|
63
|
+
* change.
|
|
64
|
+
*/
|
|
65
|
+
/** SSE frame headers. `x-accel-buffering` keeps nginx from holding the stream in a
|
|
66
|
+
* proxy buffer, which turns a live channel into a silent one — the same trap that
|
|
67
|
+
* makes a cached `200` sit over a dead origin elsewhere in this fleet. */
|
|
68
|
+
export declare const SIGNAL_SSE_HEADERS: Readonly<Record<string, string>>;
|
|
69
|
+
/** What the receiver tells the dialing half. One kind, deliberately. */
|
|
70
|
+
export interface SyncNews {
|
|
71
|
+
/** The receiver's op-log head at the moment it announced. The client's `since`. */
|
|
72
|
+
head: number;
|
|
73
|
+
/** The announcing instance, so a client linked to several can tell them apart. */
|
|
74
|
+
instanceId: string;
|
|
75
|
+
}
|
|
76
|
+
export interface SyncSignalHub {
|
|
77
|
+
/** Tell every listener the log grew. Never throws, never blocks on a socket. */
|
|
78
|
+
announce(news: SyncNews): void;
|
|
79
|
+
/** Attach a listener. The returned handle is safe to call more than once. */
|
|
80
|
+
subscribe(listener: (news: SyncNews) => void): () => void;
|
|
81
|
+
/** Live subscriber count — the reconnect tests assert against this. */
|
|
82
|
+
readonly subscriberCount: number;
|
|
83
|
+
/** Drop every subscriber (server shutdown). */
|
|
84
|
+
close(): void;
|
|
85
|
+
}
|
|
86
|
+
export declare function createSyncSignalHub(): SyncSignalHub;
|
|
87
|
+
export interface SignalStreamOptions {
|
|
88
|
+
/** Overridden only in tests, so the heartbeat can be driven by hand. */
|
|
89
|
+
setIntervalFn?: (fn: () => void, ms: number) => unknown;
|
|
90
|
+
clearIntervalFn?: (handle: unknown) => void;
|
|
91
|
+
heartbeatMs?: number;
|
|
92
|
+
/** Cancels the stream (the request's abort signal). */
|
|
93
|
+
signal?: AbortSignal;
|
|
94
|
+
/**
|
|
95
|
+
* Sent as the first frame so a client that reconnects after a sleep learns the
|
|
96
|
+
* current head without a round trip. This is the `since` catch-up.
|
|
97
|
+
*/
|
|
98
|
+
initial?: SyncNews;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Bridge a hub subscription onto a `ReadableStream` of SSE bytes.
|
|
102
|
+
*
|
|
103
|
+
* The stream ends when the client disconnects (`signal`) or the hub closes; both
|
|
104
|
+
* paths run the same teardown, so a dropped connection cannot leave a subscription
|
|
105
|
+
* — or an interval — behind.
|
|
106
|
+
*/
|
|
107
|
+
export declare function signalStream(hub: SyncSignalHub, options?: SignalStreamOptions): ReadableStream<Uint8Array>;
|
|
108
|
+
/** Serialize one announcement as an SSE frame. */
|
|
109
|
+
export declare const encodeNewsFrame: (news: SyncNews) => string;
|
|
110
|
+
/** A ready-to-return SSE `Response` — what the receiver's `GET /signal` answers. */
|
|
111
|
+
export declare const signalResponse: (hub: SyncSignalHub, options?: SignalStreamOptions) => Response;
|
|
112
|
+
export interface SignalClientOptions {
|
|
113
|
+
/** Where the peer mounted its receiver, e.g. `https://peer.example/api/sync`. */
|
|
114
|
+
basePath: string;
|
|
115
|
+
/** The same bearer the rest of the sync surface uses. */
|
|
116
|
+
token: string;
|
|
117
|
+
/** This instance's id, echoed as `x-sync-peer` so the far side can label us. */
|
|
118
|
+
selfId: string;
|
|
119
|
+
/**
|
|
120
|
+
* The peer has news. Wire this to {@link import("./timer").SyncTimerHandle.syncNow}
|
|
121
|
+
* — the whole point is that a reconciliation now happens for a REASON.
|
|
122
|
+
*/
|
|
123
|
+
onNews: (news: SyncNews) => void;
|
|
124
|
+
fetchImpl?: typeof fetch;
|
|
125
|
+
/** First reconnect step. Doubles to {@link backoffMaxMs}. Default 1s. */
|
|
126
|
+
backoffBaseMs?: number;
|
|
127
|
+
/** Reconnect ceiling — an offline laptop settles here. Default 5 min. */
|
|
128
|
+
backoffMaxMs?: number;
|
|
129
|
+
/** Swapped in tests. Resolves after `ms`; never issues a request. */
|
|
130
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
131
|
+
/** Jitter in [0,1). Real randomness by default — see the note on the stampede. */
|
|
132
|
+
jitterFn?: () => number;
|
|
133
|
+
log?: (msg: string) => void;
|
|
134
|
+
}
|
|
135
|
+
export interface SignalClient {
|
|
136
|
+
/** Close the stream and stop reconnecting. Idempotent. */
|
|
137
|
+
stop(): void;
|
|
138
|
+
/** Whether a stream is open right now — the tests assert against this. */
|
|
139
|
+
readonly connected: boolean;
|
|
140
|
+
/** Outbound requests issued since start. 🔴 The number the ruling is about. */
|
|
141
|
+
readonly requestCount: number;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Hold one stream open against the peer, calling {@link SignalClientOptions.onNews}
|
|
145
|
+
* on every `news` frame, and reconnect with jittered backoff **while disconnected**.
|
|
146
|
+
*
|
|
147
|
+
* 🔴 Read the loop shape as load-bearing. The reconnect is `await sleep(ms)` inside
|
|
148
|
+
* an async loop, NOT a `setTimeout` whose callback calls `fetch`. The second shape
|
|
149
|
+
* is byte-for-byte what a poll looks like, and a reviewer — or a checker — cannot
|
|
150
|
+
* tell the two apart once it is written that way. Here, a connected client is
|
|
151
|
+
* parked on `reader.read()` and issues nothing; `requestCount` stops moving the
|
|
152
|
+
* moment a connection succeeds, and that is what `signal.spec.ts` asserts over 24
|
|
153
|
+
* simulated hours.
|
|
154
|
+
*
|
|
155
|
+
* The jitter is not decoration either: a fixed backoff across N clients recovering
|
|
156
|
+
* from the same outage is a synchronized stampede at the instant the peer comes
|
|
157
|
+
* back up — the worst possible moment to send it a thundering herd.
|
|
158
|
+
*/
|
|
159
|
+
export declare function connectSyncSignal(options: SignalClientOptions): SignalClient;
|
|
160
|
+
/** `event: news\ndata: {...}` → {@link SyncNews}, or null for a heartbeat/unknown. */
|
|
161
|
+
export declare function parseNewsFrame(frame: string): SyncNews | null;
|