velocious 1.0.708 → 1.0.710

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 (37) hide show
  1. package/README.md +1 -1
  2. package/build/configuration-types.js +19 -3
  3. package/build/configuration.js +85 -3
  4. package/build/database/advisory-lock-errors.js +59 -0
  5. package/build/database/advisory-lock-lock-server-backend.js +557 -0
  6. package/build/database/advisory-lock-runner.js +175 -2
  7. package/build/database/migrator.js +29 -1
  8. package/build/database/record/index.js +16 -2
  9. package/build/src/configuration-types.d.ts +76 -4
  10. package/build/src/configuration-types.d.ts.map +1 -1
  11. package/build/src/configuration-types.js +19 -4
  12. package/build/src/configuration.d.ts +42 -2
  13. package/build/src/configuration.d.ts.map +1 -1
  14. package/build/src/configuration.js +82 -4
  15. package/build/src/database/advisory-lock-errors.d.ts +54 -0
  16. package/build/src/database/advisory-lock-errors.d.ts.map +1 -0
  17. package/build/src/database/advisory-lock-errors.js +52 -0
  18. package/build/src/database/advisory-lock-lock-server-backend.d.ts +279 -0
  19. package/build/src/database/advisory-lock-lock-server-backend.d.ts.map +1 -0
  20. package/build/src/database/advisory-lock-lock-server-backend.js +474 -0
  21. package/build/src/database/advisory-lock-runner.d.ts +69 -1
  22. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  23. package/build/src/database/advisory-lock-runner.js +150 -3
  24. package/build/src/database/migrator.d.ts +13 -0
  25. package/build/src/database/migrator.d.ts.map +1 -1
  26. package/build/src/database/migrator.js +27 -2
  27. package/build/src/database/record/index.d.ts +2 -2
  28. package/build/src/database/record/index.d.ts.map +1 -1
  29. package/build/src/database/record/index.js +3 -3
  30. package/package.json +1 -1
  31. package/src/configuration-types.js +19 -3
  32. package/src/configuration.js +85 -3
  33. package/src/database/advisory-lock-errors.js +59 -0
  34. package/src/database/advisory-lock-lock-server-backend.js +557 -0
  35. package/src/database/advisory-lock-runner.js +175 -2
  36. package/src/database/migrator.js +29 -1
  37. package/src/database/record/index.js +16 -2
@@ -0,0 +1,557 @@
1
+ // @ts-check
2
+
3
+ import WebSocket from "ws"
4
+
5
+ import VelociousWebsocketClient from "../http-client/websocket-client.js"
6
+ import {AdvisoryLockServiceUnavailableError} from "./advisory-lock-errors.js"
7
+
8
+ /**
9
+ * @typedef {object} LockServerConnectionLike
10
+ * @property {Promise<void>} ready - Resolves when the server accepted the 1:1 connection.
11
+ * @property {(body: Record<string, unknown>) => void} sendMessage - Sends one connection message.
12
+ * @property {() => void} [close] - Closes the connection.
13
+ * @typedef {object} LockServerClientLike
14
+ * @property {() => Promise<void>} connect - Opens the transport.
15
+ * @property {(type: string, args: {onMessage: (body: Record<string, unknown>) => void, params: Record<string, unknown>, timeoutMs: number}) => LockServerConnectionLike} openConnection - Opens the 1:1 connection.
16
+ * @property {() => Promise<void> | void} [close] - Closes the transport.
17
+ * @typedef {{epoch: number, name: string, timer: ReturnType<typeof setInterval>, leaseExpiresAt: number}} LockServerHold
18
+ * @typedef {{status: "acquired", epoch: number, leaseExpiresAt: number} | {status: "busy"} | {status: "timeout"}} LockServerAcquireResult
19
+ */
20
+
21
+ /** Protocol version spoken by the centralized lock server (consens-lock v2). */
22
+ export const LOCK_SERVER_PROTOCOL_VERSION = 2
23
+
24
+ /** Connection role registered on the lock server. */
25
+ const LOCK_SERVER_CONNECTION_TYPE = "lock"
26
+
27
+ /**
28
+ * Tier (b) backend: the centralized lock server (`consens-lock`, protocol v2).
29
+ *
30
+ * The server holds every lock in its own process memory, so a held lock never
31
+ * pins a database connection — that is the whole point of this tier. Ownership
32
+ * is bound to this process's connection: the server mints the `holderId`, so a
33
+ * client can only renew or release its own leases, and a dropped connection
34
+ * releases all of them immediately.
35
+ *
36
+ * Holds are leases renewed every third of the lease window. A fenced lease
37
+ * (the server granted the name to another holder after an expiry or partition)
38
+ * is recorded per lock name and surfaced by the runner as
39
+ * `AdvisoryLockLostError` after the critical section settles.
40
+ *
41
+ * Unavailability is cached for `reprobeCooldownMs` so a flapping server cannot
42
+ * turn every waiting caller into a connection attempt (design §2).
43
+ */
44
+ export default class AdvisoryLockLockServerBackend {
45
+ /**
46
+ * Creates the lock-server backend. The transport is created on first use, so
47
+ * constructing this object never performs I/O.
48
+ * @param {object} args - Backend options.
49
+ * @param {string} [args.authToken] - Shared secret; the server fails closed without a matching token.
50
+ * @param {(args: {authToken: string, url: string}) => LockServerClientLike} [args.clientFactory] - Transport factory (tests inject a fake).
51
+ * @param {number} [args.connectTimeoutMs] - Handshake timeout.
52
+ * @param {number} [args.leaseMs] - Lease duration used until the server advertises its own.
53
+ * @param {(error: Error) => void} [args.onError] - Async error reporter.
54
+ * @param {number} [args.reprobeCooldownMs] - How long an unavailable server is not re-probed.
55
+ * @param {string} args.url - Lock-server websocket URL.
56
+ */
57
+ constructor({authToken = "", clientFactory = defaultClientFactory, connectTimeoutMs = 3000, leaseMs = 30_000, onError, reprobeCooldownMs = 5000, url}) {
58
+ this.url = url
59
+ this.authToken = authToken
60
+ this.clientFactory = clientFactory
61
+ this.connectTimeoutMs = connectTimeoutMs
62
+ this.leaseMs = leaseMs
63
+ this.onError = onError
64
+ this.reprobeCooldownMs = reprobeCooldownMs
65
+ /** @type {LockServerClientLike | null} */
66
+ this.client = null
67
+ /** @type {LockServerConnectionLike | null} */
68
+ this.connection = null
69
+ /** @type {Promise<void> | null} */
70
+ this.connectPromise = null
71
+ /** @type {{reject: (error: Error) => void, resolve: () => void, timer: ReturnType<typeof setTimeout> | null} | null} */
72
+ this.helloWaiter = null
73
+ /** @type {Map<string, {epoch: number, name: string, timer: ReturnType<typeof setInterval>, leaseExpiresAt: number}>} */
74
+ this.holds = new Map()
75
+ /** @type {Map<string, {currentEpoch: number | null, epoch: number | null}>} */
76
+ this.fencedHolds = new Map()
77
+ /** @type {Map<string, {reject: (error: Error) => void, resolve: (message: Record<string, unknown>) => void, timer: ReturnType<typeof setTimeout> | null}>} */
78
+ this.pending = new Map()
79
+ /** @type {{lastError: string | null, lastProbeAt: string | null, unavailableUntil: number}} */
80
+ this.status = {lastError: null, lastProbeAt: null, unavailableUntil: 0}
81
+ this.requestCounter = 0
82
+ this.closed = false
83
+ }
84
+
85
+ /**
86
+ * Opens the connection if needed, honoring the re-probe cooldown.
87
+ * @param {string} lockName - Lock name, for error messages.
88
+ * @returns {Promise<void>} - Resolves when connected.
89
+ */
90
+ async ensureConnected(lockName) {
91
+ if (this.closed) throw new AdvisoryLockServiceUnavailableError("Lock server backend is closed", {lockName, reason: "closed"})
92
+ if (this.connection) return
93
+ if (this.connectPromise) return await this.connectPromise
94
+
95
+ if (Date.now() < this.status.unavailableUntil) {
96
+ throw new AdvisoryLockServiceUnavailableError(
97
+ `Lock server ${this.url} is marked unavailable: ${String(this.status.lastError)}`,
98
+ {lockName, reason: "cooldown"}
99
+ )
100
+ }
101
+
102
+ this.connectPromise = this.openConnection(lockName)
103
+
104
+ try {
105
+ await this.connectPromise
106
+ } finally {
107
+ this.connectPromise = null
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Opens the transport and completes the hello handshake.
113
+ * @param {string} lockName - Lock name, for error messages.
114
+ * @returns {Promise<void>} - Resolves when the handshake completed.
115
+ */
116
+ async openConnection(lockName) {
117
+ this.status.lastProbeAt = new Date().toISOString()
118
+
119
+ try {
120
+ const client = this.clientFactory({authToken: this.authToken, url: this.url})
121
+
122
+ this.client = client
123
+ await client.connect()
124
+
125
+ /** @type {Promise<void>} */
126
+ const helloPromise = new Promise((resolve, reject) => {
127
+ const timer = setTimeout(() => {
128
+ reject(new AdvisoryLockServiceUnavailableError(
129
+ `Lock server ${this.url} did not answer the handshake within ${this.connectTimeoutMs}ms`,
130
+ {lockName, reason: "handshake-timeout"}
131
+ ))
132
+ }, this.connectTimeoutMs)
133
+
134
+ timer.unref?.()
135
+ this.helloWaiter = {reject, resolve, timer}
136
+ })
137
+
138
+ const connection = client.openConnection(LOCK_SERVER_CONNECTION_TYPE, {
139
+ onMessage: (body) => this.handleMessage(body),
140
+ params: {authToken: this.authToken, protocolVersion: LOCK_SERVER_PROTOCOL_VERSION},
141
+ timeoutMs: this.connectTimeoutMs
142
+ })
143
+
144
+ this.connection = connection
145
+ await connection.ready
146
+ await helloPromise
147
+
148
+ this.status.lastError = null
149
+ this.status.unavailableUntil = 0
150
+ } catch (error) {
151
+ const normalized = error instanceof Error ? error : new Error(String(error))
152
+
153
+ this.markUnavailable(normalized)
154
+ this.teardown()
155
+
156
+ throw new AdvisoryLockServiceUnavailableError(
157
+ `Lock server ${this.url} is unavailable: ${normalized.message}`,
158
+ {lockName, reason: "connect-failed"}
159
+ )
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Waits for a lock through the server's FIFO queue.
165
+ * @param {string} name - Lock name.
166
+ * @param {{timeoutMs?: number | null}} [args] - Bounded wait; the server defaults to 30s.
167
+ * @returns {Promise<boolean>} - True when the lease was granted, false on timeout.
168
+ */
169
+ async acquire(name, {timeoutMs} = {}) {
170
+ await this.ensureConnected(name)
171
+
172
+ /** @type {LockServerAcquireResult} */
173
+ const result = /** @type {LockServerAcquireResult} */ (await this.request("acquire", {
174
+ name,
175
+ timeoutMs: typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : undefined
176
+ }, typeof timeoutMs === "number" ? timeoutMs + this.connectTimeoutMs : null))
177
+
178
+ if (result.status === "acquired") {
179
+ this.startRenewal(name, result.epoch)
180
+
181
+ return true
182
+ }
183
+
184
+ if (result.status === "busy") return false
185
+
186
+ return false
187
+ }
188
+
189
+ /**
190
+ * Takes a lock only if the server grants it immediately.
191
+ * @param {string} name - Lock name.
192
+ * @returns {Promise<boolean>} - True when granted.
193
+ */
194
+ async tryAcquire(name) {
195
+ await this.ensureConnected(name)
196
+
197
+ /** @type {LockServerAcquireResult} */
198
+ const result = /** @type {LockServerAcquireResult} */ (await this.request("try-acquire", {name}))
199
+
200
+ if (result.status !== "acquired") return false
201
+
202
+ this.startRenewal(name, result.epoch)
203
+
204
+ return true
205
+ }
206
+
207
+ /**
208
+ * Releases a held lease and stops its renewal.
209
+ * @param {string} name - Lock name.
210
+ * @returns {Promise<void>} - Resolves when released (or when the transport is gone).
211
+ */
212
+ async release(name) {
213
+ const hold = this.holds.get(name)
214
+
215
+ if (!hold) return
216
+
217
+ this.holds.delete(name)
218
+ clearInterval(hold.timer)
219
+
220
+ if (!this.connection) return
221
+
222
+ try {
223
+ await this.request("release", {epoch: hold.epoch, name})
224
+ } catch (error) {
225
+ if (!(error instanceof AdvisoryLockServiceUnavailableError)) throw error
226
+ // The server releases every lease of a closed connection, and the lease
227
+ // expires otherwise; both paths reclaim the name.
228
+ this.reportError(error)
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Releases every lease this process holds (shutdown path).
234
+ * @returns {Promise<void>} - Resolves when every lease was released.
235
+ */
236
+ async releaseAllHeld() {
237
+ for (const name of [...this.holds.keys()]) await this.release(name)
238
+ }
239
+
240
+ /**
241
+ * Runs the server's live holder list (diagnostics).
242
+ * @returns {Promise<Record<string, unknown>[]>} - Live holders.
243
+ */
244
+ async list() {
245
+ const response = await this.request("list", {})
246
+ const holders = response.holders
247
+
248
+ return /** @type {Record<string, unknown>[]} */ (Array.isArray(holders) ? holders : [])
249
+ }
250
+
251
+ /**
252
+ * Runs who-holds for one name (diagnostics).
253
+ * @param {string} name - Lock name.
254
+ * @returns {Promise<Record<string, unknown> | null>} - Holder, or null.
255
+ */
256
+ async whoHolds(name) {
257
+ const response = await this.request("who-holds", {name})
258
+ const holder = response.holder
259
+
260
+ return /** @type {Record<string, unknown> | null} */ (holder && typeof holder === "object" ? holder : null)
261
+ }
262
+
263
+ /**
264
+ * Runs the fencing epoch granted for one held lease.
265
+ * @param {string} name - Lock name.
266
+ * @returns {number | null} - Granted epoch, or null when the lease is not (or no longer) held.
267
+ */
268
+ getHoldEpoch(name) {
269
+ return this.holds.get(name)?.epoch ?? null
270
+ }
271
+
272
+ /**
273
+ * Whether this hold was fenced or lost during its critical section.
274
+ * @param {string} name - Lock name.
275
+ * @param {number | null} epoch - Epoch the hold was granted.
276
+ * @returns {{currentEpoch: number | null, epoch: number | null} | null} - Fencing details when lost.
277
+ */
278
+ takeLoss(name, epoch) {
279
+ const loss = this.fencedHolds.get(name)
280
+
281
+ if (loss) {
282
+ this.fencedHolds.delete(name)
283
+
284
+ return loss
285
+ }
286
+
287
+ const hold = this.holds.get(name)
288
+
289
+ if (hold && hold.epoch !== epoch) return {currentEpoch: hold.epoch, epoch}
290
+
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * Reports connection state and the names currently leased through this
296
+ * process, for `getAdvisoryLockBackendStatus()`.
297
+ * @returns {{connected: boolean, lastError: string | null, lastProbeAt: string | null, leasedNames: string[]}} - Backend status for diagnostics.
298
+ */
299
+ getStatus() {
300
+ return {
301
+ connected: Boolean(this.connection),
302
+ lastError: this.status.lastError,
303
+ lastProbeAt: this.status.lastProbeAt,
304
+ leasedNames: [...this.holds.keys()]
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Closes the backend: explicit release first, then the transport.
310
+ * @returns {Promise<void>} - Resolves when closed.
311
+ */
312
+ async close() {
313
+ this.closed = true
314
+ await this.releaseAllHeld()
315
+
316
+ this.teardown()
317
+ }
318
+
319
+ /**
320
+ * Dispatches one server message.
321
+ * @param {Record<string, unknown>} body - Server message.
322
+ * @returns {void}
323
+ */
324
+ handleMessage(body) {
325
+ if (!body || typeof body !== "object") return
326
+
327
+ if (body.type === "hello-ack") {
328
+ const advertisedLeaseMs = Number(body.leaseMs)
329
+
330
+ if (Number.isFinite(advertisedLeaseMs) && advertisedLeaseMs > 0) this.leaseMs = advertisedLeaseMs
331
+
332
+ const helloWaiter = this.helloWaiter
333
+
334
+ this.helloWaiter = null
335
+ if (helloWaiter) {
336
+ if (helloWaiter.timer) clearTimeout(helloWaiter.timer)
337
+ helloWaiter.resolve()
338
+ }
339
+
340
+ return
341
+ }
342
+
343
+ if (body.type === "lost") {
344
+ const name = String(body.name ?? "")
345
+ const hold = this.holds.get(name)
346
+
347
+ if (!hold) return
348
+
349
+ this.holds.delete(name)
350
+ clearInterval(hold.timer)
351
+ this.fencedHolds.set(name, {currentEpoch: Number(body.epoch ?? 0), epoch: hold.epoch})
352
+
353
+ return
354
+ }
355
+
356
+ if (body.type !== "response" || typeof body.correlationId !== "string") return
357
+
358
+ const pending = this.pending.get(body.correlationId)
359
+
360
+ if (!pending) return
361
+
362
+ this.pending.delete(body.correlationId)
363
+ if (pending.timer) clearTimeout(pending.timer)
364
+
365
+ if (body.status === "error") {
366
+ pending.reject(new Error(String(body.error || "Lock server error")))
367
+
368
+ return
369
+ }
370
+
371
+ pending.resolve(body)
372
+ }
373
+
374
+ /**
375
+ * Sends one request and awaits its correlated response.
376
+ * @param {string} type - Request type.
377
+ * @param {Record<string, unknown>} payload - Request fields.
378
+ * @param {number | null} [timeoutMs] - Response timeout; null waits for the server.
379
+ * @returns {Promise<Record<string, unknown>>} - Response body.
380
+ */
381
+ async request(type, payload, timeoutMs = null) {
382
+ const connection = this.connection
383
+
384
+ if (!connection) {
385
+ throw new AdvisoryLockServiceUnavailableError("Lock server connection is not open", {lockName: String(payload.name ?? ""), reason: "not-connected"})
386
+ }
387
+
388
+ const correlationId = `req-${++this.requestCounter}`
389
+
390
+ return await new Promise((resolve, reject) => {
391
+ const timer = typeof timeoutMs === "number" && timeoutMs > 0
392
+ ? setTimeout(() => {
393
+ this.pending.delete(correlationId)
394
+ reject(new AdvisoryLockServiceUnavailableError(
395
+ `Lock server request timed out: ${type} ${String(payload.name ?? "")}`,
396
+ {lockName: String(payload.name ?? ""), reason: "request-timeout"}
397
+ ))
398
+ }, timeoutMs)
399
+ : null
400
+
401
+ timer?.unref?.()
402
+ this.pending.set(correlationId, {reject, resolve, timer})
403
+
404
+ try {
405
+ connection.sendMessage({...payload, correlationId, type})
406
+ } catch (error) {
407
+ this.pending.delete(correlationId)
408
+ if (timer) clearTimeout(timer)
409
+ this.markUnavailable(error instanceof Error ? error : new Error(String(error)))
410
+ reject(new AdvisoryLockServiceUnavailableError(
411
+ `Lock server send failed: ${error instanceof Error ? error.message : String(error)}`,
412
+ {lockName: String(payload.name ?? ""), reason: "send-failed"}
413
+ ))
414
+ }
415
+ })
416
+ }
417
+
418
+ /**
419
+ * Starts lease renewal for a granted lock.
420
+ * @param {string} name - Lock name.
421
+ * @param {number} epoch - Granted epoch.
422
+ * @returns {void}
423
+ */
424
+ startRenewal(name, epoch) {
425
+ const existing = this.holds.get(name)
426
+
427
+ if (existing) clearInterval(existing.timer)
428
+
429
+ const intervalMs = Math.max(250, Math.floor(this.leaseMs / 3))
430
+ const timer = setInterval(() => {
431
+ void this.renew(name, epoch)
432
+ }, intervalMs)
433
+
434
+ timer.unref?.()
435
+ this.holds.set(name, {epoch, leaseExpiresAt: Date.now() + this.leaseMs, name, timer})
436
+ }
437
+
438
+ /**
439
+ * Renews one lease; a fenced lease is recorded for the runner to surface.
440
+ * @param {string} name - Lock name.
441
+ * @param {number} epoch - Epoch the hold believes it owns.
442
+ * @returns {Promise<void>} - Resolves after the renewal round-trip.
443
+ */
444
+ async renew(name, epoch) {
445
+ const hold = this.holds.get(name)
446
+
447
+ if (!hold || !this.connection) return
448
+
449
+ try {
450
+ const response = await this.request("renew", {epoch, name}, this.connectTimeoutMs * 2)
451
+ const status = String(response.status)
452
+
453
+ if (status === "renewed") {
454
+ hold.leaseExpiresAt = Number(response.leaseExpiresAt)
455
+
456
+ return
457
+ }
458
+
459
+ if (status === "lost" || status === "not-found") {
460
+ this.holds.delete(name)
461
+ clearInterval(hold.timer)
462
+ this.fencedHolds.set(name, {currentEpoch: Number(response.currentEpoch ?? 0), epoch})
463
+ }
464
+ } catch (error) {
465
+ if (!(error instanceof AdvisoryLockServiceUnavailableError)) throw error
466
+
467
+ // A lease that cannot be renewed can no longer be proven to be ours: the
468
+ // server may have fenced it while the transport was failing. Recording it
469
+ // as lost is the only honest answer, and stops the renewal loop.
470
+ this.markUnavailable(error)
471
+ this.holds.delete(name)
472
+ clearInterval(hold.timer)
473
+ this.fencedHolds.set(name, {currentEpoch: null, epoch})
474
+ this.reportError(error)
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Records that the lock server is unreachable and starts the re-probe cooldown.
480
+ * @param {Error} error - Failure.
481
+ * @returns {void}
482
+ */
483
+ markUnavailable(error) {
484
+ this.status.lastError = error.message
485
+ this.status.lastProbeAt = new Date().toISOString()
486
+ this.status.unavailableUntil = Date.now() + this.reprobeCooldownMs
487
+ }
488
+
489
+ /**
490
+ * Drops transport state and marks every outstanding hold lost.
491
+ * @returns {void}
492
+ */
493
+ teardown() {
494
+ for (const [name, hold] of this.holds) {
495
+ clearInterval(hold.timer)
496
+ this.fencedHolds.set(name, {currentEpoch: null, epoch: hold.epoch})
497
+ }
498
+
499
+ this.holds.clear()
500
+
501
+ for (const pending of this.pending.values()) {
502
+ if (pending.timer) clearTimeout(pending.timer)
503
+ pending.reject(new AdvisoryLockServiceUnavailableError("Lock server connection dropped", {lockName: "", reason: "connection-dropped"}))
504
+ }
505
+
506
+ this.pending.clear()
507
+
508
+ const connection = this.connection
509
+ const client = this.client
510
+
511
+ this.connection = null
512
+ this.client = null
513
+
514
+ try {
515
+ connection?.close?.()
516
+ } catch (error) {
517
+ this.reportError(error instanceof Error ? error : new Error(String(error)))
518
+ }
519
+
520
+ try {
521
+ void client?.close?.()
522
+ } catch (error) {
523
+ this.reportError(error instanceof Error ? error : new Error(String(error)))
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Reports a transport-side failure without breaking lock handling.
529
+ * @param {unknown} error - Failure to report.
530
+ * @returns {void}
531
+ */
532
+ reportError(error) {
533
+ const normalized = error instanceof Error ? error : new Error(String(error))
534
+
535
+ this.status.lastError = normalized.message
536
+
537
+ if (this.onError) this.onError(normalized)
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Default transport factory: the framework websocket client with the Node
543
+ * `ws` implementation bound explicitly (no global mutation).
544
+ * @param {{authToken: string, url: string}} args - Connection args.
545
+ * @returns {LockServerClientLike} - Websocket client.
546
+ */
547
+ function defaultClientFactory({url}) {
548
+ // Node's global WebSocket is not guaranteed to exist, so the implementation is
549
+ // bound explicitly instead of mutating `globalThis`.
550
+ const webSocketImplementation = /** @type {typeof globalThis.WebSocket} */ (/** @type {unknown} */ (WebSocket))
551
+
552
+ return /** @type {LockServerClientLike} */ (/** @type {unknown} */ (new VelociousWebsocketClient({
553
+ heartbeatIntervalMs: 10_000,
554
+ url,
555
+ webSocketImplementation
556
+ })))
557
+ }