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.
Files changed (123) hide show
  1. package/dist/cjs/createZeroClient.cjs +14 -1
  2. package/dist/cjs/createZeroClient.native.js +15 -1
  3. package/dist/cjs/createZeroClient.native.js.map +1 -1
  4. package/dist/cjs/httpPull/auth.test.cjs +197 -0
  5. package/dist/cjs/httpPull/auth.test.native.js +279 -0
  6. package/dist/cjs/httpPull/auth.test.native.js.map +1 -0
  7. package/dist/cjs/httpPull/churn.test.cjs +132 -0
  8. package/dist/cjs/httpPull/churn.test.native.js +155 -0
  9. package/dist/cjs/httpPull/churn.test.native.js.map +1 -0
  10. package/dist/cjs/httpPull/fixtureSchema.cjs +76 -0
  11. package/dist/cjs/httpPull/fixtureSchema.native.js +82 -0
  12. package/dist/cjs/httpPull/fixtureSchema.native.js.map +1 -0
  13. package/dist/cjs/httpPull/fixtureServer.cjs +340 -0
  14. package/dist/cjs/httpPull/fixtureServer.native.js +534 -0
  15. package/dist/cjs/httpPull/fixtureServer.native.js.map +1 -0
  16. package/dist/cjs/httpPull/integration.test.cjs +53 -0
  17. package/dist/cjs/httpPull/integration.test.native.js +60 -0
  18. package/dist/cjs/httpPull/integration.test.native.js.map +1 -0
  19. package/dist/cjs/httpPull/rebase.test.cjs +360 -0
  20. package/dist/cjs/httpPull/rebase.test.native.js +420 -0
  21. package/dist/cjs/httpPull/rebase.test.native.js.map +1 -0
  22. package/dist/cjs/httpPull/relations.test.cjs +107 -0
  23. package/dist/cjs/httpPull/relations.test.native.js +119 -0
  24. package/dist/cjs/httpPull/relations.test.native.js.map +1 -0
  25. package/dist/cjs/httpPull/testHarness.cjs +100 -0
  26. package/dist/cjs/httpPull/testHarness.native.js +112 -0
  27. package/dist/cjs/httpPull/testHarness.native.js.map +1 -0
  28. package/dist/cjs/httpPull/transport.test.cjs +588 -0
  29. package/dist/cjs/httpPull/transport.test.native.js +677 -0
  30. package/dist/cjs/httpPull/transport.test.native.js.map +1 -0
  31. package/dist/cjs/httpPullTransport.cjs +447 -0
  32. package/dist/cjs/httpPullTransport.native.js +710 -0
  33. package/dist/cjs/httpPullTransport.native.js.map +1 -0
  34. package/dist/cjs/index.cjs +1 -0
  35. package/dist/cjs/index.native.js +1 -0
  36. package/dist/cjs/index.native.js.map +1 -1
  37. package/dist/esm/createZeroClient.mjs +14 -1
  38. package/dist/esm/createZeroClient.mjs.map +1 -1
  39. package/dist/esm/createZeroClient.native.js +15 -1
  40. package/dist/esm/createZeroClient.native.js.map +1 -1
  41. package/dist/esm/httpPull/auth.test.mjs +198 -0
  42. package/dist/esm/httpPull/auth.test.mjs.map +1 -0
  43. package/dist/esm/httpPull/auth.test.native.js +277 -0
  44. package/dist/esm/httpPull/auth.test.native.js.map +1 -0
  45. package/dist/esm/httpPull/churn.test.mjs +133 -0
  46. package/dist/esm/httpPull/churn.test.mjs.map +1 -0
  47. package/dist/esm/httpPull/churn.test.native.js +153 -0
  48. package/dist/esm/httpPull/churn.test.native.js.map +1 -0
  49. package/dist/esm/httpPull/fixtureSchema.mjs +50 -0
  50. package/dist/esm/httpPull/fixtureSchema.mjs.map +1 -0
  51. package/dist/esm/httpPull/fixtureSchema.native.js +53 -0
  52. package/dist/esm/httpPull/fixtureSchema.native.js.map +1 -0
  53. package/dist/esm/httpPull/fixtureServer.mjs +315 -0
  54. package/dist/esm/httpPull/fixtureServer.mjs.map +1 -0
  55. package/dist/esm/httpPull/fixtureServer.native.js +506 -0
  56. package/dist/esm/httpPull/fixtureServer.native.js.map +1 -0
  57. package/dist/esm/httpPull/integration.test.mjs +54 -0
  58. package/dist/esm/httpPull/integration.test.mjs.map +1 -0
  59. package/dist/esm/httpPull/integration.test.native.js +58 -0
  60. package/dist/esm/httpPull/integration.test.native.js.map +1 -0
  61. package/dist/esm/httpPull/rebase.test.mjs +361 -0
  62. package/dist/esm/httpPull/rebase.test.mjs.map +1 -0
  63. package/dist/esm/httpPull/rebase.test.native.js +418 -0
  64. package/dist/esm/httpPull/rebase.test.native.js.map +1 -0
  65. package/dist/esm/httpPull/relations.test.mjs +108 -0
  66. package/dist/esm/httpPull/relations.test.mjs.map +1 -0
  67. package/dist/esm/httpPull/relations.test.native.js +117 -0
  68. package/dist/esm/httpPull/relations.test.native.js.map +1 -0
  69. package/dist/esm/httpPull/testHarness.mjs +72 -0
  70. package/dist/esm/httpPull/testHarness.mjs.map +1 -0
  71. package/dist/esm/httpPull/testHarness.native.js +81 -0
  72. package/dist/esm/httpPull/testHarness.native.js.map +1 -0
  73. package/dist/esm/httpPull/transport.test.mjs +589 -0
  74. package/dist/esm/httpPull/transport.test.mjs.map +1 -0
  75. package/dist/esm/httpPull/transport.test.native.js +675 -0
  76. package/dist/esm/httpPull/transport.test.native.js.map +1 -0
  77. package/dist/esm/httpPullTransport.mjs +421 -0
  78. package/dist/esm/httpPullTransport.mjs.map +1 -0
  79. package/dist/esm/httpPullTransport.native.js +681 -0
  80. package/dist/esm/httpPullTransport.native.js.map +1 -0
  81. package/dist/esm/index.js +1 -0
  82. package/dist/esm/index.js.map +1 -1
  83. package/dist/esm/index.mjs +1 -0
  84. package/dist/esm/index.mjs.map +1 -1
  85. package/dist/esm/index.native.js +1 -0
  86. package/dist/esm/index.native.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/createZeroClient.tsx +22 -0
  89. package/src/httpPull/auth.test.ts +208 -0
  90. package/src/httpPull/churn.test.ts +147 -0
  91. package/src/httpPull/fixtureSchema.ts +82 -0
  92. package/src/httpPull/fixtureServer.ts +391 -0
  93. package/src/httpPull/integration.test.ts +57 -0
  94. package/src/httpPull/rebase.test.ts +368 -0
  95. package/src/httpPull/relations.test.ts +135 -0
  96. package/src/httpPull/testHarness.ts +95 -0
  97. package/src/httpPull/transport.test.ts +603 -0
  98. package/src/httpPullTransport.ts +587 -0
  99. package/src/index.ts +1 -0
  100. package/types/createZeroClient.d.ts +3 -1
  101. package/types/createZeroClient.d.ts.map +1 -1
  102. package/types/httpPull/auth.test.d.ts +2 -0
  103. package/types/httpPull/auth.test.d.ts.map +1 -0
  104. package/types/httpPull/churn.test.d.ts +2 -0
  105. package/types/httpPull/churn.test.d.ts.map +1 -0
  106. package/types/httpPull/fixtureSchema.d.ts +111 -0
  107. package/types/httpPull/fixtureSchema.d.ts.map +1 -0
  108. package/types/httpPull/fixtureServer.d.ts +14 -0
  109. package/types/httpPull/fixtureServer.d.ts.map +1 -0
  110. package/types/httpPull/integration.test.d.ts +2 -0
  111. package/types/httpPull/integration.test.d.ts.map +1 -0
  112. package/types/httpPull/rebase.test.d.ts +2 -0
  113. package/types/httpPull/rebase.test.d.ts.map +1 -0
  114. package/types/httpPull/relations.test.d.ts +2 -0
  115. package/types/httpPull/relations.test.d.ts.map +1 -0
  116. package/types/httpPull/testHarness.d.ts +32 -0
  117. package/types/httpPull/testHarness.d.ts.map +1 -0
  118. package/types/httpPull/transport.test.d.ts +2 -0
  119. package/types/httpPull/transport.test.d.ts.map +1 -0
  120. package/types/httpPullTransport.d.ts +13 -0
  121. package/types/httpPullTransport.d.ts.map +1 -0
  122. package/types/index.d.ts +1 -0
  123. 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":"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"}
1
+ {"version":3,"file":"createZeroClient.d.ts","sourceRoot":"","sources":["../src/createZeroClient.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=auth.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.test.d.ts","sourceRoot":"","sources":["../../src/httpPull/auth.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=churn.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"churn.test.d.ts","sourceRoot":"","sources":["../../src/httpPull/churn.test.ts"],"names":[],"mappings":""}