@rebasepro/client 0.17.3 → 0.18.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.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/dist/auth.d.ts +80 -0
  3. package/dist/functions.d.ts +6 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.es.js +471 -94
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/offline-connectivity.d.ts +12 -1
  8. package/dist/offline.d.ts +23 -1
  9. package/dist/query-contract.types.d.ts +30 -0
  10. package/dist/realtime-channel.d.ts +29 -1
  11. package/dist/sdk_query_builder.d.ts +21 -2
  12. package/dist/transport.d.ts +24 -0
  13. package/package.json +28 -15
  14. package/src/admin.ts +0 -90
  15. package/src/anonymous-client-guard.test.ts +0 -190
  16. package/src/api-keys.ts +0 -87
  17. package/src/auth-listener-errors.test.ts +0 -57
  18. package/src/auth-refresh-overflow.test.ts +0 -89
  19. package/src/auth.ts +0 -982
  20. package/src/backups.ts +0 -40
  21. package/src/client-close.test.ts +0 -80
  22. package/src/collection-listen-meta.test.ts +0 -105
  23. package/src/collection-observe.test.ts +0 -138
  24. package/src/collection.test.ts +0 -293
  25. package/src/collection.ts +0 -525
  26. package/src/cron.test.ts +0 -164
  27. package/src/cron.ts +0 -62
  28. package/src/data-proxy.test.ts +0 -183
  29. package/src/errors.ts +0 -9
  30. package/src/functions.ts +0 -82
  31. package/src/index.ts +0 -639
  32. package/src/like-pattern-redos.test.ts +0 -61
  33. package/src/offline-codec.ts +0 -79
  34. package/src/offline-connectivity.test.ts +0 -191
  35. package/src/offline-connectivity.ts +0 -255
  36. package/src/offline-idb-store.test.ts +0 -340
  37. package/src/offline-integration.test.ts +0 -180
  38. package/src/offline-query.test.ts +0 -431
  39. package/src/offline-query.ts +0 -529
  40. package/src/offline-store.ts +0 -357
  41. package/src/offline-sync-engine.test.ts +0 -857
  42. package/src/offline.test.ts +0 -897
  43. package/src/offline.ts +0 -1928
  44. package/src/query-contract.types.ts +0 -206
  45. package/src/query_builder.ts +0 -1
  46. package/src/realtime-channel.test.ts +0 -542
  47. package/src/realtime-channel.ts +0 -539
  48. package/src/realtime-concurrent-subscribe.test.ts +0 -102
  49. package/src/realtime-error-surfacing.test.ts +0 -105
  50. package/src/realtime-optout.test.ts +0 -279
  51. package/src/realtime-row-identity.test.ts +0 -254
  52. package/src/realtime-subscription-key.test.ts +0 -92
  53. package/src/reviver.ts +0 -39
  54. package/src/sdk_query_builder.ts +0 -206
  55. package/src/storage-key-encoding.test.ts +0 -65
  56. package/src/storage-registry.ts +0 -102
  57. package/src/storage.ts +0 -253
  58. package/src/transport-baseurl.test.ts +0 -101
  59. package/src/transport.ts +0 -505
  60. package/src/vector-search-listen.test.ts +0 -42
  61. package/src/vector-search-query.test.ts +0 -55
  62. package/src/websocket-url.test.ts +0 -97
  63. package/src/websocket.ts +0 -1837
@@ -1,61 +0,0 @@
1
- import { matchesOperator } from "./offline-query";
2
-
3
- /**
4
- * A `like` pattern is user input, and it becomes a regular expression.
5
- *
6
- * `%` translates to `[\s\S]*`, so `%%%%X` becomes four adjacent unbounded
7
- * quantifiers followed by a literal. On a subject that does not match, the
8
- * engine has to try every way of splitting the subject between them — which is
9
- * exponential. Twelve `%` against a forty-character value took **100 seconds**
10
- * on this machine before answering `false`.
11
- *
12
- * The pattern arrives over HTTP: `?title=like.%25%25%25…` is a public filter
13
- * operator, listed in the operator table. On the offline evaluator that freezes
14
- * the tab; the same translation in the Mongo driver hands the expression to the
15
- * database, where it occupies a server thread instead.
16
- *
17
- * Consecutive `%` mean exactly what one `%` means, so collapsing a run is
18
- * semantics-preserving and removes the ambiguity the backtracking feeds on.
19
- */
20
- const HOSTILE = "%".repeat(14) + "X";
21
- const SUBJECT = "a".repeat(48);
22
-
23
- describe("like patterns cannot be made to backtrack", () => {
24
- it("answers a hostile pattern promptly", () => {
25
- const started = Date.now();
26
- const result = matchesOperator(SUBJECT, "like", HOSTILE);
27
- const elapsed = Date.now() - started;
28
-
29
- expect(result).toBe(false);
30
- expect(elapsed).toBeLessThan(1000);
31
- });
32
-
33
- it("answers the case-insensitive form promptly too", () => {
34
- const started = Date.now();
35
- matchesOperator(SUBJECT, "ilike", HOSTILE);
36
-
37
- expect(Date.now() - started).toBeLessThan(1000);
38
- });
39
-
40
- it("still means what LIKE means", () => {
41
- expect(matchesOperator("post-1", "like", "post-%")).toBe(true);
42
- expect(matchesOperator("post-1", "like", "%1")).toBe(true);
43
- expect(matchesOperator("post-1", "like", "%st-%")).toBe(true);
44
- expect(matchesOperator("post-1", "like", "other-%")).toBe(false);
45
- // A run of wildcards is the same query as one wildcard.
46
- expect(matchesOperator("post-1", "like", "post%%%%1")).toBe(true);
47
- expect(matchesOperator("abc", "like", "a_c")).toBe(true);
48
- expect(matchesOperator("abbc", "like", "a_c")).toBe(false);
49
- // `_` is fixed-width, so a run of them still counts.
50
- expect(matchesOperator("abc", "like", "a__")).toBe(true);
51
- expect(matchesOperator("ab", "like", "a__")).toBe(false);
52
- });
53
-
54
- it("keeps an escaped percent literal", () => {
55
- expect(matchesOperator("50%", "like", "50\\%")).toBe(true);
56
- expect(matchesOperator("500", "like", "50\\%")).toBe(false);
57
- // An escaped percent next to a wildcard is still a literal.
58
- expect(matchesOperator("50%off", "like", "50\\%%")).toBe(true);
59
- expect(matchesOperator("50off", "like", "50\\%%")).toBe(false);
60
- });
61
- });
@@ -1,79 +0,0 @@
1
- import { EntityReference, EntityRelation, GeoPoint, Vector } from "@rebasepro/types";
2
- import { rebaseReviver } from "./reviver";
3
-
4
- /**
5
- * Lossless round-tripping of rows through the offline store.
6
- *
7
- * Both persistence backends move values by structured clone, which keeps
8
- * `Date` but flattens every class instance to a plain object. For
9
- * `EntityReference`/`EntityRelation` that is harmless — they carry their own
10
- * `__type` discriminator, so the JSON reviver can rebuild them — but
11
- * `GeoPoint` and `Vector` do not, and would come back out of the cache as
12
- * anonymous `{ latitude, longitude }` / `{ value }` bags. A row read from the
13
- * cache must be indistinguishable from the same row read from the network, so
14
- * those two are tagged on the way in and revived on the way out.
15
- *
16
- * Type tests here are structural rather than `instanceof`, because a structured
17
- * clone can arrive from another realm — an iframe, a worker, or the polyfill
18
- * the tests run against — where the constructor identity differs but the value
19
- * is the real thing. Only *plain* objects are walked; anything else is passed
20
- * through whole, so a class instance is never quietly reduced to `{}`.
21
- */
22
-
23
- function isDate(value: unknown): value is Date {
24
- return Object.prototype.toString.call(value) === "[object Date]";
25
- }
26
-
27
- /** An object literal — not a Date, RegExp, Map, or any class instance. */
28
- function isPlainObject(value: unknown): value is Record<string, unknown> {
29
- if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
30
- const proto = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null;
31
- if (proto === null || proto === Object.prototype) return true;
32
- // A literal cloned out of another realm has a different `Object.prototype`
33
- // but is still, in every way that matters here, a plain object.
34
- return proto.constructor?.name === "Object";
35
- }
36
-
37
- function dehydrateValue(value: unknown): unknown {
38
- if (value === null || value === undefined) return value;
39
- if (value instanceof GeoPoint) {
40
- return { __type: "GeoPoint", latitude: value.latitude, longitude: value.longitude };
41
- }
42
- if (value instanceof Vector) return { __type: "Vector", value: [...value.value] };
43
- // EntityReference/EntityRelation already serialize themselves via `__type`
44
- // own properties, so a structured clone is enough for the reviver below.
45
- if (value instanceof EntityReference || value instanceof EntityRelation) return value;
46
- if (Array.isArray(value)) return value.map(dehydrateValue);
47
- if (isPlainObject(value)) {
48
- const out: Record<string, unknown> = {};
49
- for (const [key, inner] of Object.entries(value)) out[key] = dehydrateValue(inner);
50
- return out;
51
- }
52
- return value;
53
- }
54
-
55
- function hydrateValue(value: unknown): unknown {
56
- if (value === null || value === undefined || isDate(value)) return value;
57
- if (Array.isArray(value)) return value.map(hydrateValue);
58
- if (typeof value === "object") {
59
- const revived = rebaseReviver("", value);
60
- // The reviver recognised it — hand back the class instance untouched
61
- // rather than walking into its (now private) internals.
62
- if (revived !== value) return revived;
63
- if (!isPlainObject(value)) return value;
64
- const out: Record<string, unknown> = {};
65
- for (const [key, inner] of Object.entries(value)) out[key] = hydrateValue(inner);
66
- return out;
67
- }
68
- return value;
69
- }
70
-
71
- /** Prepare a row for the store. */
72
- export function dehydrateRow<T extends Record<string, unknown>>(row: T): Record<string, unknown> {
73
- return dehydrateValue(row) as Record<string, unknown>;
74
- }
75
-
76
- /** Restore a row read back from the store. */
77
- export function hydrateRow<T extends Record<string, unknown>>(row: Record<string, unknown>): T {
78
- return hydrateValue(row) as T;
79
- }
@@ -1,191 +0,0 @@
1
- import {
2
- ConnectivityMonitor,
3
- isDuplicateKeyError,
4
- isNetworkError,
5
- isRetryableError
6
- } from "./offline-connectivity";
7
- import { RebaseApiError } from "./transport";
8
-
9
- /**
10
- * The monitor decides whether a request is worth sending at all, so getting it
11
- * wrong is either an app that hangs on every read during an outage, or one that
12
- * never notices the network came back.
13
- */
14
- describe("network error classification", () => {
15
- it("recognises a request that never reached the server", () => {
16
- expect(isNetworkError(new TypeError("Failed to fetch"))).toBe(true);
17
- expect(isNetworkError(new TypeError("fetch failed"))).toBe(true);
18
- expect(isNetworkError(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBe(true);
19
- expect(isNetworkError(Object.assign(new Error("timed out"), { name: "TimeoutError" }))).toBe(true);
20
- expect(isNetworkError(new RebaseApiError("no response", { status: 0 }))).toBe(true);
21
- });
22
-
23
- it("does not mistake a server's answer for a dead network", () => {
24
- expect(isNetworkError(new RebaseApiError("nope", { status: 403 }))).toBe(false);
25
- expect(isNetworkError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
26
- // A programming error must propagate, not be swallowed as "offline".
27
- expect(isNetworkError(new RangeError("bug"))).toBe(false);
28
- });
29
-
30
- it("retries what will pass and gives up on what will not", () => {
31
- expect(isRetryableError(new TypeError("Failed to fetch"))).toBe(true);
32
- expect(isRetryableError(new RebaseApiError("busy", { status: 429 }))).toBe(true);
33
- expect(isRetryableError(new RebaseApiError("down", { status: 503 }))).toBe(true);
34
- expect(isRetryableError(new RebaseApiError("gateway", { status: 502 }))).toBe(true);
35
-
36
- expect(isRetryableError(new RebaseApiError("invalid", { status: 400 }))).toBe(false);
37
- expect(isRetryableError(new RebaseApiError("denied", { status: 403 }))).toBe(false);
38
- expect(isRetryableError(new RebaseApiError("gone", { status: 404 }))).toBe(false);
39
- // A 500 is far more often a bug the same payload will hit again than a
40
- // blip, and retrying it forever jams every write queued behind it.
41
- expect(isRetryableError(new RebaseApiError("boom", { status: 500 }))).toBe(false);
42
- });
43
-
44
- it("retries a write the server is still answering, and only that 409", () => {
45
- // The server's own message says to retry — "its result will be
46
- // replayed" — and a key whose claim outlived the process that took it
47
- // is refused until the lease expires. Giving up instead rolls back a
48
- // write that retrying would have completed.
49
- expect(isRetryableError(new RebaseApiError("in progress", {
50
- status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
51
- }))).toBe(true);
52
- // Every other 409 is a real conflict and stays fatal.
53
- expect(isRetryableError(new RebaseApiError("row exists", { status: 409, code: "23505" }))).toBe(false);
54
- expect(isRetryableError(new RebaseApiError("conflict", { status: 409 }))).toBe(false);
55
- expect(isRetryableError(new RebaseApiError("reused", {
56
- status: 422, code: "IDEMPOTENCY_KEY_REUSED"
57
- }))).toBe(false);
58
- });
59
-
60
- it("does not read an unanswered write as a row that is already there", () => {
61
- // The status alone cannot decide it. Read as a duplicate, the queue
62
- // went looking for a row that was never written, found nothing,
63
- // concluded there was nothing left to do and deleted the write.
64
- expect(isDuplicateKeyError(new RebaseApiError("in progress", {
65
- status: 409, code: "IDEMPOTENCY_KEY_IN_PROGRESS"
66
- }))).toBe(false);
67
-
68
- expect(isDuplicateKeyError(new RebaseApiError("dup", { status: 400, code: "23505" }))).toBe(true);
69
- expect(isDuplicateKeyError(new RebaseApiError("conflict", { status: 409 }))).toBe(true);
70
- expect(isDuplicateKeyError(new RebaseApiError("gone", { status: 404 }))).toBe(false);
71
- });
72
- });
73
-
74
- describe("ConnectivityMonitor", () => {
75
- function createMonitor(overrides: Partial<ConstructorParameters<typeof ConnectivityMonitor>[0]> = {}) {
76
- let now = 1_000_000;
77
- const timers: { fn: () => void; at: number }[] = [];
78
- const monitor = new ConnectivityMonitor({
79
- initialBackoffMs: 100,
80
- maxBackoffMs: 800,
81
- now: () => now,
82
- setTimer: ((fn: () => void, ms: number) => {
83
- timers.push({ fn, at: now + ms });
84
- return timers.length as unknown as ReturnType<typeof setTimeout>;
85
- }) as never,
86
- clearTimer: (() => undefined) as never,
87
- ...overrides
88
- });
89
- const advance = (ms: number) => {
90
- now += ms;
91
- for (const timer of timers.splice(0)) {
92
- if (timer.at <= now) timer.fn();
93
- else timers.push(timer);
94
- }
95
- };
96
- return { monitor, advance, at: () => now };
97
- }
98
-
99
- it("starts willing to try", () => {
100
- const { monitor } = createMonitor();
101
- expect(monitor.isOnline()).toBe(true);
102
- expect(monitor.shouldAttempt()).toBe(true);
103
- });
104
-
105
- it("stops attempting after a failure, until the backoff window opens", () => {
106
- const { monitor, advance } = createMonitor();
107
- monitor.markFailure();
108
-
109
- expect(monitor.isOnline()).toBe(false);
110
- // This is the whole point: the second read during an outage costs
111
- // nothing instead of another timeout.
112
- expect(monitor.shouldAttempt()).toBe(false);
113
-
114
- advance(200);
115
- expect(monitor.shouldAttempt()).toBe(true);
116
- });
117
-
118
- it("doubles the delay on repeated failures and caps it", () => {
119
- const { monitor, advance } = createMonitor();
120
- monitor.markFailure();
121
- const first = monitor.msUntilRetry();
122
- advance(first);
123
-
124
- monitor.markFailure();
125
- const second = monitor.msUntilRetry();
126
- expect(second).toBeGreaterThan(first);
127
-
128
- for (let i = 0; i < 10; i++) {
129
- advance(monitor.msUntilRetry());
130
- monitor.markFailure();
131
- }
132
- // 800 plus the 20% jitter ceiling.
133
- expect(monitor.msUntilRetry()).toBeLessThanOrEqual(800 * 1.2);
134
- });
135
-
136
- it("resets the backoff once a request gets through", () => {
137
- const { monitor, advance } = createMonitor();
138
- monitor.markFailure();
139
- advance(monitor.msUntilRetry());
140
- monitor.markFailure();
141
- monitor.markSuccess();
142
-
143
- expect(monitor.isOnline()).toBe(true);
144
- expect(monitor.shouldAttempt()).toBe(true);
145
- monitor.markFailure();
146
- // Back to the initial delay, not to where the doubling had reached.
147
- expect(monitor.msUntilRetry()).toBeLessThanOrEqual(100 * 1.2);
148
- });
149
-
150
- it("fires the retry hook when the window opens", () => {
151
- const { monitor, advance } = createMonitor();
152
- let retries = 0;
153
- monitor.onRetryDue = () => { retries++; };
154
-
155
- monitor.markFailure();
156
- expect(retries).toBe(0);
157
- advance(200);
158
- expect(retries).toBe(1);
159
- });
160
-
161
- it("backs off without claiming the connection is gone", () => {
162
- const { monitor } = createMonitor();
163
- // A 429 means the server answered — the app is demonstrably online and
164
- // an "offline" badge would be a lie.
165
- monitor.deferRetry();
166
- expect(monitor.isOnline()).toBe(true);
167
- expect(monitor.shouldAttempt()).toBe(true);
168
- });
169
-
170
- it("notifies listeners on each transition, and only on transitions", () => {
171
- const { monitor } = createMonitor();
172
- const seen: boolean[] = [];
173
- monitor.onChange((online) => seen.push(online));
174
-
175
- monitor.markFailure();
176
- monitor.markFailure();
177
- monitor.markSuccess();
178
- monitor.markSuccess();
179
-
180
- expect(seen).toEqual([false, true]);
181
- });
182
-
183
- it("keeps attempting when backoff suppression is off", () => {
184
- // Without a retry timer nothing would ever reopen the window, so
185
- // suppressing attempts would strand the client offline forever.
186
- const { monitor } = createMonitor({ respectBackoff: false });
187
- monitor.markFailure();
188
- expect(monitor.isOnline()).toBe(false);
189
- expect(monitor.shouldAttempt()).toBe(true);
190
- });
191
- });
@@ -1,255 +0,0 @@
1
- import { RebaseApiError } from "./transport";
2
-
3
- /**
4
- * Whether the network is worth trying, and when to try again after it wasn't.
5
- *
6
- * `navigator.onLine` is necessary but not sufficient: it reports the state of
7
- * the network interface, so it stays `true` behind a captive portal, on a
8
- * connection that resolves DNS but reaches nothing, and while the API itself
9
- * is down. This tracks what actually happened to requests as well, so the
10
- * first failure is the only one an app pays for — everything after it inside
11
- * the backoff window skips the doomed round trip and answers from the local
12
- * store immediately, which is the difference between an app that freezes when
13
- * the wifi drops and one that does not.
14
- */
15
-
16
- /** The request never reached the server, so nothing was decided by it. */
17
- export function isNetworkError(error: unknown): boolean {
18
- if (error instanceof RebaseApiError) {
19
- // A 0 status is what a transport reports when it has no response at all.
20
- return error.status === 0;
21
- }
22
- // fetch rejects with TypeError on network failure in every runtime we
23
- // support (browsers: "Failed to fetch"/"Load failed"; undici: "fetch
24
- // failed").
25
- if (error instanceof TypeError) return true;
26
- const name = (error as { name?: string } | undefined)?.name;
27
- // AbortError covers both an explicit abort and a fetch timeout; the others
28
- // are what Node and Safari surface for a dropped connection.
29
- return name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
30
- }
31
-
32
- /**
33
- * Statuses that mean "not now" rather than "not ever": a queued write that
34
- * gets one of these is worth replaying, while a 400 or a 403 never will be.
35
- * 500 is deliberately absent — an unhandled server error is far more often a
36
- * bug the same payload will hit again than a blip, and retrying it forever
37
- * jams every write behind it.
38
- */
39
- const RETRYABLE_STATUSES = new Set([408, 425, 429, 502, 503, 504]);
40
-
41
- /**
42
- * The server holds this key for a request it has not answered yet.
43
- *
44
- * It is a 409 like a duplicate row is a 409, and nothing but the code separates
45
- * them — one means "your write is already there", the other means "your write
46
- * may not have happened at all, ask again".
47
- */
48
- const IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
49
-
50
- /**
51
- * Is the server still answering an earlier attempt of this same write?
52
- *
53
- * The only correct response is to ask again — which is exactly what the
54
- * server's own message says, and exactly what this SDK used not to do.
55
- */
56
- export function isIdempotencyInProgressError(error: unknown): boolean {
57
- return error instanceof RebaseApiError
58
- && error.status === 409
59
- && error.code === IDEMPOTENCY_IN_PROGRESS;
60
- }
61
-
62
- /** Is this failure worth another attempt later? */
63
- export function isRetryableError(error: unknown): boolean {
64
- if (isNetworkError(error)) return true;
65
- if (!(error instanceof RebaseApiError)) return false;
66
- // The one 409 that resolves on its own. A key whose claim outlived the
67
- // request that took it — the process was killed between the write and the
68
- // answer — is refused until the claim's lease expires, and giving up on it
69
- // means dropping a write that retrying would have completed.
70
- if (isIdempotencyInProgressError(error)) return true;
71
- return error.status !== undefined && RETRYABLE_STATUSES.has(error.status);
72
- }
73
-
74
- /**
75
- * Did this write fail because the row is already there?
76
- *
77
- * Matched on the SQLSTATE the server passes through (`23505`, unique_violation)
78
- * and on 409, never on the message — a duplicate-key message names the
79
- * constraint and the values, so it is neither stable nor safe to parse.
80
- *
81
- * The queue uses this to recognise its own earlier attempt. A create whose
82
- * response was lost is replayed, and for a row carrying an id the SDK generated
83
- * the server can only be rejecting it because the first attempt actually landed.
84
- *
85
- * Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
86
- * is a 409 that means the opposite — the row may not exist at all. Read as a
87
- * duplicate, the queue looked for a row that was never written, found nothing,
88
- * concluded there was nothing left to do and deleted the write from the queue.
89
- */
90
- export function isDuplicateKeyError(error: unknown): boolean {
91
- if (!(error instanceof RebaseApiError)) return false;
92
- if (error.code === "23505") return true;
93
- return error.status === 409 && !isIdempotencyInProgressError(error);
94
- }
95
-
96
- export interface ConnectivityOptions {
97
- /** First retry delay after a failure. Defaults to 1 000 ms. */
98
- initialBackoffMs?: number;
99
- /** Ceiling for the doubling retry delay. Defaults to 60 000 ms. */
100
- maxBackoffMs?: number;
101
- /**
102
- * Let a known-failed connection suppress further attempts until the
103
- * backoff window opens. On by default — it is what makes a read or write
104
- * during an outage instant instead of a timeout. Turn it off when nothing
105
- * will ever wake the client up again (no retry timer, no `online` event),
106
- * where suppressing attempts would mean never recovering.
107
- */
108
- respectBackoff?: boolean;
109
- /** Injected for tests. */
110
- now?: () => number;
111
- /** Injected for tests; must return a handle `clearTimeout` accepts. */
112
- setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
113
- clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
114
- }
115
-
116
- export class ConnectivityMonitor {
117
- private state: "online" | "offline" = "online";
118
- private backoffMs: number;
119
- private readonly initialBackoffMs: number;
120
- private readonly maxBackoffMs: number;
121
- private retryAt = 0;
122
- private timer?: ReturnType<typeof setTimeout>;
123
- private listeners = new Set<(online: boolean) => void>();
124
- private readonly respectBackoff: boolean;
125
- private readonly now: () => number;
126
- private readonly setTimer: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
127
- private readonly clearTimer: (handle: ReturnType<typeof setTimeout>) => void;
128
- /** Called when the backoff window expires, to drive an automatic retry. */
129
- onRetryDue?: () => void;
130
-
131
- private readonly handleOnline = () => {
132
- // The OS says the interface is back. Trust it enough to try
133
- // immediately rather than sitting out the rest of the backoff — and to
134
- // say so, or a client with nothing queued would have no request whose
135
- // success could ever flip the badge back to "online".
136
- this.retryAt = 0;
137
- this.backoffMs = this.initialBackoffMs;
138
- this.clearPendingTimer();
139
- this.setState("online");
140
- this.onRetryDue?.();
141
- };
142
- private readonly handleOffline = () => {
143
- this.setState("offline");
144
- };
145
-
146
- constructor(options: ConnectivityOptions = {}) {
147
- this.initialBackoffMs = options.initialBackoffMs ?? 1_000;
148
- this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 60_000);
149
- this.backoffMs = this.initialBackoffMs;
150
- this.respectBackoff = options.respectBackoff ?? true;
151
- this.now = options.now ?? (() => Date.now());
152
- this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
153
- this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
154
-
155
- if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
156
- window.addEventListener("online", this.handleOnline);
157
- window.addEventListener("offline", this.handleOffline);
158
- }
159
- if (typeof navigator !== "undefined" && navigator.onLine === false) {
160
- this.state = "offline";
161
- }
162
- }
163
-
164
- /** What the app should be told: are we connected? */
165
- isOnline(): boolean {
166
- if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
167
- return this.state === "online";
168
- }
169
-
170
- /**
171
- * Should this request even be sent? False means "answer from the local
172
- * store instead" — the request would only burn a timeout to reach the same
173
- * conclusion the last one already did.
174
- */
175
- shouldAttempt(): boolean {
176
- if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
177
- if (this.state === "online" || !this.respectBackoff) return true;
178
- // Exactly one request is let through when the window opens; it is the
179
- // probe whose outcome decides whether we are back.
180
- return this.now() >= this.retryAt;
181
- }
182
-
183
- /** A request reached the server. */
184
- markSuccess(): void {
185
- this.backoffMs = this.initialBackoffMs;
186
- this.retryAt = 0;
187
- this.clearPendingTimer();
188
- this.setState("online");
189
- }
190
-
191
- /** A request did not reach the server: we are offline until proven otherwise. */
192
- markFailure(): void {
193
- this.deferRetry();
194
- this.setState("offline");
195
- }
196
-
197
- /**
198
- * Back off and try again later without claiming the connection is gone.
199
- * This is what a 429 or a 503 deserves — the server answered, so the app
200
- * is demonstrably online; it just should not hammer.
201
- */
202
- deferRetry(): void {
203
- const jitter = 0.8 + Math.random() * 0.4;
204
- this.retryAt = this.now() + this.backoffMs * jitter;
205
- const delay = Math.max(0, this.retryAt - this.now());
206
- this.backoffMs = Math.min(this.maxBackoffMs, this.backoffMs * 2);
207
- this.scheduleRetry(delay);
208
- }
209
-
210
- /** Milliseconds until the next attempt is allowed; 0 when one is allowed now. */
211
- msUntilRetry(): number {
212
- if (this.state === "online") return 0;
213
- return Math.max(0, this.retryAt - this.now());
214
- }
215
-
216
- onChange(listener: (online: boolean) => void): () => void {
217
- this.listeners.add(listener);
218
- return () => this.listeners.delete(listener);
219
- }
220
-
221
- dispose(): void {
222
- if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
223
- window.removeEventListener("online", this.handleOnline);
224
- window.removeEventListener("offline", this.handleOffline);
225
- }
226
- this.clearPendingTimer();
227
- this.listeners.clear();
228
- this.onRetryDue = undefined;
229
- }
230
-
231
- private scheduleRetry(delay: number): void {
232
- this.clearPendingTimer();
233
- if (!this.onRetryDue) return;
234
- this.timer = this.setTimer(() => {
235
- this.timer = undefined;
236
- this.onRetryDue?.();
237
- }, delay);
238
- // A retry timer must never be the reason a Node script refuses to exit.
239
- (this.timer as unknown as { unref?: () => void }).unref?.();
240
- }
241
-
242
- private clearPendingTimer(): void {
243
- if (this.timer !== undefined) {
244
- this.clearTimer(this.timer);
245
- this.timer = undefined;
246
- }
247
- }
248
-
249
- private setState(next: "online" | "offline"): void {
250
- if (this.state === next) return;
251
- this.state = next;
252
- const online = this.isOnline();
253
- for (const listener of this.listeners) listener(online);
254
- }
255
- }