on-zero 0.6.20 → 0.6.21

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.
@@ -58,6 +58,8 @@ type PullResponse =
58
58
  }
59
59
 
60
60
  type TransportState = {
61
+ readonly pageID: string
62
+ readonly transportID: string
61
63
  readonly origin: URL
62
64
  readonly originString: string
63
65
  readonly pullOriginString: string
@@ -78,10 +80,15 @@ type TransportState = {
78
80
  }
79
81
 
80
82
  const COOKIE_WIDTH = 20
83
+ // @rocicorp/zero 1.6 starts this deadline before its async createSocket work.
84
+ // the connect URL's `ts` is captured at the same attempt boundary.
85
+ const ZERO_CONNECT_TIMEOUT_MS = 10_000
81
86
 
82
87
  export type HttpPullTransport = {
83
88
  pull(): Promise<void>
84
89
  readonly connections: number
90
+ readonly pageID: string
91
+ readonly transportID: string
85
92
  uninstall(): void
86
93
  }
87
94
 
@@ -109,24 +116,74 @@ export type HttpPullTransportOptions = {
109
116
  // queries ship as name+args for the SERVER (consumer worker) to resolve with
110
117
  // auth. the production path for permission-transformed queries.
111
118
  queryForward?: boolean
112
- // optional generation trace for connection-lifecycle diagnostics
119
+ // receives the structured connection lifecycle. when omitted, on-zero logs
120
+ // each event as one JSON line so production failures retain their owners.
113
121
  lifecycle?: (event: HttpPullLifecycleEvent) => void
114
122
  }
115
123
 
116
124
  export type HttpPullLifecycleEvent = {
117
- type: 'created' | 'listener' | 'open' | 'close' | 'failure' | 'superseded'
125
+ type: 'created' | 'listener' | 'open' | 'close' | 'failure' | 'superseded' | 'aborted'
126
+ pageID: string
127
+ transportID: string
128
+ zeroInstanceID: string
129
+ clientGroupID: string
130
+ connectionAttemptID: string
131
+ socketID: string
118
132
  generation: number
119
133
  activeGeneration: number
120
134
  clientID: string
121
135
  wsid: string
136
+ timestamp: number
137
+ attemptStartedAt: number | undefined
138
+ attemptAgeMs: number | undefined
122
139
  listener?: SocketEventType
123
140
  code?: number
124
141
  reason?: string
125
142
  }
126
143
 
144
+ type HttpPullPageRegistry = {
145
+ readonly pageID: string
146
+ nextTransportGeneration: number
147
+ readonly transportsByOrigin: Map<string, HttpPullTransport>
148
+ }
149
+
150
+ const HTTP_PULL_PAGE_REGISTRY = Symbol.for('on-zero.http-pull.page-registry')
151
+
152
+ function isHttpPullPageRegistry(value: unknown): value is HttpPullPageRegistry {
153
+ return (
154
+ typeof value === 'object' &&
155
+ value !== null &&
156
+ 'pageID' in value &&
157
+ typeof value.pageID === 'string' &&
158
+ 'nextTransportGeneration' in value &&
159
+ typeof value.nextTransportGeneration === 'number' &&
160
+ 'transportsByOrigin' in value &&
161
+ value.transportsByOrigin instanceof Map
162
+ )
163
+ }
164
+
165
+ function getHttpPullPageRegistry(): HttpPullPageRegistry {
166
+ const existing = Reflect.get(globalThis, HTTP_PULL_PAGE_REGISTRY)
167
+ if (isHttpPullPageRegistry(existing)) return existing
168
+ const registry: HttpPullPageRegistry = {
169
+ pageID: `page-${Date.now().toString(36)}`,
170
+ nextTransportGeneration: 0,
171
+ transportsByOrigin: new Map(),
172
+ }
173
+ Reflect.set(globalThis, HTTP_PULL_PAGE_REGISTRY, registry)
174
+ return registry
175
+ }
176
+
177
+ function logHttpPullLifecycle(event: HttpPullLifecycleEvent) {
178
+ if (process.env.NODE_ENV === 'test') return
179
+ console.info(`[on-zero:http-pull] ${JSON.stringify(event)}`)
180
+ }
181
+
127
182
  export function installHttpPullTransport(
128
183
  opts: HttpPullTransportOptions,
129
184
  ): HttpPullTransport {
185
+ const pageRegistry = getHttpPullPageRegistry()
186
+ const transportID = `${pageRegistry.pageID}:transport:${++pageRegistry.nextTransportGeneration}`
130
187
  const previousWebSocket = globalThis.WebSocket as WebSocketConstructor | undefined
131
188
  const fetchImpl = opts.fetch ?? globalThis.fetch
132
189
  if (!fetchImpl) {
@@ -134,6 +191,8 @@ export function installHttpPullTransport(
134
191
  }
135
192
 
136
193
  const state: TransportState = {
194
+ pageID: pageRegistry.pageID,
195
+ transportID,
137
196
  origin: new URL(opts.origin),
138
197
  originString: trimTrailingSlash(new URL(opts.origin).toString()),
139
198
  pullOriginString: trimTrailingSlash(
@@ -157,7 +216,7 @@ export function installHttpPullTransport(
157
216
  nextSocketGeneration: 0,
158
217
  activeSocketGenerationByClient: new Map(),
159
218
  activeSocketByClient: new Map(),
160
- lifecycle: opts.lifecycle,
219
+ lifecycle: opts.lifecycle ?? logHttpPullLifecycle,
161
220
  }
162
221
 
163
222
  const Shim = class {
@@ -179,34 +238,40 @@ export function installHttpPullTransport(
179
238
 
180
239
  globalThis.WebSocket = Shim as unknown as typeof WebSocket
181
240
 
182
- return {
241
+ const transport: HttpPullTransport = {
183
242
  pull: async () => {
184
243
  await Promise.all([...state.sockets].map((socket) => socket.pull()))
185
244
  },
186
245
  get connections() {
187
246
  return state.sockets.size
188
247
  },
248
+ pageID: state.pageID,
249
+ transportID: state.transportID,
189
250
  uninstall: () => {
190
251
  if (globalThis.WebSocket === (Shim as unknown as typeof WebSocket)) {
191
252
  globalThis.WebSocket = previousWebSocket as typeof WebSocket
192
253
  }
254
+ const registered = pageRegistry.transportsByOrigin.get(state.originString)
255
+ if (registered === transport) {
256
+ pageRegistry.transportsByOrigin.delete(state.originString)
257
+ }
193
258
  },
194
259
  }
260
+ return transport
195
261
  }
196
262
 
197
263
  // per-origin idempotent install for app usage (ProvideZero rotates zero
198
264
  // instances against the same server; installing per rotation would chain
199
265
  // shims unboundedly). installed transports live for the page lifetime.
200
- const transportsByOrigin = new Map<string, HttpPullTransport>()
201
-
202
266
  export function ensureHttpPullTransport(
203
267
  opts: HttpPullTransportOptions,
204
268
  ): HttpPullTransport {
269
+ const pageRegistry = getHttpPullPageRegistry()
205
270
  const key = trimTrailingSlash(new URL(opts.origin).toString())
206
- const existing = transportsByOrigin.get(key)
271
+ const existing = pageRegistry.transportsByOrigin.get(key)
207
272
  if (existing) return existing
208
273
  const transport = installHttpPullTransport(opts)
209
- transportsByOrigin.set(key, transport)
274
+ pageRegistry.transportsByOrigin.set(key, transport)
210
275
  return transport
211
276
  }
212
277
 
@@ -249,6 +314,11 @@ class ZeroHttpSocket {
249
314
  private wakeSocket: { close(): void } | undefined
250
315
  private wakeReconnectTimer: ReturnType<typeof setTimeout> | undefined
251
316
  private readonly generation: number
317
+ private readonly zeroInstanceID: string
318
+ private readonly connectionAttemptID: string
319
+ private readonly socketID: string
320
+ private readonly attemptStartedAt: number | undefined
321
+ private settled = false
252
322
 
253
323
  constructor(
254
324
  private readonly state: TransportState,
@@ -261,9 +331,19 @@ class ZeroHttpSocket {
261
331
  this.clientGroupID = this.connectURL.searchParams.get('clientGroupID') ?? ''
262
332
  this.wsid = this.connectURL.searchParams.get('wsid') ?? `zero-http-${Date.now()}`
263
333
  this.generation = ++this.state.nextSocketGeneration
264
- this.state.activeSocketByClient.get(this.clientID)?.supersede()
334
+ this.zeroInstanceID = `${this.state.pageID}:zero:${this.clientID}`
335
+ this.connectionAttemptID = `${this.zeroInstanceID}:attempt:${this.wsid}`
336
+ this.socketID = `${this.state.transportID}:socket:${this.generation}`
337
+ const attemptStartedAtParam = this.connectURL.searchParams.get('ts')
338
+ const attemptStartedAt = Number(attemptStartedAtParam)
339
+ this.attemptStartedAt =
340
+ attemptStartedAtParam !== null && Number.isFinite(attemptStartedAt)
341
+ ? attemptStartedAt
342
+ : undefined
343
+ const previousSocket = this.state.activeSocketByClient.get(this.clientID)
265
344
  this.state.activeSocketGenerationByClient.set(this.clientID, this.generation)
266
345
  this.state.activeSocketByClient.set(this.clientID, this)
346
+ previousSocket?.supersede()
267
347
  this.emitLifecycle('created')
268
348
  const baseCookie = this.connectURL.searchParams.get('baseCookie')
269
349
  this.cookie = baseCookie ? baseCookie : null
@@ -281,9 +361,9 @@ class ZeroHttpSocket {
281
361
  this.queueDesiredQueries(decoded.initConnectionMessage?.[1])
282
362
 
283
363
  this.state.sockets.add(this)
284
- // open on the next task so Zero can attach its listeners. Zero owns the
285
- // connection timeout and retry cadence; delaying this synthetic open here
286
- // can outlive Zero's 10s attempt and deliver an event to abandoned state.
364
+ // open on the next task so Zero can attach its listeners. open() checks the
365
+ // attempt timestamp first, so async socket construction that outlived Zero's
366
+ // deadline closes without delivering an event to abandoned state.
287
367
  this.openTimer = setTimeout(() => this.open(), 0)
288
368
  }
289
369
 
@@ -335,17 +415,7 @@ class ZeroHttpSocket {
335
415
  }
336
416
 
337
417
  close(code = 1000, reason = '') {
338
- if (this.readyState === this.CLOSED) return
339
- if (this.openTimer) clearTimeout(this.openTimer)
340
- if (this.pullTimer) clearInterval(this.pullTimer)
341
- this.closeWakeChannel()
342
- this.readyState = this.CLOSED
343
- this.state.sockets.delete(this)
344
- if (this.state.activeSocketByClient.get(this.clientID) === this) {
345
- this.state.activeSocketByClient.delete(this.clientID)
346
- this.state.activeSocketGenerationByClient.delete(this.clientID)
347
- }
348
- this.emitLifecycle('close', { code, reason })
418
+ if (!this.settle('close', { code, reason })) return
349
419
  this.emit('close', { code, reason, wasClean: code <= 1001 })
350
420
  }
351
421
 
@@ -376,6 +446,15 @@ class ZeroHttpSocket {
376
446
 
377
447
  private open() {
378
448
  if (this.readyState !== this.CONNECTING) return
449
+ const attemptAgeMs = this.getAttemptAgeMs()
450
+ if (attemptAgeMs !== undefined && attemptAgeMs >= ZERO_CONNECT_TIMEOUT_MS) {
451
+ const reason = `zero-http connection attempt ${this.wsid} expired after ${Math.round(
452
+ attemptAgeMs,
453
+ )}ms before socket construction completed`
454
+ if (!this.settle('aborted', { code: 1000, reason })) return
455
+ this.emit('close', { code: 1000, reason, wasClean: true })
456
+ return
457
+ }
379
458
  this.readyState = this.OPEN
380
459
  this.emitLifecycle('open')
381
460
  this.emit('open', {})
@@ -740,13 +819,32 @@ class ZeroHttpSocket {
740
819
  }
741
820
 
742
821
  private supersede() {
743
- if (this.readyState === this.CLOSED) return
822
+ this.settle('superseded')
823
+ }
824
+
825
+ private settle(
826
+ type: Extract<HttpPullLifecycleEvent['type'], 'close' | 'superseded' | 'aborted'>,
827
+ detail: Pick<HttpPullLifecycleEvent, 'code' | 'reason'> = {},
828
+ ) {
829
+ if (this.settled) return false
830
+ this.settled = true
744
831
  if (this.openTimer !== undefined) clearTimeout(this.openTimer)
745
832
  if (this.pullTimer !== undefined) clearInterval(this.pullTimer)
746
833
  this.closeWakeChannel()
747
834
  this.readyState = this.CLOSED
748
835
  this.state.sockets.delete(this)
749
- this.emitLifecycle('superseded')
836
+ if (this.state.activeSocketByClient.get(this.clientID) === this) {
837
+ this.state.activeSocketByClient.delete(this.clientID)
838
+ this.state.activeSocketGenerationByClient.delete(this.clientID)
839
+ }
840
+ this.emitLifecycle(type, detail)
841
+ return true
842
+ }
843
+
844
+ private getAttemptAgeMs() {
845
+ return this.attemptStartedAt === undefined
846
+ ? undefined
847
+ : performance.now() - this.attemptStartedAt
750
848
  }
751
849
 
752
850
  private emitLifecycle(
@@ -755,11 +853,20 @@ class ZeroHttpSocket {
755
853
  ) {
756
854
  this.state.lifecycle?.({
757
855
  type,
856
+ pageID: this.state.pageID,
857
+ transportID: this.state.transportID,
858
+ zeroInstanceID: this.zeroInstanceID,
859
+ clientGroupID: this.clientGroupID,
860
+ connectionAttemptID: this.connectionAttemptID,
861
+ socketID: this.socketID,
758
862
  generation: this.generation,
759
863
  activeGeneration:
760
864
  this.state.activeSocketGenerationByClient.get(this.clientID) ?? this.generation,
761
865
  clientID: this.clientID,
762
866
  wsid: this.wsid,
867
+ timestamp: Date.now(),
868
+ attemptStartedAt: this.attemptStartedAt,
869
+ attemptAgeMs: this.getAttemptAgeMs(),
763
870
  ...detail,
764
871
  })
765
872
  }
@@ -3,6 +3,8 @@ export type QueryTransform = (name: string, args: readonly unknown[]) => unknown
3
3
  export type HttpPullTransport = {
4
4
  pull(): Promise<void>;
5
5
  readonly connections: number;
6
+ readonly pageID: string;
7
+ readonly transportID: string;
6
8
  uninstall(): void;
7
9
  };
8
10
  export type HttpPullTransportOptions = {
@@ -17,11 +19,20 @@ export type HttpPullTransportOptions = {
17
19
  lifecycle?: (event: HttpPullLifecycleEvent) => void;
18
20
  };
19
21
  export type HttpPullLifecycleEvent = {
20
- type: 'created' | 'listener' | 'open' | 'close' | 'failure' | 'superseded';
22
+ type: 'created' | 'listener' | 'open' | 'close' | 'failure' | 'superseded' | 'aborted';
23
+ pageID: string;
24
+ transportID: string;
25
+ zeroInstanceID: string;
26
+ clientGroupID: string;
27
+ connectionAttemptID: string;
28
+ socketID: string;
21
29
  generation: number;
22
30
  activeGeneration: number;
23
31
  clientID: string;
24
32
  wsid: string;
33
+ timestamp: number;
34
+ attemptStartedAt: number | undefined;
35
+ attemptAgeMs: number | undefined;
25
36
  listener?: SocketEventType;
26
37
  code?: number;
27
38
  reason?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"httpPullTransport.d.ts","sourceRoot":"","sources":["../src/httpPullTransport.ts"],"names":[],"mappings":"AAUA,KAAK,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;AA+B7D,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE,KAAK,OAAO,CAAA;AAwChF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,SAAS,IAAI,IAAI,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,MAAM,CAAA;IAGd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IAGpB,cAAc,CAAC,EAAE,MAAM,CAAA;IAMvB,IAAI,CAAC,EAAE,OAAO,CAAA;IAId,cAAc,CAAC,EAAE,cAAc,CAAA;IAI/B,YAAY,CAAC,EAAE,OAAO,CAAA;IAEtB,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,IAAI,CAAA;CACpD,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,YAAY,CAAA;IAC1E,UAAU,EAAE,MAAM,CAAA;IAClB,gBAAgB,EAAE,MAAM,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CAkEnB;AAOD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CAOnB"}
1
+ {"version":3,"file":"httpPullTransport.d.ts","sourceRoot":"","sources":["../src/httpPullTransport.ts"],"names":[],"mappings":"AAUA,KAAK,eAAe,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,OAAO,CAAA;AA+B7D,MAAM,MAAM,cAAc,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,OAAO,EAAE,KAAK,OAAO,CAAA;AA6ChF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,SAAS,IAAI,IAAI,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,MAAM,CAAA;IAGd,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IAGpB,cAAc,CAAC,EAAE,MAAM,CAAA;IAMvB,IAAI,CAAC,EAAE,OAAO,CAAA;IAId,cAAc,CAAC,EAAE,cAAc,CAAA;IAI/B,YAAY,CAAC,EAAE,OAAO,CAAA;IAGtB,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,IAAI,CAAA;CACpD,CAAA;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAA;IACtF,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,cAAc,EAAE,MAAM,CAAA;IACtB,aAAa,EAAE,MAAM,CAAA;IACrB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,gBAAgB,EAAE,MAAM,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,MAAM,CAAA;IACjB,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAwCD,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CA6EnB;AAKD,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,wBAAwB,GAC7B,iBAAiB,CAQnB"}