orez-lite 0.9.7
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/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/browser/generated/sqlite3-browser.wasm +0 -0
- package/dist/browser/generated/sync_wasm.js +922 -0
- package/dist/browser/generated/sync_wasm_bg.wasm +0 -0
- package/dist/browser/host.d.ts +10 -0
- package/dist/browser/host.d.ts.map +1 -0
- package/dist/browser/host.js +496 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/idb-snapshot.d.ts +11 -0
- package/dist/browser/idb-snapshot.d.ts.map +1 -0
- package/dist/browser/idb-snapshot.js +122 -0
- package/dist/browser/idb-snapshot.js.map +1 -0
- package/dist/browser/index.d.ts +5 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +5 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/message-port.d.ts +7 -0
- package/dist/browser/message-port.d.ts.map +1 -0
- package/dist/browser/message-port.js +188 -0
- package/dist/browser/message-port.js.map +1 -0
- package/dist/browser/sqlite-adapter.d.ts +53 -0
- package/dist/browser/sqlite-adapter.d.ts.map +1 -0
- package/dist/browser/sqlite-adapter.js +126 -0
- package/dist/browser/sqlite-adapter.js.map +1 -0
- package/dist/browser/types.d.ts +52 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/cf-deploy/bundle.d.ts +18 -0
- package/dist/cf-deploy/bundle.d.ts.map +1 -0
- package/dist/cf-deploy/bundle.js +273 -0
- package/dist/cf-deploy/bundle.js.map +1 -0
- package/dist/cf-deploy/config.d.ts +12 -0
- package/dist/cf-deploy/config.d.ts.map +1 -0
- package/dist/cf-deploy/config.js +11 -0
- package/dist/cf-deploy/config.js.map +1 -0
- package/dist/cf-deploy/index.d.ts +10 -0
- package/dist/cf-deploy/index.d.ts.map +1 -0
- package/dist/cf-deploy/index.js +12 -0
- package/dist/cf-deploy/index.js.map +1 -0
- package/dist/cf-deploy/leaves.d.ts +8 -0
- package/dist/cf-deploy/leaves.d.ts.map +1 -0
- package/dist/cf-deploy/leaves.js +66 -0
- package/dist/cf-deploy/leaves.js.map +1 -0
- package/dist/cf-deploy/migration.d.ts +27 -0
- package/dist/cf-deploy/migration.d.ts.map +1 -0
- package/dist/cf-deploy/migration.js +734 -0
- package/dist/cf-deploy/migration.js.map +1 -0
- package/dist/cf-deploy/nativeMigrations.d.ts +24 -0
- package/dist/cf-deploy/nativeMigrations.d.ts.map +1 -0
- package/dist/cf-deploy/nativeMigrations.js +185 -0
- package/dist/cf-deploy/nativeMigrations.js.map +1 -0
- package/dist/cf-deploy/prune.d.ts +14 -0
- package/dist/cf-deploy/prune.d.ts.map +1 -0
- package/dist/cf-deploy/prune.js +235 -0
- package/dist/cf-deploy/prune.js.map +1 -0
- package/dist/cf-deploy/readiness.d.ts +11 -0
- package/dist/cf-deploy/readiness.d.ts.map +1 -0
- package/dist/cf-deploy/readiness.js +56 -0
- package/dist/cf-deploy/readiness.js.map +1 -0
- package/dist/cf-deploy/sources.d.ts +3 -0
- package/dist/cf-deploy/sources.d.ts.map +1 -0
- package/dist/cf-deploy/sources.js +24 -0
- package/dist/cf-deploy/sources.js.map +1 -0
- package/dist/cf-deploy/wrangler.d.ts +9 -0
- package/dist/cf-deploy/wrangler.d.ts.map +1 -0
- package/dist/cf-deploy/wrangler.js +48 -0
- package/dist/cf-deploy/wrangler.js.map +1 -0
- package/dist/cf-do/application-sql.d.ts +49 -0
- package/dist/cf-do/application-sql.d.ts.map +1 -0
- package/dist/cf-do/application-sql.js +124 -0
- package/dist/cf-do/application-sql.js.map +1 -0
- package/dist/cf-do/cdc.d.ts +155 -0
- package/dist/cf-do/cdc.d.ts.map +1 -0
- package/dist/cf-do/cdc.js +723 -0
- package/dist/cf-do/cdc.js.map +1 -0
- package/dist/cf-do/cursor-pull.d.ts +27 -0
- package/dist/cf-do/cursor-pull.d.ts.map +1 -0
- package/dist/cf-do/cursor-pull.js +78 -0
- package/dist/cf-do/cursor-pull.js.map +1 -0
- package/dist/cf-do/lite-data-worker.d.ts +171 -0
- package/dist/cf-do/lite-data-worker.d.ts.map +1 -0
- package/dist/cf-do/lite-data-worker.js +685 -0
- package/dist/cf-do/lite-data-worker.js.map +1 -0
- package/dist/cf-do/namespace-backup.d.ts +85 -0
- package/dist/cf-do/namespace-backup.d.ts.map +1 -0
- package/dist/cf-do/namespace-backup.js +470 -0
- package/dist/cf-do/namespace-backup.js.map +1 -0
- package/dist/cf-do/row-undo.d.ts +36 -0
- package/dist/cf-do/row-undo.d.ts.map +1 -0
- package/dist/cf-do/row-undo.js +221 -0
- package/dist/cf-do/row-undo.js.map +1 -0
- package/dist/cf-do/tx-journal.d.ts +118 -0
- package/dist/cf-do/tx-journal.d.ts.map +1 -0
- package/dist/cf-do/tx-journal.js +717 -0
- package/dist/cf-do/tx-journal.js.map +1 -0
- package/dist/cf-do/watermark.d.ts +29 -0
- package/dist/cf-do/watermark.d.ts.map +1 -0
- package/dist/cf-do/watermark.js +105 -0
- package/dist/cf-do/watermark.js.map +1 -0
- package/dist/cf-do/worker.d.ts +282 -0
- package/dist/cf-do/worker.d.ts.map +1 -0
- package/dist/cf-do/worker.js +1999 -0
- package/dist/cf-do/worker.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -0
- package/dist/cloudflare-build.d.ts +9 -0
- package/dist/cloudflare-build.d.ts.map +1 -0
- package/dist/cloudflare-build.js +9 -0
- package/dist/cloudflare-build.js.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.js +2 -0
- package/dist/cloudflare-bun-wasm-loader.js.map +1 -0
- package/dist/cloudflare-query-compiler.d.ts +2 -0
- package/dist/cloudflare-query-compiler.d.ts.map +1 -0
- package/dist/cloudflare-query-compiler.js +2 -0
- package/dist/cloudflare-query-compiler.js.map +1 -0
- package/dist/cloudflare-runtime.d.ts +15 -0
- package/dist/cloudflare-runtime.d.ts.map +1 -0
- package/dist/cloudflare-runtime.js +11 -0
- package/dist/cloudflare-runtime.js.map +1 -0
- package/dist/cloudflare-sync.d.ts +3 -0
- package/dist/cloudflare-sync.d.ts.map +1 -0
- package/dist/cloudflare-sync.js +3 -0
- package/dist/cloudflare-sync.js.map +1 -0
- package/dist/cloudflare-transaction-query.d.ts +2 -0
- package/dist/cloudflare-transaction-query.d.ts.map +1 -0
- package/dist/cloudflare-transaction-query.js +2 -0
- package/dist/cloudflare-transaction-query.js.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.js +2 -0
- package/dist/cloudflare-vite-wasm-loader.js.map +1 -0
- package/dist/cloudflare.d.ts +3 -0
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +3 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/do-sql-tracking.d.ts +114 -0
- package/dist/do-sql-tracking.d.ts.map +1 -0
- package/dist/do-sql-tracking.js +280 -0
- package/dist/do-sql-tracking.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/wake.d.ts +22 -0
- package/dist/wake.d.ts.map +1 -0
- package/dist/wake.js +114 -0
- package/dist/wake.js.map +1 -0
- package/dist/worker/cf-do-shim.d.ts +53 -0
- package/dist/worker/cf-do-shim.d.ts.map +1 -0
- package/dist/worker/cf-do-shim.js +66 -0
- package/dist/worker/cf-do-shim.js.map +1 -0
- package/dist/zero-http/encrypted-column-codec.d.ts +46 -0
- package/dist/zero-http/encrypted-column-codec.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-codec.js +676 -0
- package/dist/zero-http/encrypted-column-codec.js.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts +7 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.js +161 -0
- package/dist/zero-http/encrypted-column-conformance.js.map +1 -0
- package/dist/zero-http/fixture-schema.d.ts +127 -0
- package/dist/zero-http/fixture-schema.d.ts.map +1 -0
- package/dist/zero-http/fixture-schema.js +56 -0
- package/dist/zero-http/fixture-schema.js.map +1 -0
- package/dist/zero-http/mount.d.ts +71 -0
- package/dist/zero-http/mount.d.ts.map +1 -0
- package/dist/zero-http/mount.js +594 -0
- package/dist/zero-http/mount.js.map +1 -0
- package/dist/zero-http/payload-codec.d.ts +91 -0
- package/dist/zero-http/payload-codec.d.ts.map +1 -0
- package/dist/zero-http/payload-codec.js +10 -0
- package/dist/zero-http/payload-codec.js.map +1 -0
- package/dist/zero-http/server.d.ts +16 -0
- package/dist/zero-http/server.d.ts.map +1 -0
- package/dist/zero-http/server.js +270 -0
- package/dist/zero-http/server.js.map +1 -0
- package/dist/zero-http/test-harness.d.ts +32 -0
- package/dist/zero-http/test-harness.d.ts.map +1 -0
- package/dist/zero-http/test-harness.js +77 -0
- package/dist/zero-http/test-harness.js.map +1 -0
- package/dist/zero-http/transport.d.ts +71 -0
- package/dist/zero-http/transport.d.ts.map +1 -0
- package/dist/zero-http/transport.js +1106 -0
- package/dist/zero-http/transport.js.map +1 -0
- package/package.json +116 -0
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
// http-pull transport: runs a stock @rocicorp/zero client over stateless HTTP
|
|
2
|
+
// by intercepting its /sync/v51/connect WebSocket with a shim that translates
|
|
3
|
+
// pull responses into v51 pokes. this browser-only module and the server mount
|
|
4
|
+
// are the two halves of Orez's zero-http protocol. the
|
|
5
|
+
// wire contract (lexicographic string cookies, gotQueriesPatch poke-part
|
|
6
|
+
// ordering, bounded FIFO push batching, updateAuth, 401→Unauthorized frame,
|
|
7
|
+
// teardown drain) is pinned by the tests in this directory. do not "simplify"
|
|
8
|
+
// any of it without re-running those tests against a stock zero client.
|
|
9
|
+
import { identityPayloadCodec } from './payload-codec.js';
|
|
10
|
+
const COOKIE_WIDTH = 20;
|
|
11
|
+
// the stock zero-cache pusher drains queued frames into a single request once
|
|
12
|
+
// its current request completes. keep the same backpressure relief here, but
|
|
13
|
+
// cap the number of mutations so one response cannot monopolize the HTTP
|
|
14
|
+
// transport indefinitely under a sustained producer.
|
|
15
|
+
const MAX_PUSH_BATCH_MUTATIONS = 64;
|
|
16
|
+
// @rocicorp/zero 1.6 starts this deadline before its async createSocket work.
|
|
17
|
+
// the connect URL's `ts` is captured at the same attempt boundary.
|
|
18
|
+
const ZERO_CONNECT_TIMEOUT_MS = 10_000;
|
|
19
|
+
// ceiling on a server-supplied Retry-After. a daily quota can report a reset
|
|
20
|
+
// hours out; honoring that verbatim would leave the client dark until then.
|
|
21
|
+
const MAX_RETRY_AFTER_BACKOFF_MS = 60_000;
|
|
22
|
+
export function createWakeTokenFetcher(tokenURL, init = {}, fetchImplementation = globalThis.fetch) {
|
|
23
|
+
if (!fetchImplementation) {
|
|
24
|
+
throw new Error('createWakeTokenFetcher requires a fetch implementation');
|
|
25
|
+
}
|
|
26
|
+
const request = fetchImplementation.bind(globalThis);
|
|
27
|
+
return async () => {
|
|
28
|
+
const fetchInit = typeof init === 'function' ? await init() : init;
|
|
29
|
+
const response = await request(tokenURL, { ...fetchInit, method: 'POST' });
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`wake token request failed: ${response.status}`);
|
|
32
|
+
}
|
|
33
|
+
const body = await response.json().catch(() => null);
|
|
34
|
+
if (!body ||
|
|
35
|
+
typeof body !== 'object' ||
|
|
36
|
+
Array.isArray(body) ||
|
|
37
|
+
!('token' in body) ||
|
|
38
|
+
typeof body.token !== 'string' ||
|
|
39
|
+
!body.token) {
|
|
40
|
+
throw new Error('wake token response is invalid');
|
|
41
|
+
}
|
|
42
|
+
return body.token;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const HTTP_PULL_OPTION_FIELDS = [
|
|
46
|
+
'appID',
|
|
47
|
+
'shardNum',
|
|
48
|
+
'origin',
|
|
49
|
+
'pullOrigin',
|
|
50
|
+
'pushOrigin',
|
|
51
|
+
'fetch',
|
|
52
|
+
'pullIntervalMs',
|
|
53
|
+
'wake',
|
|
54
|
+
'queryTransform',
|
|
55
|
+
'payloadCodecID',
|
|
56
|
+
'lifecycle',
|
|
57
|
+
];
|
|
58
|
+
const HTTP_PULL_PAGE_REGISTRY = Symbol.for('on-zero.http-pull.page-registry');
|
|
59
|
+
function isHttpPullPageRegistry(value) {
|
|
60
|
+
return (typeof value === 'object' &&
|
|
61
|
+
value !== null &&
|
|
62
|
+
'pageID' in value &&
|
|
63
|
+
typeof value.pageID === 'string' &&
|
|
64
|
+
'nextTransportGeneration' in value &&
|
|
65
|
+
typeof value.nextTransportGeneration === 'number' &&
|
|
66
|
+
'transportsByOrigin' in value &&
|
|
67
|
+
value.transportsByOrigin instanceof Map);
|
|
68
|
+
}
|
|
69
|
+
function getHttpPullPageRegistry() {
|
|
70
|
+
const existing = Reflect.get(globalThis, HTTP_PULL_PAGE_REGISTRY);
|
|
71
|
+
if (isHttpPullPageRegistry(existing))
|
|
72
|
+
return existing;
|
|
73
|
+
const registry = {
|
|
74
|
+
pageID: `page-${Date.now().toString(36)}`,
|
|
75
|
+
nextTransportGeneration: 0,
|
|
76
|
+
transportsByOrigin: new Map(),
|
|
77
|
+
};
|
|
78
|
+
Reflect.set(globalThis, HTTP_PULL_PAGE_REGISTRY, registry);
|
|
79
|
+
return registry;
|
|
80
|
+
}
|
|
81
|
+
function normalizePayloadCodec(codec) {
|
|
82
|
+
const resolved = codec ?? identityPayloadCodec;
|
|
83
|
+
if (typeof resolved.id !== 'string' ||
|
|
84
|
+
!resolved.id ||
|
|
85
|
+
typeof resolved.encodePush !== 'function' ||
|
|
86
|
+
typeof resolved.decodePull !== 'function') {
|
|
87
|
+
throw new Error('zero-http payload codec must have an id, encodePush, and decodePull');
|
|
88
|
+
}
|
|
89
|
+
return resolved;
|
|
90
|
+
}
|
|
91
|
+
function logHttpPullLifecycle(event) {
|
|
92
|
+
const processValue = Reflect.get(globalThis, 'process');
|
|
93
|
+
if (processValue?.env?.NODE_ENV === 'test' || event.type !== 'failure')
|
|
94
|
+
return;
|
|
95
|
+
console.error(`[orez:client] ${JSON.stringify(event)}`);
|
|
96
|
+
}
|
|
97
|
+
export function installHttpPullTransport(opts) {
|
|
98
|
+
const pageRegistry = getHttpPullPageRegistry();
|
|
99
|
+
const transportID = `${pageRegistry.pageID}:transport:${++pageRegistry.nextTransportGeneration}`;
|
|
100
|
+
const previousWebSocket = globalThis.WebSocket;
|
|
101
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
102
|
+
if (!fetchImpl) {
|
|
103
|
+
throw new Error('installHttpPullTransport requires a fetch implementation');
|
|
104
|
+
}
|
|
105
|
+
const payloadCodec = normalizePayloadCodec(opts.payloadCodec);
|
|
106
|
+
const state = {
|
|
107
|
+
appID: opts.appID ?? 'zero',
|
|
108
|
+
pageID: pageRegistry.pageID,
|
|
109
|
+
transportID,
|
|
110
|
+
origin: new URL(opts.origin),
|
|
111
|
+
originString: trimTrailingSlash(new URL(opts.origin).toString()),
|
|
112
|
+
pullOriginString: trimTrailingSlash(new URL(opts.pullOrigin ?? opts.origin).toString()),
|
|
113
|
+
pushOriginString: trimTrailingSlash(new URL(opts.pushOrigin ?? opts.origin).toString()),
|
|
114
|
+
// the transport invokes this as `state.fetch(...)` — without binding,
|
|
115
|
+
// window.fetch sees `state` as its receiver and browsers throw
|
|
116
|
+
// "Illegal invocation" (node's fetch doesn't care, so tests can't catch it)
|
|
117
|
+
fetch: fetchImpl.bind(globalThis),
|
|
118
|
+
nativeWebSocket: previousWebSocket,
|
|
119
|
+
sockets: new Set(),
|
|
120
|
+
pullIntervalMs: opts.pullIntervalMs,
|
|
121
|
+
wake: opts.wake ?? false,
|
|
122
|
+
queryTransform: opts.queryTransform,
|
|
123
|
+
payloadCodec,
|
|
124
|
+
shardNum: opts.shardNum ?? 0,
|
|
125
|
+
nextPokeID: 0,
|
|
126
|
+
nextSocketGeneration: 0,
|
|
127
|
+
activeSocketGenerationByClient: new Map(),
|
|
128
|
+
activeSocketByClient: new Map(),
|
|
129
|
+
lifecycle: opts.lifecycle ?? logHttpPullLifecycle,
|
|
130
|
+
};
|
|
131
|
+
const Shim = class {
|
|
132
|
+
static CONNECTING = 0;
|
|
133
|
+
static OPEN = 1;
|
|
134
|
+
static CLOSING = 2;
|
|
135
|
+
static CLOSED = 3;
|
|
136
|
+
constructor(url, protocols) {
|
|
137
|
+
if (shouldIntercept(state.origin, url)) {
|
|
138
|
+
return new ZeroHttpSocket(state, url, protocols);
|
|
139
|
+
}
|
|
140
|
+
if (!state.nativeWebSocket) {
|
|
141
|
+
throw new Error(`No native WebSocket available for ${String(url)}`);
|
|
142
|
+
}
|
|
143
|
+
return new state.nativeWebSocket(url, protocols);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
globalThis.WebSocket = Shim;
|
|
147
|
+
const transport = {
|
|
148
|
+
pull: async () => {
|
|
149
|
+
await Promise.all([...state.sockets].map((socket) => socket.pull()));
|
|
150
|
+
},
|
|
151
|
+
flush: async () => {
|
|
152
|
+
await Promise.resolve();
|
|
153
|
+
const generation = state.nextSocketGeneration;
|
|
154
|
+
const sockets = [...state.sockets];
|
|
155
|
+
await Promise.all(sockets.map((socket) => socket.flush()));
|
|
156
|
+
if (state.nextSocketGeneration !== generation ||
|
|
157
|
+
sockets.some((socket) => !state.sockets.has(socket))) {
|
|
158
|
+
throw new Error('transport changed during flush');
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
get connections() {
|
|
162
|
+
return state.sockets.size;
|
|
163
|
+
},
|
|
164
|
+
pageID: state.pageID,
|
|
165
|
+
transportID: state.transportID,
|
|
166
|
+
uninstall: () => {
|
|
167
|
+
if (globalThis.WebSocket === Shim) {
|
|
168
|
+
globalThis.WebSocket = previousWebSocket;
|
|
169
|
+
}
|
|
170
|
+
const registered = pageRegistry.transportsByOrigin.get(state.originString);
|
|
171
|
+
if (registered?.transport === transport) {
|
|
172
|
+
pageRegistry.transportsByOrigin.delete(state.originString);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
return transport;
|
|
177
|
+
}
|
|
178
|
+
// per-origin idempotent install for app usage (ProvideZero rotates zero
|
|
179
|
+
// instances against the same server; installing per rotation would chain
|
|
180
|
+
// shims unboundedly). installed transports live for the page lifetime.
|
|
181
|
+
export function ensureHttpPullTransport(opts) {
|
|
182
|
+
const pageRegistry = getHttpPullPageRegistry();
|
|
183
|
+
const key = trimTrailingSlash(new URL(opts.origin).toString());
|
|
184
|
+
const payloadCodec = normalizePayloadCodec(opts.payloadCodec);
|
|
185
|
+
const options = {
|
|
186
|
+
appID: opts.appID ?? 'zero',
|
|
187
|
+
shardNum: opts.shardNum ?? 0,
|
|
188
|
+
origin: key,
|
|
189
|
+
pullOrigin: trimTrailingSlash(new URL(opts.pullOrigin ?? opts.origin).toString()),
|
|
190
|
+
pushOrigin: trimTrailingSlash(new URL(opts.pushOrigin ?? opts.origin).toString()),
|
|
191
|
+
fetch: opts.fetch ?? globalThis.fetch,
|
|
192
|
+
pullIntervalMs: opts.pullIntervalMs,
|
|
193
|
+
wake: typeof opts.wake === 'object' ? opts.wake.getToken : (opts.wake ?? false),
|
|
194
|
+
queryTransform: opts.queryTransform,
|
|
195
|
+
payloadCodecID: payloadCodec.id,
|
|
196
|
+
lifecycle: opts.lifecycle,
|
|
197
|
+
};
|
|
198
|
+
const existing = pageRegistry.transportsByOrigin.get(key);
|
|
199
|
+
if (existing) {
|
|
200
|
+
const conflicts = HTTP_PULL_OPTION_FIELDS.filter((field) => !Object.is(existing.options[field], options[field]));
|
|
201
|
+
if (conflicts.length > 0) {
|
|
202
|
+
throw new Error(`HTTP pull transport for ${key} is already installed with different ${conflicts.join(', ')}`);
|
|
203
|
+
}
|
|
204
|
+
return existing.transport;
|
|
205
|
+
}
|
|
206
|
+
const transport = installHttpPullTransport({ ...opts, payloadCodec });
|
|
207
|
+
pageRegistry.transportsByOrigin.set(key, { transport, options });
|
|
208
|
+
return transport;
|
|
209
|
+
}
|
|
210
|
+
export function createZeroClientTransport(options = {}) {
|
|
211
|
+
return Object.freeze({
|
|
212
|
+
type: 'orez-client',
|
|
213
|
+
install(origin) {
|
|
214
|
+
return ensureHttpPullTransport({ ...options, origin });
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
export async function flushHttpPullTransports() {
|
|
219
|
+
const pageRegistry = getHttpPullPageRegistry();
|
|
220
|
+
await Promise.all([...pageRegistry.transportsByOrigin.values()].map(({ transport }) => transport.flush()));
|
|
221
|
+
}
|
|
222
|
+
// a data-changed nudge for hosts that know the server advanced (e.g. a local
|
|
223
|
+
// mutation applied elsewhere in the page): pull every installed transport now
|
|
224
|
+
// instead of waiting out its pullIntervalMs.
|
|
225
|
+
export async function pullHttpPullTransports() {
|
|
226
|
+
const pageRegistry = getHttpPullPageRegistry();
|
|
227
|
+
await Promise.all([...pageRegistry.transportsByOrigin.values()].map(({ transport }) => transport.pull()));
|
|
228
|
+
}
|
|
229
|
+
class ZeroHttpSocket {
|
|
230
|
+
state;
|
|
231
|
+
CONNECTING = 0;
|
|
232
|
+
OPEN = 1;
|
|
233
|
+
CLOSING = 2;
|
|
234
|
+
CLOSED = 3;
|
|
235
|
+
url;
|
|
236
|
+
readyState = this.CONNECTING;
|
|
237
|
+
connectURL;
|
|
238
|
+
authToken;
|
|
239
|
+
listeners = {
|
|
240
|
+
open: new Set(),
|
|
241
|
+
message: new Set(),
|
|
242
|
+
close: new Set(),
|
|
243
|
+
error: new Set(),
|
|
244
|
+
};
|
|
245
|
+
clientID;
|
|
246
|
+
clientGroupID;
|
|
247
|
+
wsid;
|
|
248
|
+
cookie;
|
|
249
|
+
pendingGotQueriesPatch = [];
|
|
250
|
+
// a snapshot clear removes both rows and got-query marks from Replicache.
|
|
251
|
+
// remember every delivered mark so a clear-bearing poke can restore them.
|
|
252
|
+
ackedGotHashes = new Set();
|
|
253
|
+
// query-aware extension state: the accumulated un-acked desired-query delta
|
|
254
|
+
// to ship, a client-side query-state version that bumps on each change, and
|
|
255
|
+
// the version/length of the delta the in-flight pull sent (to clear the
|
|
256
|
+
// acked prefix on the server's ack).
|
|
257
|
+
desiredQueryPatch = [];
|
|
258
|
+
queryVersion = 0;
|
|
259
|
+
sentQueryVersion;
|
|
260
|
+
sentQueryPatchLen = 0;
|
|
261
|
+
pullInFlight;
|
|
262
|
+
pullAfterCurrent = false;
|
|
263
|
+
pendingPushes = [];
|
|
264
|
+
pushInFlight = false;
|
|
265
|
+
pushCompletion = Promise.resolve();
|
|
266
|
+
flushGeneration = 0;
|
|
267
|
+
pendingUpstream = new Set();
|
|
268
|
+
nextLocalCookieID;
|
|
269
|
+
openTimer;
|
|
270
|
+
pullTimer;
|
|
271
|
+
wakeSocket;
|
|
272
|
+
wakeConnecting = false;
|
|
273
|
+
wakeReconnectTimer;
|
|
274
|
+
generation;
|
|
275
|
+
zeroInstanceID;
|
|
276
|
+
connectionAttemptID;
|
|
277
|
+
socketID;
|
|
278
|
+
attemptStartedAt;
|
|
279
|
+
settled = false;
|
|
280
|
+
constructor(state, url, protocols) {
|
|
281
|
+
this.state = state;
|
|
282
|
+
this.connectURL = toHttpURL(url);
|
|
283
|
+
this.url = String(url);
|
|
284
|
+
this.clientID = this.connectURL.searchParams.get('clientID') ?? '';
|
|
285
|
+
this.clientGroupID = this.connectURL.searchParams.get('clientGroupID') ?? '';
|
|
286
|
+
this.wsid = this.connectURL.searchParams.get('wsid') ?? `zero-http-${Date.now()}`;
|
|
287
|
+
this.generation = ++this.state.nextSocketGeneration;
|
|
288
|
+
this.zeroInstanceID = `${this.state.pageID}:zero:${this.clientID}`;
|
|
289
|
+
this.connectionAttemptID = `${this.zeroInstanceID}:attempt:${this.wsid}`;
|
|
290
|
+
this.socketID = `${this.state.transportID}:socket:${this.generation}`;
|
|
291
|
+
const attemptStartedAtParam = this.connectURL.searchParams.get('ts');
|
|
292
|
+
const attemptStartedAt = Number(attemptStartedAtParam);
|
|
293
|
+
this.attemptStartedAt =
|
|
294
|
+
attemptStartedAtParam !== null && Number.isFinite(attemptStartedAt)
|
|
295
|
+
? attemptStartedAt
|
|
296
|
+
: undefined;
|
|
297
|
+
const previousSocket = this.state.activeSocketByClient.get(this.clientID);
|
|
298
|
+
this.state.activeSocketGenerationByClient.set(this.clientID, this.generation);
|
|
299
|
+
this.state.activeSocketByClient.set(this.clientID, this);
|
|
300
|
+
previousSocket?.supersede();
|
|
301
|
+
this.emitLifecycle('created');
|
|
302
|
+
const baseCookie = this.connectURL.searchParams.get('baseCookie');
|
|
303
|
+
this.cookie = baseCookie ? baseCookie : null;
|
|
304
|
+
// the local cookie ID is encoded into the suffix of any cookie we
|
|
305
|
+
// already advanced past (toLocalWebSocketCookie). reconnects rehydrate
|
|
306
|
+
// this.cookie from the URL baseCookie param replicache persisted, so we
|
|
307
|
+
// must resume the counter from its suffix — otherwise the first poke on
|
|
308
|
+
// the new socket re-emits an identical suffix and replicache trips
|
|
309
|
+
// "cookie did not change, but patch is not empty" and drops the patch.
|
|
310
|
+
const suffixMatch = this.cookie?.match(/#(\d+)$/);
|
|
311
|
+
this.nextLocalCookieID = suffixMatch ? Number(suffixMatch[1]) : 0;
|
|
312
|
+
const decoded = decodeSecProtocol(protocols);
|
|
313
|
+
this.authToken = decoded.authToken;
|
|
314
|
+
this.queueDesiredQueries(decoded.initConnectionMessage?.[1]);
|
|
315
|
+
this.state.sockets.add(this);
|
|
316
|
+
// open on the next task so Zero can attach its listeners. open() checks the
|
|
317
|
+
// attempt timestamp first, so async socket construction that outlived Zero's
|
|
318
|
+
// deadline closes without delivering an event to abandoned state.
|
|
319
|
+
this.openTimer = setTimeout(() => this.open(), 0);
|
|
320
|
+
}
|
|
321
|
+
addEventListener(type, listener) {
|
|
322
|
+
if (listener) {
|
|
323
|
+
this.listeners[type]?.add(listener);
|
|
324
|
+
this.emitLifecycle('listener', { listener: type });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
removeEventListener(type, listener) {
|
|
328
|
+
if (listener)
|
|
329
|
+
this.listeners[type]?.delete(listener);
|
|
330
|
+
}
|
|
331
|
+
dispatchEvent(event) {
|
|
332
|
+
this.emit(event.type, event);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
send(data) {
|
|
336
|
+
if (this.readyState !== this.OPEN) {
|
|
337
|
+
throw new Error('cannot send on a socket that is not open');
|
|
338
|
+
}
|
|
339
|
+
const message = JSON.parse(data);
|
|
340
|
+
switch (message[0]) {
|
|
341
|
+
case 'initConnection':
|
|
342
|
+
case 'changeDesiredQueries':
|
|
343
|
+
this.flushGeneration++;
|
|
344
|
+
this.queueDesiredQueries(message[1]);
|
|
345
|
+
this.requestPullAfterCurrent();
|
|
346
|
+
return;
|
|
347
|
+
case 'updateAuth':
|
|
348
|
+
this.flushGeneration++;
|
|
349
|
+
this.authToken = message[1].auth;
|
|
350
|
+
return;
|
|
351
|
+
case 'push':
|
|
352
|
+
this.enqueuePush(message[1]);
|
|
353
|
+
return;
|
|
354
|
+
case 'ping':
|
|
355
|
+
this.emitMessage(['pong', {}]);
|
|
356
|
+
return;
|
|
357
|
+
case 'pull': {
|
|
358
|
+
this.flushGeneration++;
|
|
359
|
+
const recoveryPull = this.answerMutationRecoveryPull(message[1]);
|
|
360
|
+
this.trackUpstream(recoveryPull);
|
|
361
|
+
this.run(recoveryPull);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
case 'deleteClients':
|
|
365
|
+
case 'ackMutationResponses':
|
|
366
|
+
return;
|
|
367
|
+
default:
|
|
368
|
+
throw new Error(`unsupported zero-http upstream message ${message[0]}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
close(code = 1000, reason = '') {
|
|
372
|
+
if (!this.settle('close', { code, reason }))
|
|
373
|
+
return;
|
|
374
|
+
this.emit('close', { code, reason, wasClean: code <= 1001 });
|
|
375
|
+
}
|
|
376
|
+
pull() {
|
|
377
|
+
if (this.readyState !== this.OPEN)
|
|
378
|
+
return Promise.resolve();
|
|
379
|
+
if (this.pullInFlight)
|
|
380
|
+
return this.pullInFlight;
|
|
381
|
+
this.pullInFlight = this.fetchPull(this.clientGroupID, this.cookie, true)
|
|
382
|
+
.then((response) => {
|
|
383
|
+
this.applyServerGotQueries(response);
|
|
384
|
+
if (response.unchanged) {
|
|
385
|
+
this.emitGotQueriesPatch(response.cookie);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.emitPoke(response);
|
|
389
|
+
})
|
|
390
|
+
.catch((error) => {
|
|
391
|
+
this.fail(error);
|
|
392
|
+
throw error;
|
|
393
|
+
})
|
|
394
|
+
.finally(async () => {
|
|
395
|
+
const pullAgain = this.pullAfterCurrent;
|
|
396
|
+
this.pullAfterCurrent = false;
|
|
397
|
+
this.pullInFlight = undefined;
|
|
398
|
+
if (pullAgain && this.readyState !== this.CLOSED)
|
|
399
|
+
await this.pull();
|
|
400
|
+
});
|
|
401
|
+
return this.pullInFlight;
|
|
402
|
+
}
|
|
403
|
+
// settle every tracked upstream effect, then prove quiescence with two pull
|
|
404
|
+
// round trips. repeat if work arrived during that pass.
|
|
405
|
+
async flush() {
|
|
406
|
+
for (;;) {
|
|
407
|
+
const generation = this.flushGeneration;
|
|
408
|
+
await Promise.all([...this.pendingUpstream]);
|
|
409
|
+
await this.pushCompletion;
|
|
410
|
+
await this.pull();
|
|
411
|
+
await Promise.all([...this.pendingUpstream]);
|
|
412
|
+
await this.pushCompletion;
|
|
413
|
+
await this.pull();
|
|
414
|
+
await Promise.resolve();
|
|
415
|
+
if (this.flushGeneration === generation && this.pendingUpstream.size === 0) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
open() {
|
|
421
|
+
if (this.readyState !== this.CONNECTING)
|
|
422
|
+
return;
|
|
423
|
+
const attemptAgeMs = this.getAttemptAgeMs();
|
|
424
|
+
if (attemptAgeMs !== undefined && attemptAgeMs >= ZERO_CONNECT_TIMEOUT_MS) {
|
|
425
|
+
const reason = `zero-http connection attempt ${this.wsid} expired after ${Math.round(attemptAgeMs)}ms before socket construction completed`;
|
|
426
|
+
if (!this.settle('aborted', { code: 1000, reason }))
|
|
427
|
+
return;
|
|
428
|
+
this.emit('close', { code: 1000, reason, wasClean: true });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
this.readyState = this.OPEN;
|
|
432
|
+
this.emitLifecycle('open');
|
|
433
|
+
this.emit('open', {});
|
|
434
|
+
this.emitMessage(['connected', { wsid: this.wsid, timestamp: Date.now() }]);
|
|
435
|
+
setTimeout(() => this.run(this.pull()), 0);
|
|
436
|
+
if (this.state.pullIntervalMs) {
|
|
437
|
+
this.pullTimer = setInterval(() => {
|
|
438
|
+
this.run(this.pull());
|
|
439
|
+
}, this.state.pullIntervalMs);
|
|
440
|
+
}
|
|
441
|
+
if (this.state.wake)
|
|
442
|
+
this.openWakeChannel();
|
|
443
|
+
}
|
|
444
|
+
// notification-only wake channel: a real WebSocket to <origin>/wake that
|
|
445
|
+
// carries no data. any frame means "pull now", so a wake triggers an
|
|
446
|
+
// immediate (coalesced) pull — push-shaped propagation without waiting on
|
|
447
|
+
// the poll interval. advisory only: if it drops we reconnect, and the
|
|
448
|
+
// interval poll remains the safety net that guarantees convergence.
|
|
449
|
+
openWakeChannel() {
|
|
450
|
+
const Native = this.state.nativeWebSocket;
|
|
451
|
+
if (!Native ||
|
|
452
|
+
this.wakeSocket ||
|
|
453
|
+
this.wakeConnecting ||
|
|
454
|
+
this.readyState !== this.OPEN) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
this.wakeConnecting = true;
|
|
458
|
+
const wsBase = this.state.originString.replace(/^http/, 'ws');
|
|
459
|
+
void (async () => {
|
|
460
|
+
let wakeToken;
|
|
461
|
+
let socket;
|
|
462
|
+
try {
|
|
463
|
+
if (typeof this.state.wake === 'object') {
|
|
464
|
+
wakeToken = await this.state.wake.getToken();
|
|
465
|
+
}
|
|
466
|
+
if (this.readyState !== this.OPEN || this.wakeSocket)
|
|
467
|
+
return;
|
|
468
|
+
const url = `${wsBase}/wake?clientID=${encodeURIComponent(this.clientID)}` +
|
|
469
|
+
(wakeToken === undefined ? '' : `&wakeToken=${encodeURIComponent(wakeToken)}`);
|
|
470
|
+
socket = new Native(url);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
this.scheduleWakeReconnect();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
this.wakeConnecting = false;
|
|
478
|
+
}
|
|
479
|
+
this.wakeSocket = socket;
|
|
480
|
+
const reconnect = () => {
|
|
481
|
+
if (this.wakeSocket !== socket)
|
|
482
|
+
return;
|
|
483
|
+
this.wakeSocket = undefined;
|
|
484
|
+
this.scheduleWakeReconnect();
|
|
485
|
+
};
|
|
486
|
+
// route through requestPullAfterCurrent, NOT pull() directly: a wake that
|
|
487
|
+
// lands while a pull is already in flight must set pullAfterCurrent so the
|
|
488
|
+
// in-flight pull re-runs and picks up the woken change. calling pull()
|
|
489
|
+
// directly would return the existing promise and silently drop the wake,
|
|
490
|
+
// leaving convergence to the safety poll (a burst-storm latency bug).
|
|
491
|
+
socket.onmessage = () => this.requestPullAfterCurrent();
|
|
492
|
+
socket.onclose = reconnect;
|
|
493
|
+
socket.onerror = reconnect;
|
|
494
|
+
})();
|
|
495
|
+
}
|
|
496
|
+
scheduleWakeReconnect() {
|
|
497
|
+
if (this.readyState !== this.OPEN || this.wakeReconnectTimer)
|
|
498
|
+
return;
|
|
499
|
+
this.wakeReconnectTimer = setTimeout(() => {
|
|
500
|
+
this.wakeReconnectTimer = undefined;
|
|
501
|
+
this.openWakeChannel();
|
|
502
|
+
}, 500);
|
|
503
|
+
}
|
|
504
|
+
closeWakeChannel() {
|
|
505
|
+
if (this.wakeReconnectTimer) {
|
|
506
|
+
clearTimeout(this.wakeReconnectTimer);
|
|
507
|
+
this.wakeReconnectTimer = undefined;
|
|
508
|
+
}
|
|
509
|
+
const socket = this.wakeSocket;
|
|
510
|
+
this.wakeSocket = undefined;
|
|
511
|
+
if (socket) {
|
|
512
|
+
try {
|
|
513
|
+
socket.close();
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// best effort: an already-closing wake socket is harmless
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
queueDesiredQueries(body) {
|
|
521
|
+
const desiredQueriesPatch = body
|
|
522
|
+
?.desiredQueriesPatch;
|
|
523
|
+
if (!Array.isArray(desiredQueriesPatch))
|
|
524
|
+
return;
|
|
525
|
+
// accumulate the desired-query delta to ship to the server — always, like
|
|
526
|
+
// stock Zero against zero-cache. a named put ships name+args for the server
|
|
527
|
+
// to resolve with auth (or a client-resolved ast via queryTransform); an
|
|
528
|
+
// anonymous put ships its inline ast. the server owns the got-query ack.
|
|
529
|
+
const transform = this.state.queryTransform;
|
|
530
|
+
for (const op of desiredQueriesPatch) {
|
|
531
|
+
if (op.op === 'clear') {
|
|
532
|
+
this.desiredQueryPatch.push({ op: 'clear' });
|
|
533
|
+
}
|
|
534
|
+
else if (op.op === 'del') {
|
|
535
|
+
this.desiredQueryPatch.push({ op: 'del', hash: op.hash });
|
|
536
|
+
}
|
|
537
|
+
else if (op.op === 'put') {
|
|
538
|
+
const inline = op.ast;
|
|
539
|
+
const name = op.name ?? '';
|
|
540
|
+
const args = (op.args ??
|
|
541
|
+
[]);
|
|
542
|
+
if (name && transform) {
|
|
543
|
+
this.desiredQueryPatch.push({
|
|
544
|
+
op: 'put',
|
|
545
|
+
hash: op.hash,
|
|
546
|
+
ast: transform(name, args),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
else if (name) {
|
|
550
|
+
this.desiredQueryPatch.push({ op: 'put', hash: op.hash, name, args });
|
|
551
|
+
}
|
|
552
|
+
else if (inline !== undefined) {
|
|
553
|
+
this.desiredQueryPatch.push({ op: 'put', hash: op.hash, ast: inline });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
this.queryVersion++;
|
|
558
|
+
}
|
|
559
|
+
async push({ body, frameCount, mutationCount }) {
|
|
560
|
+
const encodedBody = await this.state.payloadCodec.encodePush(body);
|
|
561
|
+
const response = (await this.postJSON('/push', encodedBody));
|
|
562
|
+
this.emitLifecycle('push', {
|
|
563
|
+
pushFrameCount: frameCount,
|
|
564
|
+
mutationCount,
|
|
565
|
+
});
|
|
566
|
+
// mutation RECOVERY pushes carry a PREVIOUS client's pending mutations,
|
|
567
|
+
// and the server response echoes that old clientID. zero-cache's pusher
|
|
568
|
+
// groups results by clientID and only delivers a client its OWN results
|
|
569
|
+
// (recovered mutations settle via lastMutationIDChanges in pokes); the
|
|
570
|
+
// raw response would trip zero's "received mutation for the wrong
|
|
571
|
+
// client" assert and kill the connection. mirror the same filtering.
|
|
572
|
+
this.emitMessage([
|
|
573
|
+
'pushResponse',
|
|
574
|
+
filterMutationResultsToClient('pushResponse' in response ? response.pushResponse : response, this.clientID),
|
|
575
|
+
]);
|
|
576
|
+
this.requestPullAfterCurrent();
|
|
577
|
+
}
|
|
578
|
+
enqueuePush(body) {
|
|
579
|
+
this.flushGeneration++;
|
|
580
|
+
this.pendingPushes.push(body);
|
|
581
|
+
if (this.pushInFlight)
|
|
582
|
+
return;
|
|
583
|
+
const drain = this.drainPushes();
|
|
584
|
+
this.pushCompletion = drain;
|
|
585
|
+
this.trackUpstream(drain);
|
|
586
|
+
this.run(drain);
|
|
587
|
+
}
|
|
588
|
+
async drainPushes() {
|
|
589
|
+
this.pushInFlight = true;
|
|
590
|
+
try {
|
|
591
|
+
while (this.readyState !== this.CLOSED && this.pendingPushes.length > 0) {
|
|
592
|
+
await this.push(this.takePushBatch());
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
this.pushInFlight = false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
takePushBatch() {
|
|
600
|
+
const first = this.pendingPushes.shift();
|
|
601
|
+
const firstBody = parsePushBody(first);
|
|
602
|
+
if (!firstBody) {
|
|
603
|
+
return { body: first, frameCount: 1, mutationCount: 0 };
|
|
604
|
+
}
|
|
605
|
+
const mutations = [...firstBody.mutations];
|
|
606
|
+
let frameCount = 1;
|
|
607
|
+
while (this.pendingPushes.length > 0) {
|
|
608
|
+
const nextBody = parsePushBody(this.pendingPushes[0]);
|
|
609
|
+
if (!nextBody ||
|
|
610
|
+
!areCompatiblePushBodies(firstBody, nextBody) ||
|
|
611
|
+
mutations.length + nextBody.mutations.length > MAX_PUSH_BATCH_MUTATIONS) {
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
this.pendingPushes.shift();
|
|
615
|
+
mutations.push(...nextBody.mutations);
|
|
616
|
+
frameCount++;
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
body: frameCount === 1 ? firstBody : { ...firstBody, mutations },
|
|
620
|
+
frameCount,
|
|
621
|
+
mutationCount: mutations.length,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
trackUpstream(promise) {
|
|
625
|
+
this.pendingUpstream.add(promise);
|
|
626
|
+
void promise.then(() => this.pendingUpstream.delete(promise), () => this.pendingUpstream.delete(promise));
|
|
627
|
+
}
|
|
628
|
+
requestPullAfterCurrent() {
|
|
629
|
+
if (this.pullInFlight) {
|
|
630
|
+
this.pullAfterCurrent = true;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
this.run(this.pull());
|
|
634
|
+
}
|
|
635
|
+
// in query-aware mode the got-query ack is authoritative from the server:
|
|
636
|
+
// take the server's gotQueries.patch as the got patch to emit (replacing
|
|
637
|
+
// local synthesis), and clear the acked prefix of the shipped desired delta
|
|
638
|
+
// once the server acks that version (the ack never leads its row effects —
|
|
639
|
+
// invariant 13 — so the client marks a query got only after its rows land).
|
|
640
|
+
applyServerGotQueries(response) {
|
|
641
|
+
const got = response.gotQueries;
|
|
642
|
+
this.pendingGotQueriesPatch = got ? [...got.patch] : [];
|
|
643
|
+
if (got &&
|
|
644
|
+
this.sentQueryVersion !== undefined &&
|
|
645
|
+
got.version >= this.sentQueryVersion) {
|
|
646
|
+
this.desiredQueryPatch.splice(0, this.sentQueryPatchLen);
|
|
647
|
+
this.sentQueryVersion = undefined;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async answerMutationRecoveryPull(body) {
|
|
651
|
+
const response = await this.fetchPull(body.clientGroupID, body.cookie, false);
|
|
652
|
+
const cookie = toWebSocketCookie(response.cookie);
|
|
653
|
+
this.emitMessage([
|
|
654
|
+
'pull',
|
|
655
|
+
{
|
|
656
|
+
requestID: body.requestID,
|
|
657
|
+
cookie: cookie ?? this.cookie ?? '0',
|
|
658
|
+
lastMutationIDChanges: response.unchanged ? {} : response.lastMutationIDChanges,
|
|
659
|
+
},
|
|
660
|
+
]);
|
|
661
|
+
}
|
|
662
|
+
async fetchPull(clientGroupID, cookie, includeQueries) {
|
|
663
|
+
const body = {
|
|
664
|
+
clientID: this.clientID,
|
|
665
|
+
clientGroupID,
|
|
666
|
+
cookie: toHttpCookie(cookie),
|
|
667
|
+
};
|
|
668
|
+
// ship the un-acked desired-query delta with the pull; remember what we
|
|
669
|
+
// sent so the server ack can clear exactly that prefix. a recovery pull
|
|
670
|
+
// (includeQueries=false) never carries desires.
|
|
671
|
+
if (includeQueries && this.desiredQueryPatch.length > 0) {
|
|
672
|
+
this.sentQueryVersion = this.queryVersion;
|
|
673
|
+
this.sentQueryPatchLen = this.desiredQueryPatch.length;
|
|
674
|
+
body.queries = { version: this.queryVersion, patch: [...this.desiredQueryPatch] };
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
this.sentQueryVersion = undefined;
|
|
678
|
+
}
|
|
679
|
+
const response = (await this.postJSON('/pull', body));
|
|
680
|
+
return this.state.payloadCodec.decodePull(response);
|
|
681
|
+
}
|
|
682
|
+
async postJSON(path, body) {
|
|
683
|
+
const base = path === '/push' ? this.state.pushOriginString : this.state.pullOriginString;
|
|
684
|
+
const url = new URL(`${base}${path}`);
|
|
685
|
+
if (path === '/push') {
|
|
686
|
+
url.searchParams.set('schema', `${this.state.appID}_${this.state.shardNum}`);
|
|
687
|
+
url.searchParams.set('appID', this.state.appID);
|
|
688
|
+
}
|
|
689
|
+
const response = await this.state.fetch(url, {
|
|
690
|
+
method: 'POST',
|
|
691
|
+
headers: {
|
|
692
|
+
authorization: this.authToken ? `Bearer ${this.authToken}` : '',
|
|
693
|
+
'content-type': 'application/json',
|
|
694
|
+
},
|
|
695
|
+
body: JSON.stringify(body),
|
|
696
|
+
});
|
|
697
|
+
if (!response.ok) {
|
|
698
|
+
let body = '';
|
|
699
|
+
try {
|
|
700
|
+
body = await response.text();
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
// the status still owns the failure when the response body is unreadable
|
|
704
|
+
}
|
|
705
|
+
throw new ZeroHttpResponseError(path, response.status, body.length > 512 ? `${body.slice(0, 512)}...` : body || undefined, retryAfterMsFromResponse(response, body));
|
|
706
|
+
}
|
|
707
|
+
return response.json();
|
|
708
|
+
}
|
|
709
|
+
run(promise) {
|
|
710
|
+
void promise.catch((error) => this.fail(error));
|
|
711
|
+
}
|
|
712
|
+
fail(error) {
|
|
713
|
+
if (this.readyState === this.CLOSED)
|
|
714
|
+
return;
|
|
715
|
+
this.emitLifecycle('failure', { reason: errorMessage(error) });
|
|
716
|
+
if (isAuthHTTPError(error)) {
|
|
717
|
+
this.emitMessage([
|
|
718
|
+
'error',
|
|
719
|
+
{
|
|
720
|
+
kind: 'Unauthorized',
|
|
721
|
+
message: error.message,
|
|
722
|
+
origin: 'server',
|
|
723
|
+
},
|
|
724
|
+
]);
|
|
725
|
+
if (this.readyState !== this.CLOSED)
|
|
726
|
+
this.close(1000, error.message);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (isStaleClientCookieError(error)) {
|
|
730
|
+
// the client's cookie is AHEAD of the server watermark — the server
|
|
731
|
+
// lost or reset its change-tracking state (replica reset / restore).
|
|
732
|
+
// mirror zero-cache's InvalidConnectionRequestBaseCookie error frame so
|
|
733
|
+
// the stock client drops its local db and rebuilds from scratch instead
|
|
734
|
+
// of reconnect-looping on 409 forever.
|
|
735
|
+
this.emitMessage([
|
|
736
|
+
'error',
|
|
737
|
+
{
|
|
738
|
+
kind: 'InvalidConnectionRequestBaseCookie',
|
|
739
|
+
message: error.message,
|
|
740
|
+
origin: 'server',
|
|
741
|
+
},
|
|
742
|
+
]);
|
|
743
|
+
if (this.readyState !== this.CLOSED)
|
|
744
|
+
this.close(1000, error.message);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (error instanceof ZeroHttpResponseError &&
|
|
748
|
+
error.status >= 400 &&
|
|
749
|
+
error.status < 500 &&
|
|
750
|
+
error.status !== 408 &&
|
|
751
|
+
error.status !== 425 &&
|
|
752
|
+
error.status !== 429) {
|
|
753
|
+
this.emitMessage([
|
|
754
|
+
'error',
|
|
755
|
+
{
|
|
756
|
+
kind: error.path === '/pull' ? 'InvalidConnectionRequest' : 'InvalidPush',
|
|
757
|
+
message: error.message,
|
|
758
|
+
origin: 'server',
|
|
759
|
+
},
|
|
760
|
+
]);
|
|
761
|
+
if (this.readyState !== this.CLOSED)
|
|
762
|
+
this.close(1000, error.message);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
// everything left is transient: a rate limit (429), a throttled or failing
|
|
766
|
+
// server (5xx), or a network error. stock Zero skips its run-loop sleep for
|
|
767
|
+
// a non-clean socket close — AbruptClose is one of the four close reasons
|
|
768
|
+
// throwIfConnectionError deliberately swallows — so closing 1011 here
|
|
769
|
+
// reconnects with NO delay, and each reconnect re-pushes the same pending
|
|
770
|
+
// mutations. measured: 605 pushes/s against a 429 and 374 pulls/s against a
|
|
771
|
+
// 500, which turns one transient failure into a self-sustaining storm that
|
|
772
|
+
// rate-limits the client permanently. ServerOverloaded is the protocol's
|
|
773
|
+
// only backoff-bearing frame (MutationRateLimited is swallowed with no
|
|
774
|
+
// delay at all), so a transient failure closes cleanly and carries the
|
|
775
|
+
// server's own Retry-After as the run loop's minimum sleep.
|
|
776
|
+
const retryAfterMs = error instanceof ZeroHttpResponseError ? error.retryAfterMs : undefined;
|
|
777
|
+
this.emit('error', { error });
|
|
778
|
+
this.emitMessage([
|
|
779
|
+
'error',
|
|
780
|
+
{
|
|
781
|
+
kind: 'ServerOverloaded',
|
|
782
|
+
message: errorMessage(error),
|
|
783
|
+
// the backoff frame is the sync backend's to send, and Zero only
|
|
784
|
+
// treats it as a server error when the origin says so.
|
|
785
|
+
origin: 'zeroCache',
|
|
786
|
+
// a server may hand back a window measured in hours (a daily quota).
|
|
787
|
+
// sleeping that long strands the client, so cap what it will honor.
|
|
788
|
+
...(retryAfterMs === undefined
|
|
789
|
+
? {}
|
|
790
|
+
: { minBackoffMs: retryAfterMs, maxBackoffMs: MAX_RETRY_AFTER_BACKOFF_MS }),
|
|
791
|
+
},
|
|
792
|
+
]);
|
|
793
|
+
if (this.readyState !== this.CLOSED)
|
|
794
|
+
this.close(1000, errorMessage(error));
|
|
795
|
+
}
|
|
796
|
+
emitPoke(response) {
|
|
797
|
+
const currentServer = toHttpCookie(this.cookie);
|
|
798
|
+
let nextCookie;
|
|
799
|
+
if (currentServer !== null && response.cookie < currentServer) {
|
|
800
|
+
// the server watermark is BEHIND the client: a real reset/restore. mirror
|
|
801
|
+
// the 409 stale path instead of poking the client backwards.
|
|
802
|
+
throw new Error(`zero-http pull returned stale cookie ${response.cookie} for ${this.cookie}`);
|
|
803
|
+
}
|
|
804
|
+
else if (currentServer !== null && response.cookie === currentServer) {
|
|
805
|
+
// same server watermark but a non-empty patch: a query-aware membership
|
|
806
|
+
// delta (a desired-query change recomputes rows without advancing the
|
|
807
|
+
// change log). bump a client-local cookie id so replicache sees a changed
|
|
808
|
+
// cookie — otherwise it trips "cookie did not change, but patch is not
|
|
809
|
+
// empty" and drops the patch.
|
|
810
|
+
nextCookie = toLocalWebSocketCookie(response.cookie, ++this.nextLocalCookieID);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
nextCookie = toWebSocketCookie(response.cookie);
|
|
814
|
+
}
|
|
815
|
+
const pokeID = `zero-http-${++this.state.nextPokeID}`;
|
|
816
|
+
let gotQueries = dedupeGotQueriesPatch(this.pendingGotQueriesPatch);
|
|
817
|
+
this.pendingGotQueriesPatch = [];
|
|
818
|
+
const rowsCleared = response.rowsPatch.some((op) => op?.op === 'clear');
|
|
819
|
+
if (rowsCleared && this.ackedGotHashes.size > 0) {
|
|
820
|
+
gotQueries = dedupeGotQueriesPatch([
|
|
821
|
+
...[...this.ackedGotHashes].map((hash) => ({ op: 'put', hash })),
|
|
822
|
+
...gotQueries,
|
|
823
|
+
]);
|
|
824
|
+
}
|
|
825
|
+
this.recordAckedGotQueries(gotQueries);
|
|
826
|
+
this.emitMessage([
|
|
827
|
+
'pokeStart',
|
|
828
|
+
{
|
|
829
|
+
pokeID,
|
|
830
|
+
baseCookie: this.cookie,
|
|
831
|
+
schemaVersions: {
|
|
832
|
+
minSupportedVersion: 1,
|
|
833
|
+
maxSupportedVersion: 1,
|
|
834
|
+
},
|
|
835
|
+
timestamp: Date.now(),
|
|
836
|
+
},
|
|
837
|
+
]);
|
|
838
|
+
this.emitMessage([
|
|
839
|
+
'pokePart',
|
|
840
|
+
{
|
|
841
|
+
pokeID,
|
|
842
|
+
lastMutationIDChanges: response.lastMutationIDChanges,
|
|
843
|
+
rowsPatch: response.rowsPatch,
|
|
844
|
+
},
|
|
845
|
+
]);
|
|
846
|
+
if (gotQueries.length > 0) {
|
|
847
|
+
this.emitMessage([
|
|
848
|
+
'pokePart',
|
|
849
|
+
{
|
|
850
|
+
pokeID,
|
|
851
|
+
gotQueriesPatch: gotQueries,
|
|
852
|
+
},
|
|
853
|
+
]);
|
|
854
|
+
}
|
|
855
|
+
this.emitMessage(['pokeEnd', { pokeID, cookie: nextCookie }]);
|
|
856
|
+
this.cookie = nextCookie;
|
|
857
|
+
}
|
|
858
|
+
emitGotQueriesPatch(cookie) {
|
|
859
|
+
if (this.pendingGotQueriesPatch.length === 0)
|
|
860
|
+
return;
|
|
861
|
+
const serverCookie = cookie ?? toHttpCookie(this.cookie);
|
|
862
|
+
if (serverCookie === null)
|
|
863
|
+
return;
|
|
864
|
+
const nextCookie = toLocalWebSocketCookie(serverCookie, ++this.nextLocalCookieID);
|
|
865
|
+
const pokeID = `zero-http-${++this.state.nextPokeID}`;
|
|
866
|
+
const gotQueries = dedupeGotQueriesPatch(this.pendingGotQueriesPatch);
|
|
867
|
+
this.pendingGotQueriesPatch = [];
|
|
868
|
+
this.recordAckedGotQueries(gotQueries);
|
|
869
|
+
this.emitMessage([
|
|
870
|
+
'pokeStart',
|
|
871
|
+
{
|
|
872
|
+
pokeID,
|
|
873
|
+
baseCookie: this.cookie,
|
|
874
|
+
schemaVersions: {
|
|
875
|
+
minSupportedVersion: 1,
|
|
876
|
+
maxSupportedVersion: 1,
|
|
877
|
+
},
|
|
878
|
+
timestamp: Date.now(),
|
|
879
|
+
},
|
|
880
|
+
]);
|
|
881
|
+
this.emitMessage([
|
|
882
|
+
'pokePart',
|
|
883
|
+
{
|
|
884
|
+
pokeID,
|
|
885
|
+
gotQueriesPatch: gotQueries,
|
|
886
|
+
},
|
|
887
|
+
]);
|
|
888
|
+
this.emitMessage(['pokeEnd', { pokeID, cookie: nextCookie }]);
|
|
889
|
+
this.cookie = nextCookie;
|
|
890
|
+
}
|
|
891
|
+
recordAckedGotQueries(patch) {
|
|
892
|
+
for (const op of patch) {
|
|
893
|
+
if (op.op === 'clear')
|
|
894
|
+
this.ackedGotHashes.clear();
|
|
895
|
+
else if (op.op === 'put')
|
|
896
|
+
this.ackedGotHashes.add(op.hash);
|
|
897
|
+
else
|
|
898
|
+
this.ackedGotHashes.delete(op.hash);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
emitMessage(message) {
|
|
902
|
+
if (this.readyState !== this.OPEN)
|
|
903
|
+
return;
|
|
904
|
+
this.emit('message', { data: JSON.stringify(message) });
|
|
905
|
+
}
|
|
906
|
+
emit(type, event) {
|
|
907
|
+
const handler = this[`on${type}`];
|
|
908
|
+
if (typeof handler === 'function')
|
|
909
|
+
handler.call(this, event);
|
|
910
|
+
for (const listener of this.listeners[type]) {
|
|
911
|
+
if (typeof listener === 'function')
|
|
912
|
+
listener(event);
|
|
913
|
+
else
|
|
914
|
+
listener.handleEvent(event);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
supersede() {
|
|
918
|
+
this.settle('superseded');
|
|
919
|
+
}
|
|
920
|
+
settle(type, detail = {}) {
|
|
921
|
+
if (this.settled)
|
|
922
|
+
return false;
|
|
923
|
+
this.settled = true;
|
|
924
|
+
if (this.openTimer !== undefined)
|
|
925
|
+
clearTimeout(this.openTimer);
|
|
926
|
+
if (this.pullTimer !== undefined)
|
|
927
|
+
clearInterval(this.pullTimer);
|
|
928
|
+
this.closeWakeChannel();
|
|
929
|
+
this.readyState = this.CLOSED;
|
|
930
|
+
this.state.sockets.delete(this);
|
|
931
|
+
if (this.state.activeSocketByClient.get(this.clientID) === this) {
|
|
932
|
+
this.state.activeSocketByClient.delete(this.clientID);
|
|
933
|
+
this.state.activeSocketGenerationByClient.delete(this.clientID);
|
|
934
|
+
}
|
|
935
|
+
this.emitLifecycle(type, detail);
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
getAttemptAgeMs() {
|
|
939
|
+
return this.attemptStartedAt === undefined
|
|
940
|
+
? undefined
|
|
941
|
+
: performance.now() - this.attemptStartedAt;
|
|
942
|
+
}
|
|
943
|
+
emitLifecycle(type, detail = {}) {
|
|
944
|
+
this.state.lifecycle?.({
|
|
945
|
+
type,
|
|
946
|
+
pageID: this.state.pageID,
|
|
947
|
+
transportID: this.state.transportID,
|
|
948
|
+
zeroInstanceID: this.zeroInstanceID,
|
|
949
|
+
clientGroupID: this.clientGroupID,
|
|
950
|
+
connectionAttemptID: this.connectionAttemptID,
|
|
951
|
+
socketID: this.socketID,
|
|
952
|
+
generation: this.generation,
|
|
953
|
+
activeGeneration: this.state.activeSocketGenerationByClient.get(this.clientID) ?? this.generation,
|
|
954
|
+
clientID: this.clientID,
|
|
955
|
+
wsid: this.wsid,
|
|
956
|
+
timestamp: Date.now(),
|
|
957
|
+
attemptStartedAt: this.attemptStartedAt,
|
|
958
|
+
attemptAgeMs: this.getAttemptAgeMs(),
|
|
959
|
+
...detail,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function parsePushBody(value) {
|
|
964
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
965
|
+
return;
|
|
966
|
+
if (!('mutations' in value) || !Array.isArray(value.mutations))
|
|
967
|
+
return;
|
|
968
|
+
// the runtime checks above establish the only fields batching reads.
|
|
969
|
+
return value;
|
|
970
|
+
}
|
|
971
|
+
function areCompatiblePushBodies(left, right) {
|
|
972
|
+
return (left.clientGroupID === right.clientGroupID &&
|
|
973
|
+
left.pushVersion === right.pushVersion &&
|
|
974
|
+
left.schemaVersion === right.schemaVersion);
|
|
975
|
+
}
|
|
976
|
+
function shouldIntercept(origin, url) {
|
|
977
|
+
const candidate = toHttpURL(url);
|
|
978
|
+
if (candidate.origin !== origin.origin)
|
|
979
|
+
return false;
|
|
980
|
+
return candidate.pathname === `${trimTrailingSlash(origin.pathname)}/sync/v51/connect`;
|
|
981
|
+
}
|
|
982
|
+
function toHttpURL(url) {
|
|
983
|
+
const parsed = new URL(url);
|
|
984
|
+
if (parsed.protocol === 'ws:')
|
|
985
|
+
parsed.protocol = 'http:';
|
|
986
|
+
if (parsed.protocol === 'wss:')
|
|
987
|
+
parsed.protocol = 'https:';
|
|
988
|
+
return parsed;
|
|
989
|
+
}
|
|
990
|
+
function trimTrailingSlash(value) {
|
|
991
|
+
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
992
|
+
}
|
|
993
|
+
function decodeSecProtocol(protocols) {
|
|
994
|
+
const protocol = Array.isArray(protocols) ? protocols[0] : protocols;
|
|
995
|
+
if (!protocol)
|
|
996
|
+
return {};
|
|
997
|
+
try {
|
|
998
|
+
const decoded = decodeURIComponent(protocol);
|
|
999
|
+
const json = new TextDecoder().decode(Uint8Array.from(globalThis.atob(decoded), (char) => char.charCodeAt(0)));
|
|
1000
|
+
const parsed = JSON.parse(json);
|
|
1001
|
+
return {
|
|
1002
|
+
authToken: parsed.authToken,
|
|
1003
|
+
initConnectionMessage: Array.isArray(parsed.initConnectionMessage)
|
|
1004
|
+
? parsed.initConnectionMessage
|
|
1005
|
+
: undefined,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
catch {
|
|
1009
|
+
return {};
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
// initConnection and a racing changeDesiredQueries can acknowledge the same
|
|
1013
|
+
// hash in one poke. Zero's complete tracking requires one final op per hash.
|
|
1014
|
+
function dedupeGotQueriesPatch(patch) {
|
|
1015
|
+
let clear = false;
|
|
1016
|
+
const lastOpByHash = new Map();
|
|
1017
|
+
for (const op of patch) {
|
|
1018
|
+
if (op.op === 'clear') {
|
|
1019
|
+
clear = true;
|
|
1020
|
+
lastOpByHash.clear();
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
lastOpByHash.delete(op.hash);
|
|
1024
|
+
lastOpByHash.set(op.hash, op);
|
|
1025
|
+
}
|
|
1026
|
+
const deduped = clear ? [{ op: 'clear' }] : [];
|
|
1027
|
+
deduped.push(...lastOpByHash.values());
|
|
1028
|
+
return deduped;
|
|
1029
|
+
}
|
|
1030
|
+
function toHttpCookie(cookie) {
|
|
1031
|
+
if (cookie === null || cookie === '')
|
|
1032
|
+
return null;
|
|
1033
|
+
const parsed = Number(cookie.slice(0, COOKIE_WIDTH));
|
|
1034
|
+
if (!Number.isFinite(parsed)) {
|
|
1035
|
+
throw new Error(`zero-http cookie is not numeric: ${cookie}`);
|
|
1036
|
+
}
|
|
1037
|
+
return parsed;
|
|
1038
|
+
}
|
|
1039
|
+
function toWebSocketCookie(cookie) {
|
|
1040
|
+
return cookie === null ? null : String(cookie).padStart(COOKIE_WIDTH, '0');
|
|
1041
|
+
}
|
|
1042
|
+
function toLocalWebSocketCookie(cookie, localID) {
|
|
1043
|
+
return `${String(cookie).padStart(COOKIE_WIDTH, '0')}#${String(localID).padStart(6, '0')}`;
|
|
1044
|
+
}
|
|
1045
|
+
function errorMessage(error) {
|
|
1046
|
+
return error instanceof Error ? error.message : String(error);
|
|
1047
|
+
}
|
|
1048
|
+
class ZeroHttpResponseError extends Error {
|
|
1049
|
+
path;
|
|
1050
|
+
status;
|
|
1051
|
+
retryAfterMs;
|
|
1052
|
+
constructor(path, status, bodyPreview, retryAfterMs) {
|
|
1053
|
+
super(`zero-http ${path} failed with ${status}${bodyPreview ? `: ${bodyPreview}` : ''}`);
|
|
1054
|
+
this.path = path;
|
|
1055
|
+
this.status = status;
|
|
1056
|
+
this.retryAfterMs = retryAfterMs;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
// a throttled server states its own wait, either as `retryAfterMs` in the JSON
|
|
1060
|
+
// error body or as the standard delta-seconds Retry-After header. an HTTP-date
|
|
1061
|
+
// Retry-After is ignored: the client clock cannot be trusted to compare with it.
|
|
1062
|
+
function retryAfterMsFromResponse(response, body) {
|
|
1063
|
+
const header = response.headers.get('retry-after');
|
|
1064
|
+
const headerSeconds = header === null ? Number.NaN : Number(header);
|
|
1065
|
+
const headerMs = Number.isFinite(headerSeconds)
|
|
1066
|
+
? Math.max(0, headerSeconds) * 1_000
|
|
1067
|
+
: undefined;
|
|
1068
|
+
if (body) {
|
|
1069
|
+
try {
|
|
1070
|
+
const parsed = JSON.parse(body);
|
|
1071
|
+
const value = parsed?.retryAfterMs;
|
|
1072
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
1073
|
+
return Math.max(0, value);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
catch {
|
|
1077
|
+
// an unparseable or truncated body leaves the header as the only hint
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return headerMs;
|
|
1081
|
+
}
|
|
1082
|
+
function isAuthHTTPError(error) {
|
|
1083
|
+
return (error instanceof ZeroHttpResponseError &&
|
|
1084
|
+
(error.status === 401 || error.status === 403));
|
|
1085
|
+
}
|
|
1086
|
+
// keep only this client's own mutation results — zero-cache's pusher does the
|
|
1087
|
+
// same per-client fan-out (results for clients without a live connection are
|
|
1088
|
+
// dropped; their LMID advance arrives via the next pull's
|
|
1089
|
+
// lastMutationIDChanges, which is how recovered mutations settle).
|
|
1090
|
+
function filterMutationResultsToClient(pushResponse, clientID) {
|
|
1091
|
+
if (!pushResponse || typeof pushResponse !== 'object')
|
|
1092
|
+
return pushResponse;
|
|
1093
|
+
const mutations = pushResponse.mutations;
|
|
1094
|
+
if (!Array.isArray(mutations))
|
|
1095
|
+
return pushResponse;
|
|
1096
|
+
return {
|
|
1097
|
+
...pushResponse,
|
|
1098
|
+
mutations: mutations.filter((m) => m?.id?.clientID === clientID),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function isStaleClientCookieError(error) {
|
|
1102
|
+
return (error instanceof ZeroHttpResponseError &&
|
|
1103
|
+
error.path === '/pull' &&
|
|
1104
|
+
error.status === 409);
|
|
1105
|
+
}
|
|
1106
|
+
//# sourceMappingURL=transport.js.map
|