on-zero 0.4.26 → 0.4.28
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/cjs/createZeroClient.cjs +14 -1
- package/dist/cjs/createZeroClient.native.js +15 -1
- package/dist/cjs/createZeroClient.native.js.map +1 -1
- package/dist/cjs/httpPull/auth.test.cjs +197 -0
- package/dist/cjs/httpPull/auth.test.native.js +279 -0
- package/dist/cjs/httpPull/auth.test.native.js.map +1 -0
- package/dist/cjs/httpPull/churn.test.cjs +132 -0
- package/dist/cjs/httpPull/churn.test.native.js +155 -0
- package/dist/cjs/httpPull/churn.test.native.js.map +1 -0
- package/dist/cjs/httpPull/fixtureSchema.cjs +76 -0
- package/dist/cjs/httpPull/fixtureSchema.native.js +82 -0
- package/dist/cjs/httpPull/fixtureSchema.native.js.map +1 -0
- package/dist/cjs/httpPull/fixtureServer.cjs +340 -0
- package/dist/cjs/httpPull/fixtureServer.native.js +534 -0
- package/dist/cjs/httpPull/fixtureServer.native.js.map +1 -0
- package/dist/cjs/httpPull/integration.test.cjs +53 -0
- package/dist/cjs/httpPull/integration.test.native.js +60 -0
- package/dist/cjs/httpPull/integration.test.native.js.map +1 -0
- package/dist/cjs/httpPull/rebase.test.cjs +360 -0
- package/dist/cjs/httpPull/rebase.test.native.js +420 -0
- package/dist/cjs/httpPull/rebase.test.native.js.map +1 -0
- package/dist/cjs/httpPull/relations.test.cjs +107 -0
- package/dist/cjs/httpPull/relations.test.native.js +119 -0
- package/dist/cjs/httpPull/relations.test.native.js.map +1 -0
- package/dist/cjs/httpPull/testHarness.cjs +100 -0
- package/dist/cjs/httpPull/testHarness.native.js +112 -0
- package/dist/cjs/httpPull/testHarness.native.js.map +1 -0
- package/dist/cjs/httpPull/transport.test.cjs +588 -0
- package/dist/cjs/httpPull/transport.test.native.js +677 -0
- package/dist/cjs/httpPull/transport.test.native.js.map +1 -0
- package/dist/cjs/httpPullTransport.cjs +447 -0
- package/dist/cjs/httpPullTransport.native.js +710 -0
- package/dist/cjs/httpPullTransport.native.js.map +1 -0
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/esm/createZeroClient.mjs +14 -1
- package/dist/esm/createZeroClient.mjs.map +1 -1
- package/dist/esm/createZeroClient.native.js +15 -1
- package/dist/esm/createZeroClient.native.js.map +1 -1
- package/dist/esm/httpPull/auth.test.mjs +198 -0
- package/dist/esm/httpPull/auth.test.mjs.map +1 -0
- package/dist/esm/httpPull/auth.test.native.js +277 -0
- package/dist/esm/httpPull/auth.test.native.js.map +1 -0
- package/dist/esm/httpPull/churn.test.mjs +133 -0
- package/dist/esm/httpPull/churn.test.mjs.map +1 -0
- package/dist/esm/httpPull/churn.test.native.js +153 -0
- package/dist/esm/httpPull/churn.test.native.js.map +1 -0
- package/dist/esm/httpPull/fixtureSchema.mjs +50 -0
- package/dist/esm/httpPull/fixtureSchema.mjs.map +1 -0
- package/dist/esm/httpPull/fixtureSchema.native.js +53 -0
- package/dist/esm/httpPull/fixtureSchema.native.js.map +1 -0
- package/dist/esm/httpPull/fixtureServer.mjs +315 -0
- package/dist/esm/httpPull/fixtureServer.mjs.map +1 -0
- package/dist/esm/httpPull/fixtureServer.native.js +506 -0
- package/dist/esm/httpPull/fixtureServer.native.js.map +1 -0
- package/dist/esm/httpPull/integration.test.mjs +54 -0
- package/dist/esm/httpPull/integration.test.mjs.map +1 -0
- package/dist/esm/httpPull/integration.test.native.js +58 -0
- package/dist/esm/httpPull/integration.test.native.js.map +1 -0
- package/dist/esm/httpPull/rebase.test.mjs +361 -0
- package/dist/esm/httpPull/rebase.test.mjs.map +1 -0
- package/dist/esm/httpPull/rebase.test.native.js +418 -0
- package/dist/esm/httpPull/rebase.test.native.js.map +1 -0
- package/dist/esm/httpPull/relations.test.mjs +108 -0
- package/dist/esm/httpPull/relations.test.mjs.map +1 -0
- package/dist/esm/httpPull/relations.test.native.js +117 -0
- package/dist/esm/httpPull/relations.test.native.js.map +1 -0
- package/dist/esm/httpPull/testHarness.mjs +72 -0
- package/dist/esm/httpPull/testHarness.mjs.map +1 -0
- package/dist/esm/httpPull/testHarness.native.js +81 -0
- package/dist/esm/httpPull/testHarness.native.js.map +1 -0
- package/dist/esm/httpPull/transport.test.mjs +589 -0
- package/dist/esm/httpPull/transport.test.mjs.map +1 -0
- package/dist/esm/httpPull/transport.test.native.js +675 -0
- package/dist/esm/httpPull/transport.test.native.js.map +1 -0
- package/dist/esm/httpPullTransport.mjs +421 -0
- package/dist/esm/httpPullTransport.mjs.map +1 -0
- package/dist/esm/httpPullTransport.native.js +681 -0
- package/dist/esm/httpPullTransport.native.js.map +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/index.mjs +1 -0
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +1 -0
- package/dist/esm/index.native.js.map +1 -1
- package/package.json +2 -2
- package/src/createZeroClient.tsx +22 -0
- package/src/httpPull/auth.test.ts +208 -0
- package/src/httpPull/churn.test.ts +147 -0
- package/src/httpPull/fixtureSchema.ts +82 -0
- package/src/httpPull/fixtureServer.ts +391 -0
- package/src/httpPull/integration.test.ts +57 -0
- package/src/httpPull/rebase.test.ts +368 -0
- package/src/httpPull/relations.test.ts +135 -0
- package/src/httpPull/testHarness.ts +95 -0
- package/src/httpPull/transport.test.ts +603 -0
- package/src/httpPullTransport.ts +587 -0
- package/src/index.ts +1 -0
- package/types/createZeroClient.d.ts +3 -1
- package/types/createZeroClient.d.ts.map +1 -1
- package/types/httpPull/auth.test.d.ts +2 -0
- package/types/httpPull/auth.test.d.ts.map +1 -0
- package/types/httpPull/churn.test.d.ts +2 -0
- package/types/httpPull/churn.test.d.ts.map +1 -0
- package/types/httpPull/fixtureSchema.d.ts +111 -0
- package/types/httpPull/fixtureSchema.d.ts.map +1 -0
- package/types/httpPull/fixtureServer.d.ts +14 -0
- package/types/httpPull/fixtureServer.d.ts.map +1 -0
- package/types/httpPull/integration.test.d.ts +2 -0
- package/types/httpPull/integration.test.d.ts.map +1 -0
- package/types/httpPull/rebase.test.d.ts +2 -0
- package/types/httpPull/rebase.test.d.ts.map +1 -0
- package/types/httpPull/relations.test.d.ts +2 -0
- package/types/httpPull/relations.test.d.ts.map +1 -0
- package/types/httpPull/testHarness.d.ts +32 -0
- package/types/httpPull/testHarness.d.ts.map +1 -0
- package/types/httpPull/transport.test.d.ts +2 -0
- package/types/httpPull/transport.test.d.ts.map +1 -0
- package/types/httpPullTransport.d.ts +13 -0
- package/types/httpPullTransport.d.ts.map +1 -0
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
|
@@ -0,0 +1,587 @@
|
|
|
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. ported from the orez zero-http spike — the
|
|
4
|
+
// wire contract (lexicographic string cookies, gotQueriesPatch poke-part
|
|
5
|
+
// ordering, FIFO push serialization, updateAuth, 401→Unauthorized frame,
|
|
6
|
+
// teardown drain) is pinned by the tests in ./httpPull/ and documented in
|
|
7
|
+
// ~/orez/plans/zero-http.md. do not "simplify" any of it without re-running
|
|
8
|
+
// those tests against a stock zero client.
|
|
9
|
+
|
|
10
|
+
type WebSocketProtocols = string | string[] | undefined
|
|
11
|
+
type SocketEventType = 'open' | 'message' | 'close' | 'error'
|
|
12
|
+
type SocketListener = ((event: any) => void) | { handleEvent(event: any): void }
|
|
13
|
+
|
|
14
|
+
type WebSocketConstructor = {
|
|
15
|
+
new (url: string | URL, protocols?: WebSocketProtocols): any
|
|
16
|
+
CONNECTING?: number
|
|
17
|
+
OPEN?: number
|
|
18
|
+
CLOSING?: number
|
|
19
|
+
CLOSED?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type DesiredQueryPatchOp =
|
|
23
|
+
| { op: 'clear' }
|
|
24
|
+
| { op: 'put' | 'del'; hash: string; [key: string]: unknown }
|
|
25
|
+
|
|
26
|
+
type GotQueryPatchOp = { op: 'clear' } | { op: 'put' | 'del'; hash: string }
|
|
27
|
+
|
|
28
|
+
type PullResponse =
|
|
29
|
+
| {
|
|
30
|
+
cookie: number
|
|
31
|
+
lastMutationIDChanges: Record<string, number>
|
|
32
|
+
rowsPatch: unknown[]
|
|
33
|
+
unchanged?: false
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
cookie: number | null
|
|
37
|
+
unchanged: true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type TransportState = {
|
|
41
|
+
readonly origin: URL
|
|
42
|
+
readonly originString: string
|
|
43
|
+
readonly fetch: typeof fetch
|
|
44
|
+
readonly nativeWebSocket: WebSocketConstructor | undefined
|
|
45
|
+
readonly sockets: Set<ZeroHttpSocket>
|
|
46
|
+
readonly pullIntervalMs: number | undefined
|
|
47
|
+
nextPokeID: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const COOKIE_WIDTH = 20
|
|
51
|
+
|
|
52
|
+
export type HttpPullTransport = {
|
|
53
|
+
pull(): Promise<void>
|
|
54
|
+
readonly connections: number
|
|
55
|
+
uninstall(): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type HttpPullTransportOptions = {
|
|
59
|
+
origin: string
|
|
60
|
+
fetch?: typeof fetch
|
|
61
|
+
// when set, every open connection also pulls on this interval so
|
|
62
|
+
// server-initiated changes arrive without a client-side trigger
|
|
63
|
+
pullIntervalMs?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function installHttpPullTransport(
|
|
67
|
+
opts: HttpPullTransportOptions,
|
|
68
|
+
): HttpPullTransport {
|
|
69
|
+
const previousWebSocket = globalThis.WebSocket as WebSocketConstructor | undefined
|
|
70
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch
|
|
71
|
+
if (!fetchImpl) {
|
|
72
|
+
throw new Error('installHttpPullTransport requires a fetch implementation')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const state: TransportState = {
|
|
76
|
+
origin: new URL(opts.origin),
|
|
77
|
+
originString: trimTrailingSlash(new URL(opts.origin).toString()),
|
|
78
|
+
// the transport invokes this as `state.fetch(...)` — without binding,
|
|
79
|
+
// window.fetch sees `state` as its receiver and browsers throw
|
|
80
|
+
// "Illegal invocation" (node's fetch doesn't care, so tests can't catch it)
|
|
81
|
+
fetch: fetchImpl.bind(globalThis),
|
|
82
|
+
nativeWebSocket: previousWebSocket,
|
|
83
|
+
sockets: new Set(),
|
|
84
|
+
pullIntervalMs: opts.pullIntervalMs,
|
|
85
|
+
nextPokeID: 0,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const Shim = class {
|
|
89
|
+
static CONNECTING = 0
|
|
90
|
+
static OPEN = 1
|
|
91
|
+
static CLOSING = 2
|
|
92
|
+
static CLOSED = 3
|
|
93
|
+
|
|
94
|
+
constructor(url: string | URL, protocols?: WebSocketProtocols) {
|
|
95
|
+
if (shouldIntercept(state.origin, url)) {
|
|
96
|
+
return new ZeroHttpSocket(state, url, protocols)
|
|
97
|
+
}
|
|
98
|
+
if (!state.nativeWebSocket) {
|
|
99
|
+
throw new Error(`No native WebSocket available for ${String(url)}`)
|
|
100
|
+
}
|
|
101
|
+
return new state.nativeWebSocket(url, protocols)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
globalThis.WebSocket = Shim as unknown as typeof WebSocket
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
pull: async () => {
|
|
109
|
+
await Promise.all([...state.sockets].map((socket) => socket.pull()))
|
|
110
|
+
},
|
|
111
|
+
get connections() {
|
|
112
|
+
return state.sockets.size
|
|
113
|
+
},
|
|
114
|
+
uninstall: () => {
|
|
115
|
+
if (globalThis.WebSocket === (Shim as unknown as typeof WebSocket)) {
|
|
116
|
+
globalThis.WebSocket = previousWebSocket as typeof WebSocket
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// per-origin idempotent install for app usage (ProvideZero rotates zero
|
|
123
|
+
// instances against the same server; installing per rotation would chain
|
|
124
|
+
// shims unboundedly). installed transports live for the page lifetime.
|
|
125
|
+
const transportsByOrigin = new Map<string, HttpPullTransport>()
|
|
126
|
+
|
|
127
|
+
export function ensureHttpPullTransport(
|
|
128
|
+
opts: HttpPullTransportOptions,
|
|
129
|
+
): HttpPullTransport {
|
|
130
|
+
const key = trimTrailingSlash(new URL(opts.origin).toString())
|
|
131
|
+
const existing = transportsByOrigin.get(key)
|
|
132
|
+
if (existing) return existing
|
|
133
|
+
const transport = installHttpPullTransport(opts)
|
|
134
|
+
transportsByOrigin.set(key, transport)
|
|
135
|
+
return transport
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class ZeroHttpSocket {
|
|
139
|
+
readonly CONNECTING = 0
|
|
140
|
+
readonly OPEN = 1
|
|
141
|
+
readonly CLOSING = 2
|
|
142
|
+
readonly CLOSED = 3
|
|
143
|
+
|
|
144
|
+
readonly url: string
|
|
145
|
+
readyState = this.CONNECTING
|
|
146
|
+
|
|
147
|
+
private readonly connectURL: URL
|
|
148
|
+
private authToken: string | undefined
|
|
149
|
+
private readonly listeners: Record<SocketEventType, Set<SocketListener>> = {
|
|
150
|
+
open: new Set(),
|
|
151
|
+
message: new Set(),
|
|
152
|
+
close: new Set(),
|
|
153
|
+
error: new Set(),
|
|
154
|
+
}
|
|
155
|
+
private readonly clientID: string
|
|
156
|
+
private readonly clientGroupID: string
|
|
157
|
+
private readonly wsid: string
|
|
158
|
+
private cookie: string | null
|
|
159
|
+
private pendingGotQueriesPatch: GotQueryPatchOp[] = []
|
|
160
|
+
private pullInFlight: Promise<void> | undefined
|
|
161
|
+
private pullAfterCurrent = false
|
|
162
|
+
private pushChain: Promise<void> = Promise.resolve()
|
|
163
|
+
private nextLocalCookieID = 0
|
|
164
|
+
private openTimer: ReturnType<typeof setTimeout> | undefined
|
|
165
|
+
private pullTimer: ReturnType<typeof setInterval> | undefined
|
|
166
|
+
|
|
167
|
+
constructor(
|
|
168
|
+
private readonly state: TransportState,
|
|
169
|
+
url: string | URL,
|
|
170
|
+
protocols?: WebSocketProtocols,
|
|
171
|
+
) {
|
|
172
|
+
this.connectURL = toHttpURL(url)
|
|
173
|
+
this.url = String(url)
|
|
174
|
+
this.clientID = this.connectURL.searchParams.get('clientID') ?? ''
|
|
175
|
+
this.clientGroupID = this.connectURL.searchParams.get('clientGroupID') ?? ''
|
|
176
|
+
this.wsid = this.connectURL.searchParams.get('wsid') ?? `zero-http-${Date.now()}`
|
|
177
|
+
const baseCookie = this.connectURL.searchParams.get('baseCookie')
|
|
178
|
+
this.cookie = baseCookie ? baseCookie : null
|
|
179
|
+
|
|
180
|
+
const decoded = decodeSecProtocol(protocols)
|
|
181
|
+
this.authToken = decoded.authToken
|
|
182
|
+
this.queueDesiredQueries(decoded.initConnectionMessage?.[1])
|
|
183
|
+
|
|
184
|
+
this.state.sockets.add(this)
|
|
185
|
+
this.openTimer = setTimeout(() => this.open(), 0)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
addEventListener(type: SocketEventType, listener: SocketListener | null) {
|
|
189
|
+
if (listener) this.listeners[type]?.add(listener)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
removeEventListener(type: SocketEventType, listener: SocketListener | null) {
|
|
193
|
+
if (listener) this.listeners[type]?.delete(listener)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
dispatchEvent(event: { type: SocketEventType }) {
|
|
197
|
+
this.emit(event.type, event)
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
send(data: string) {
|
|
202
|
+
if (this.readyState !== this.OPEN) {
|
|
203
|
+
throw new Error('cannot send on a socket that is not open')
|
|
204
|
+
}
|
|
205
|
+
const message = JSON.parse(data) as [string, any]
|
|
206
|
+
switch (message[0]) {
|
|
207
|
+
case 'initConnection':
|
|
208
|
+
case 'changeDesiredQueries':
|
|
209
|
+
this.queueDesiredQueries(message[1])
|
|
210
|
+
this.requestPullAfterCurrent()
|
|
211
|
+
return
|
|
212
|
+
case 'updateAuth':
|
|
213
|
+
this.authToken = (message[1] as { auth?: string }).auth
|
|
214
|
+
return
|
|
215
|
+
case 'push':
|
|
216
|
+
this.enqueuePush(message[1])
|
|
217
|
+
return
|
|
218
|
+
case 'ping':
|
|
219
|
+
this.emitMessage(['pong', {}])
|
|
220
|
+
return
|
|
221
|
+
case 'pull':
|
|
222
|
+
this.run(this.answerMutationRecoveryPull(message[1]))
|
|
223
|
+
return
|
|
224
|
+
case 'deleteClients':
|
|
225
|
+
case 'ackMutationResponses':
|
|
226
|
+
return
|
|
227
|
+
default:
|
|
228
|
+
throw new Error(`unsupported zero-http upstream message ${message[0]}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close(code = 1000, reason = '') {
|
|
233
|
+
if (this.readyState === this.CLOSED) return
|
|
234
|
+
if (this.openTimer) clearTimeout(this.openTimer)
|
|
235
|
+
if (this.pullTimer) clearInterval(this.pullTimer)
|
|
236
|
+
this.readyState = this.CLOSED
|
|
237
|
+
this.state.sockets.delete(this)
|
|
238
|
+
this.emit('close', { code, reason, wasClean: code <= 1001 })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
pull(): Promise<void> {
|
|
242
|
+
if (this.readyState === this.CLOSED) return Promise.resolve()
|
|
243
|
+
if (this.pullInFlight) return this.pullInFlight
|
|
244
|
+
this.pullInFlight = this.fetchPull(this.clientGroupID, this.cookie)
|
|
245
|
+
.then((response) => {
|
|
246
|
+
if (response.unchanged) {
|
|
247
|
+
this.emitGotQueriesPatch(response.cookie)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
this.emitPoke(response)
|
|
251
|
+
})
|
|
252
|
+
.catch((error) => {
|
|
253
|
+
this.fail(error)
|
|
254
|
+
throw error
|
|
255
|
+
})
|
|
256
|
+
.finally(async () => {
|
|
257
|
+
const pullAgain = this.pullAfterCurrent
|
|
258
|
+
this.pullAfterCurrent = false
|
|
259
|
+
this.pullInFlight = undefined
|
|
260
|
+
if (pullAgain && this.readyState !== this.CLOSED) await this.pull()
|
|
261
|
+
})
|
|
262
|
+
return this.pullInFlight
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private open() {
|
|
266
|
+
if (this.readyState !== this.CONNECTING) return
|
|
267
|
+
this.readyState = this.OPEN
|
|
268
|
+
this.emit('open', {})
|
|
269
|
+
this.emitMessage(['connected', { wsid: this.wsid, timestamp: Date.now() }])
|
|
270
|
+
setTimeout(() => this.run(this.pull()), 0)
|
|
271
|
+
if (this.state.pullIntervalMs) {
|
|
272
|
+
this.pullTimer = setInterval(() => {
|
|
273
|
+
this.run(this.pull())
|
|
274
|
+
}, this.state.pullIntervalMs)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private queueDesiredQueries(body: unknown) {
|
|
279
|
+
const desiredQueriesPatch = (body as { desiredQueriesPatch?: unknown })
|
|
280
|
+
?.desiredQueriesPatch
|
|
281
|
+
if (!Array.isArray(desiredQueriesPatch)) return
|
|
282
|
+
this.pendingGotQueriesPatch.push(...gotQueriesPatch(desiredQueriesPatch))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async push(body: unknown) {
|
|
286
|
+
const response = (await this.postJSON('/push', body)) as {
|
|
287
|
+
pushResponse?: unknown
|
|
288
|
+
}
|
|
289
|
+
this.emitMessage(['pushResponse', response.pushResponse])
|
|
290
|
+
this.requestPullAfterCurrent()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private enqueuePush(body: unknown) {
|
|
294
|
+
const nextPush = this.pushChain.then(async () => {
|
|
295
|
+
if (this.readyState === this.CLOSED) return
|
|
296
|
+
await this.push(body)
|
|
297
|
+
})
|
|
298
|
+
this.pushChain = nextPush.catch(() => {})
|
|
299
|
+
this.run(nextPush)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private requestPullAfterCurrent() {
|
|
303
|
+
if (this.pullInFlight) {
|
|
304
|
+
this.pullAfterCurrent = true
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
this.run(this.pull())
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async answerMutationRecoveryPull(body: {
|
|
311
|
+
clientGroupID: string
|
|
312
|
+
cookie: string | null
|
|
313
|
+
requestID: string
|
|
314
|
+
}) {
|
|
315
|
+
const response = await this.fetchPull(body.clientGroupID, body.cookie)
|
|
316
|
+
const cookie = toWebSocketCookie(response.cookie)
|
|
317
|
+
this.emitMessage([
|
|
318
|
+
'pull',
|
|
319
|
+
{
|
|
320
|
+
requestID: body.requestID,
|
|
321
|
+
cookie: cookie ?? this.cookie ?? '0',
|
|
322
|
+
lastMutationIDChanges: response.unchanged ? {} : response.lastMutationIDChanges,
|
|
323
|
+
},
|
|
324
|
+
])
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async fetchPull(clientGroupID: string, cookie: string | null) {
|
|
328
|
+
return (await this.postJSON('/pull', {
|
|
329
|
+
clientID: this.clientID,
|
|
330
|
+
clientGroupID,
|
|
331
|
+
cookie: toHttpCookie(cookie),
|
|
332
|
+
})) as PullResponse
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async postJSON(path: '/pull' | '/push', body: unknown) {
|
|
336
|
+
const response = await this.state.fetch(`${this.state.originString}${path}`, {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: {
|
|
339
|
+
authorization: this.authToken ? `Bearer ${this.authToken}` : '',
|
|
340
|
+
'content-type': 'application/json',
|
|
341
|
+
},
|
|
342
|
+
body: JSON.stringify(body),
|
|
343
|
+
})
|
|
344
|
+
if (!response.ok) {
|
|
345
|
+
throw new ZeroHttpResponseError(path, response.status)
|
|
346
|
+
}
|
|
347
|
+
return response.json()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private run(promise: Promise<void>) {
|
|
351
|
+
void promise.catch((error) => this.fail(error))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private fail(error: unknown) {
|
|
355
|
+
if (this.readyState === this.CLOSED) return
|
|
356
|
+
if (isAuthHTTPError(error)) {
|
|
357
|
+
this.emitMessage([
|
|
358
|
+
'error',
|
|
359
|
+
{
|
|
360
|
+
kind: 'Unauthorized',
|
|
361
|
+
message: error.message,
|
|
362
|
+
origin: 'server',
|
|
363
|
+
},
|
|
364
|
+
])
|
|
365
|
+
if (this.readyState !== this.CLOSED) this.close(1000, error.message)
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
if (isStaleClientCookieError(error)) {
|
|
369
|
+
// the client's cookie is AHEAD of the server watermark — the server
|
|
370
|
+
// lost or reset its change-tracking state (replica reset / restore).
|
|
371
|
+
// mirror zero-cache's InvalidConnectionRequestBaseCookie error frame so
|
|
372
|
+
// the stock client drops its local db and rebuilds from scratch instead
|
|
373
|
+
// of reconnect-looping on 409 forever.
|
|
374
|
+
this.emitMessage([
|
|
375
|
+
'error',
|
|
376
|
+
{
|
|
377
|
+
kind: 'InvalidConnectionRequestBaseCookie',
|
|
378
|
+
message: error.message,
|
|
379
|
+
origin: 'server',
|
|
380
|
+
},
|
|
381
|
+
])
|
|
382
|
+
if (this.readyState !== this.CLOSED) this.close(1000, error.message)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
this.emit('error', { error })
|
|
386
|
+
this.close(1011, errorMessage(error))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private emitPoke(response: Exclude<PullResponse, { unchanged: true }>) {
|
|
390
|
+
const nextCookie = toWebSocketCookie(response.cookie)
|
|
391
|
+
if (isStaleCookie(this.cookie, response.cookie)) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`zero-http pull returned stale cookie ${response.cookie} for ${this.cookie}`,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const pokeID = `zero-http-${++this.state.nextPokeID}`
|
|
398
|
+
const gotQueries = this.pendingGotQueriesPatch
|
|
399
|
+
this.pendingGotQueriesPatch = []
|
|
400
|
+
|
|
401
|
+
this.emitMessage([
|
|
402
|
+
'pokeStart',
|
|
403
|
+
{
|
|
404
|
+
pokeID,
|
|
405
|
+
baseCookie: this.cookie,
|
|
406
|
+
schemaVersions: {
|
|
407
|
+
minSupportedVersion: 1,
|
|
408
|
+
maxSupportedVersion: 1,
|
|
409
|
+
},
|
|
410
|
+
timestamp: Date.now(),
|
|
411
|
+
},
|
|
412
|
+
])
|
|
413
|
+
this.emitMessage([
|
|
414
|
+
'pokePart',
|
|
415
|
+
{
|
|
416
|
+
pokeID,
|
|
417
|
+
lastMutationIDChanges: response.lastMutationIDChanges,
|
|
418
|
+
rowsPatch: response.rowsPatch,
|
|
419
|
+
},
|
|
420
|
+
])
|
|
421
|
+
if (gotQueries.length > 0) {
|
|
422
|
+
this.emitMessage([
|
|
423
|
+
'pokePart',
|
|
424
|
+
{
|
|
425
|
+
pokeID,
|
|
426
|
+
gotQueriesPatch: gotQueries,
|
|
427
|
+
},
|
|
428
|
+
])
|
|
429
|
+
}
|
|
430
|
+
this.emitMessage(['pokeEnd', { pokeID, cookie: nextCookie }])
|
|
431
|
+
this.cookie = nextCookie
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private emitGotQueriesPatch(cookie: number | null) {
|
|
435
|
+
if (this.pendingGotQueriesPatch.length === 0) return
|
|
436
|
+
|
|
437
|
+
const serverCookie = cookie ?? toHttpCookie(this.cookie)
|
|
438
|
+
if (serverCookie === null) return
|
|
439
|
+
const nextCookie = toLocalWebSocketCookie(serverCookie, ++this.nextLocalCookieID)
|
|
440
|
+
const pokeID = `zero-http-${++this.state.nextPokeID}`
|
|
441
|
+
const gotQueries = this.pendingGotQueriesPatch
|
|
442
|
+
this.pendingGotQueriesPatch = []
|
|
443
|
+
|
|
444
|
+
this.emitMessage([
|
|
445
|
+
'pokeStart',
|
|
446
|
+
{
|
|
447
|
+
pokeID,
|
|
448
|
+
baseCookie: this.cookie,
|
|
449
|
+
schemaVersions: {
|
|
450
|
+
minSupportedVersion: 1,
|
|
451
|
+
maxSupportedVersion: 1,
|
|
452
|
+
},
|
|
453
|
+
timestamp: Date.now(),
|
|
454
|
+
},
|
|
455
|
+
])
|
|
456
|
+
this.emitMessage([
|
|
457
|
+
'pokePart',
|
|
458
|
+
{
|
|
459
|
+
pokeID,
|
|
460
|
+
gotQueriesPatch: gotQueries,
|
|
461
|
+
},
|
|
462
|
+
])
|
|
463
|
+
this.emitMessage(['pokeEnd', { pokeID, cookie: nextCookie }])
|
|
464
|
+
this.cookie = nextCookie
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private emitMessage(message: unknown) {
|
|
468
|
+
if (this.readyState !== this.OPEN) return
|
|
469
|
+
this.emit('message', { data: JSON.stringify(message) })
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private emit(type: SocketEventType, event: any) {
|
|
473
|
+
const handler = (this as unknown as Record<string, unknown>)[`on${type}`]
|
|
474
|
+
if (typeof handler === 'function') handler.call(this, event)
|
|
475
|
+
for (const listener of this.listeners[type]) {
|
|
476
|
+
if (typeof listener === 'function') listener(event)
|
|
477
|
+
else listener.handleEvent(event)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function shouldIntercept(origin: URL, url: string | URL) {
|
|
483
|
+
const candidate = toHttpURL(url)
|
|
484
|
+
if (candidate.origin !== origin.origin) return false
|
|
485
|
+
return candidate.pathname === `${trimTrailingSlash(origin.pathname)}/sync/v51/connect`
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function toHttpURL(url: string | URL) {
|
|
489
|
+
const parsed = new URL(url)
|
|
490
|
+
if (parsed.protocol === 'ws:') parsed.protocol = 'http:'
|
|
491
|
+
if (parsed.protocol === 'wss:') parsed.protocol = 'https:'
|
|
492
|
+
return parsed
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function trimTrailingSlash(value: string) {
|
|
496
|
+
return value.endsWith('/') ? value.slice(0, -1) : value
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function decodeSecProtocol(protocols: WebSocketProtocols):
|
|
500
|
+
| {
|
|
501
|
+
authToken?: string
|
|
502
|
+
initConnectionMessage?: [string, Record<string, unknown>]
|
|
503
|
+
}
|
|
504
|
+
| Record<string, never> {
|
|
505
|
+
const protocol = Array.isArray(protocols) ? protocols[0] : protocols
|
|
506
|
+
if (!protocol) return {}
|
|
507
|
+
try {
|
|
508
|
+
const decoded = decodeURIComponent(protocol)
|
|
509
|
+
const json = new TextDecoder().decode(
|
|
510
|
+
Uint8Array.from(globalThis.atob(decoded), (char) => char.charCodeAt(0)),
|
|
511
|
+
)
|
|
512
|
+
const parsed = JSON.parse(json) as {
|
|
513
|
+
authToken?: string
|
|
514
|
+
initConnectionMessage?: unknown
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
authToken: parsed.authToken,
|
|
518
|
+
initConnectionMessage: Array.isArray(parsed.initConnectionMessage)
|
|
519
|
+
? (parsed.initConnectionMessage as [string, Record<string, unknown>])
|
|
520
|
+
: undefined,
|
|
521
|
+
}
|
|
522
|
+
} catch {
|
|
523
|
+
return {}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function gotQueriesPatch(patch: DesiredQueryPatchOp[]) {
|
|
528
|
+
const got: GotQueryPatchOp[] = []
|
|
529
|
+
for (const op of patch) {
|
|
530
|
+
if (op.op === 'clear') got.push({ op: 'clear' })
|
|
531
|
+
else if (op.hash) got.push({ op: op.op, hash: op.hash })
|
|
532
|
+
}
|
|
533
|
+
return got
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function toHttpCookie(cookie: string | null): number | null {
|
|
537
|
+
if (cookie === null || cookie === '') return null
|
|
538
|
+
const parsed = Number(cookie.slice(0, COOKIE_WIDTH))
|
|
539
|
+
if (!Number.isFinite(parsed)) {
|
|
540
|
+
throw new Error(`zero-http cookie is not numeric: ${cookie}`)
|
|
541
|
+
}
|
|
542
|
+
return parsed
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function toWebSocketCookie(cookie: number | null): string | null {
|
|
546
|
+
return cookie === null ? null : String(cookie).padStart(COOKIE_WIDTH, '0')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function toLocalWebSocketCookie(cookie: number, localID: number): string {
|
|
550
|
+
return `${String(cookie).padStart(COOKIE_WIDTH, '0')}#${String(localID).padStart(
|
|
551
|
+
6,
|
|
552
|
+
'0',
|
|
553
|
+
)}`
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function isStaleCookie(current: string | null, next: number) {
|
|
557
|
+
const currentNumber = toHttpCookie(current)
|
|
558
|
+
return currentNumber !== null && next <= currentNumber
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function errorMessage(error: unknown) {
|
|
562
|
+
return error instanceof Error ? error.message : String(error)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
class ZeroHttpResponseError extends Error {
|
|
566
|
+
constructor(
|
|
567
|
+
readonly path: '/pull' | '/push',
|
|
568
|
+
readonly status: number,
|
|
569
|
+
) {
|
|
570
|
+
super(`zero-http ${path} failed with ${status}`)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function isAuthHTTPError(error: unknown): error is ZeroHttpResponseError {
|
|
575
|
+
return (
|
|
576
|
+
error instanceof ZeroHttpResponseError &&
|
|
577
|
+
(error.status === 401 || error.status === 403)
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function isStaleClientCookieError(error: unknown): error is ZeroHttpResponseError {
|
|
582
|
+
return (
|
|
583
|
+
error instanceof ZeroHttpResponseError &&
|
|
584
|
+
error.path === '/pull' &&
|
|
585
|
+
error.status === 409
|
|
586
|
+
)
|
|
587
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { setAuthData, setEnvironment } from './state'
|
|
|
10
10
|
|
|
11
11
|
export * from './combineZeroClients'
|
|
12
12
|
export * from './createZeroClient'
|
|
13
|
+
export * from './httpPullTransport'
|
|
13
14
|
export * from './createUseQuery'
|
|
14
15
|
export * from './resolveQuery'
|
|
15
16
|
export * from './run'
|
|
@@ -18,10 +18,12 @@ export declare function createZeroClient<Schema extends ZeroSchema, Models exten
|
|
|
18
18
|
}): {
|
|
19
19
|
instanceName: string;
|
|
20
20
|
zeroEvents: import("@take-out/helpers").Emitter<ZeroEvent | null>;
|
|
21
|
-
ProvideZero: ({ children, authData: authDataIn, disable, ...props }: Omit<ZeroOptions<Schema, GetZeroMutators<Models>>, "schema" | "mutators"> & {
|
|
21
|
+
ProvideZero: ({ children, authData: authDataIn, disable, transport, pullIntervalMs, ...props }: Omit<ZeroOptions<Schema, GetZeroMutators<Models>>, "schema" | "mutators"> & {
|
|
22
22
|
children: ReactNode;
|
|
23
23
|
authData?: AuthData | null;
|
|
24
24
|
disable?: boolean;
|
|
25
|
+
transport?: "http-pull";
|
|
26
|
+
pullIntervalMs?: number;
|
|
25
27
|
}) => string | number | bigint | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | null | undefined;
|
|
26
28
|
ControlQueries: ({ children, action, whenDisabled, }: {
|
|
27
29
|
children: ReactNode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createZeroClient.d.ts","sourceRoot":"","sources":["../src/createZeroClient.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA8B,IAAI,IAAI,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAG/E,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"createZeroClient.d.ts","sourceRoot":"","sources":["../src/createZeroClient.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.test.d.ts","sourceRoot":"","sources":["../../src/httpPull/auth.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"churn.test.d.ts","sourceRoot":"","sources":["../../src/httpPull/churn.test.ts"],"names":[],"mappings":""}
|