velocious 1.0.626 → 1.0.628

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 (81) hide show
  1. package/README.md +15 -3
  2. package/build/background-jobs/job-runner.js +9 -1
  3. package/build/background-jobs/local-dispatcher.js +23 -1
  4. package/build/background-jobs/main.js +6 -4
  5. package/build/background-jobs/perform-job.js +10 -1
  6. package/build/background-jobs/platform-job.js +62 -0
  7. package/build/background-jobs/runtime.js +6 -3
  8. package/build/background-jobs/types.js +8 -0
  9. package/build/background-jobs/worker.js +3 -1
  10. package/build/database/pool/async-tracked-multi-connection.js +46 -9
  11. package/build/database/pool/base.js +3 -0
  12. package/build/database/pool/checkout-timeout-error.js +16 -0
  13. package/build/http-client/websocket-client.js +52 -0
  14. package/build/http-server/client/index.js +6 -0
  15. package/build/http-server/client/websocket-session.js +30 -0
  16. package/build/http-server/index.js +202 -38
  17. package/build/http-server/worker-handler/index.js +15 -1
  18. package/build/http-server/worker-handler/worker-thread.js +8 -0
  19. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  20. package/build/src/background-jobs/job-runner.js +10 -2
  21. package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -1
  22. package/build/src/background-jobs/local-dispatcher.js +24 -2
  23. package/build/src/background-jobs/main.d.ts.map +1 -1
  24. package/build/src/background-jobs/main.js +7 -5
  25. package/build/src/background-jobs/perform-job.d.ts +5 -1
  26. package/build/src/background-jobs/perform-job.d.ts.map +1 -1
  27. package/build/src/background-jobs/perform-job.js +11 -2
  28. package/build/src/background-jobs/platform-job.d.ts +34 -0
  29. package/build/src/background-jobs/platform-job.d.ts.map +1 -1
  30. package/build/src/background-jobs/platform-job.js +56 -1
  31. package/build/src/background-jobs/runtime.d.ts.map +1 -1
  32. package/build/src/background-jobs/runtime.js +7 -4
  33. package/build/src/background-jobs/types.d.ts +30 -0
  34. package/build/src/background-jobs/types.d.ts.map +1 -1
  35. package/build/src/background-jobs/types.js +9 -1
  36. package/build/src/background-jobs/worker.d.ts.map +1 -1
  37. package/build/src/background-jobs/worker.js +4 -2
  38. package/build/src/database/pool/async-tracked-multi-connection.d.ts +22 -2
  39. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  40. package/build/src/database/pool/async-tracked-multi-connection.js +44 -10
  41. package/build/src/database/pool/base.d.ts +15 -0
  42. package/build/src/database/pool/base.d.ts.map +1 -1
  43. package/build/src/database/pool/base.js +4 -1
  44. package/build/src/database/pool/checkout-timeout-error.d.ts +13 -0
  45. package/build/src/database/pool/checkout-timeout-error.d.ts.map +1 -0
  46. package/build/src/database/pool/checkout-timeout-error.js +15 -0
  47. package/build/src/http-client/websocket-client.d.ts +18 -0
  48. package/build/src/http-client/websocket-client.d.ts.map +1 -1
  49. package/build/src/http-client/websocket-client.js +48 -1
  50. package/build/src/http-server/client/index.d.ts.map +1 -1
  51. package/build/src/http-server/client/index.js +7 -1
  52. package/build/src/http-server/client/websocket-session.d.ts +6 -0
  53. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  54. package/build/src/http-server/client/websocket-session.js +27 -1
  55. package/build/src/http-server/index.d.ts +82 -10
  56. package/build/src/http-server/index.d.ts.map +1 -1
  57. package/build/src/http-server/index.js +186 -33
  58. package/build/src/http-server/worker-handler/index.d.ts +28 -1
  59. package/build/src/http-server/worker-handler/index.d.ts.map +1 -1
  60. package/build/src/http-server/worker-handler/index.js +23 -2
  61. package/build/src/http-server/worker-handler/worker-thread.d.ts.map +1 -1
  62. package/build/src/http-server/worker-handler/worker-thread.js +7 -1
  63. package/build/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +1 -1
  65. package/src/background-jobs/job-runner.js +9 -1
  66. package/src/background-jobs/local-dispatcher.js +23 -1
  67. package/src/background-jobs/main.js +6 -4
  68. package/src/background-jobs/perform-job.js +10 -1
  69. package/src/background-jobs/platform-job.js +62 -0
  70. package/src/background-jobs/runtime.js +6 -3
  71. package/src/background-jobs/types.js +8 -0
  72. package/src/background-jobs/worker.js +3 -1
  73. package/src/database/pool/async-tracked-multi-connection.js +46 -9
  74. package/src/database/pool/base.js +3 -0
  75. package/src/database/pool/checkout-timeout-error.js +16 -0
  76. package/src/http-client/websocket-client.js +52 -0
  77. package/src/http-server/client/index.js +6 -0
  78. package/src/http-server/client/websocket-session.js +30 -0
  79. package/src/http-server/index.js +202 -38
  80. package/src/http-server/worker-handler/index.js +15 -1
  81. package/src/http-server/worker-handler/worker-thread.js +8 -0
@@ -40,10 +40,13 @@ import sha256Hex from "../../utils/sha256-hex.js"
40
40
  * @property {Array<Record<string, ReturnType<typeof JSON.parse>>>} connections - Live connection snapshots.
41
41
  * @property {number} connectionsBeingSpawned - Number of in-progress connection spawns.
42
42
  * @property {number} idleCount - Number of idle connections.
43
+ * @property {number} [idleMatchingPendingCheckoutCount] - Idle connections that can satisfy at least one pending checkout.
43
44
  * @property {string} identifier - Database identifier.
44
45
  * @property {number} inUseCount - Number of checked-out connections.
45
46
  * @property {Array<DatabasePoolPendingCheckoutDebugSnapshot>} [pendingCheckouts] - Waiting checkout snapshots.
46
47
  * @property {number} pendingCheckoutCount - Number of queued checkout requests.
48
+ * @property {boolean} [pendingCheckoutDrainActive] - Whether a checkout drain pass is active.
49
+ * @property {boolean} [pendingCheckoutDrainRequested] - Whether another checkout drain pass was requested.
47
50
  * @property {string} poolClass - Pool class name.
48
51
  * @property {{connectionCreationCount: number, connectionCreationFailureCount: number, connectionCreationMaxMs: number, connectionCreationTotalMs: number, checkoutTimeoutCount: number, checkoutWaitCount: number, checkoutWaitMaxMs: number, checkoutWaitTotalMs: number, idleReapCount: number, idleReapDisposalCount: number, idleReapFailureCount: number, idleReapMaxMs: number, idleReapTotalMs: number, peakLiveConnections: number}} [telemetry] - Cumulative pool lifecycle telemetry.
49
52
  */
@@ -0,0 +1,16 @@
1
+ // @ts-check
2
+
3
+ /** Stable framework error for a database pool checkout that exceeded its wait limit. */
4
+ export default class DatabasePoolCheckoutTimeoutError extends Error {
5
+ /**
6
+ * Builds a database pool checkout timeout error.
7
+ * @param {string} message - Detailed sanitized pool timeout message.
8
+ * @param {{cause?: unknown}} [args] - Error options.
9
+ */
10
+ constructor(message, {cause} = {}) {
11
+ super(message, cause === undefined ? undefined : {cause})
12
+
13
+ this.name = "DatabasePoolCheckoutTimeoutError"
14
+ this.code = "VELOCIOUS_DATABASE_POOL_CHECKOUT_TIMEOUT"
15
+ }
16
+ }
@@ -4,6 +4,7 @@ import SnapReqWebSocketClient from "snapreq/websocket"
4
4
  import {deserializeFrontendModelTransportValue} from "../frontend-models/transport-serialization.js"
5
5
 
6
6
  const DEFAULT_URL = "ws://127.0.0.1:3006/websocket"
7
+ const SESSION_ROUTING_PARAMETER = "velociousSessionId"
7
8
 
8
9
  /**
9
10
  * Velocious's WebSocket client. The cross-platform connection/session/channel
@@ -28,6 +29,57 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
28
29
  this.runningReconnectTasks = new Set()
29
30
  /** @type {Promise<void> | null} */
30
31
  this.gracefulClosePromise = null
32
+ this.routingBaseUrl = this.url
33
+ }
34
+
35
+ /**
36
+ * Restores a persisted session before opening the socket so the host can route
37
+ * the HTTP upgrade to the worker that owns its paused state.
38
+ * @returns {Promise<void>}
39
+ */
40
+ async _restoreSessionIdForRouting() {
41
+ // SnapReq initializes these internal session fields in its constructor, but
42
+ // its declaration does not expose that definite-assignment lifecycle here.
43
+ const routingState = /** @type {{_sessionId: string | null, _sessionStore: {get: () => string | null | undefined | Promise<string | null | undefined>} | undefined, _sessionStoreRestored: boolean}} */ (/** @type {unknown} */ (this))
44
+
45
+ if (routingState._sessionId || routingState._sessionStoreRestored || !routingState._sessionStore) return
46
+
47
+ routingState._sessionStoreRestored = true
48
+
49
+ try {
50
+ const storedId = await routingState._sessionStore.get()
51
+
52
+ if (typeof storedId === "string" && storedId.length > 0) routingState._sessionId = storedId
53
+ } catch (error) {
54
+ this._debug("sessionStore.get failed", error)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Builds the WebSocket URL carrying only the current resumable session routing hint.
60
+ * @returns {string} - WebSocket URL.
61
+ */
62
+ _sessionRoutingUrl() {
63
+ const url = new URL(this.routingBaseUrl)
64
+
65
+ if (this._sessionId) {
66
+ url.searchParams.set(SESSION_ROUTING_PARAMETER, this._sessionId)
67
+ } else {
68
+ url.searchParams.delete(SESSION_ROUTING_PARAMETER)
69
+ }
70
+
71
+ return url.toString()
72
+ }
73
+
74
+ /**
75
+ * Restores routing state before delegating socket creation to SnapReq.
76
+ * @param {Parameters<SnapReqWebSocketClient["_connect"]>[0]} [options] - Connect options.
77
+ * @returns {Promise<void>} - Resolves when the session is ready.
78
+ */
79
+ async _connect(options) {
80
+ await this._restoreSessionIdForRouting()
81
+ this.url = this._sessionRoutingUrl()
82
+ await super._connect(options)
31
83
  }
32
84
 
33
85
  /**
@@ -314,6 +314,12 @@ export default class VeoliciousHttpServerClient {
314
314
  this.websocketSession = undefined
315
315
  this.events.emit("close")
316
316
  })
317
+ this.websocketSession.events.on("ownershipClaimed", ({sessionId}) => {
318
+ this.events.emit("websocketSessionOwned", {sessionId})
319
+ })
320
+ this.websocketSession.events.on("ownershipReleased", ({sessionId}) => {
321
+ this.events.emit("websocketSessionReleased", {sessionId})
322
+ })
317
323
  this.state = "websocket"
318
324
  this.events.emit("output", response)
319
325
  void this.websocketSession.initializeChannel()
@@ -215,6 +215,9 @@ export default class VelociousHttpServerClientWebsocketSession {
215
215
  */
216
216
  this._resumeIdentityPromise = undefined
217
217
 
218
+ /** @type {string | null} */
219
+ this._claimedSessionId = null
220
+
218
221
  /**
219
222
  * Accumulates payloads for a fragmented websocket message per
220
223
  * RFC 6455. Non-null while mid-fragment; cleared when the frame
@@ -266,6 +269,7 @@ export default class VelociousHttpServerClientWebsocketSession {
266
269
  * @returns {void}
267
270
  */
268
271
  sendSessionEstablished() {
272
+ this._claimOwnership()
269
273
  this.sendJson({
270
274
  type: "session-established",
271
275
  sessionId: this.sessionId,
@@ -313,6 +317,7 @@ export default class VelociousHttpServerClientWebsocketSession {
313
317
  }
314
318
 
315
319
  destroy() {
320
+ this._releaseOwnership()
316
321
  this._stopHeartbeat()
317
322
  this._resetFragmentBuffer()
318
323
  this._clearBufferedFrameChunks()
@@ -325,6 +330,25 @@ export default class VelociousHttpServerClientWebsocketSession {
325
330
  this.events.removeAllListeners()
326
331
  }
327
332
 
333
+ /** Claims this session id for host-side reconnect routing. */
334
+ _claimOwnership() {
335
+ if (this._claimedSessionId === this.sessionId) return
336
+ if (this._claimedSessionId) this._releaseOwnership()
337
+
338
+ this._claimedSessionId = this.sessionId
339
+ this.events.emit("ownershipClaimed", {sessionId: this.sessionId})
340
+ }
341
+
342
+ /** Releases the currently claimed session id exactly once. */
343
+ _releaseOwnership() {
344
+ const sessionId = this._claimedSessionId
345
+
346
+ if (!sessionId) return
347
+
348
+ this._claimedSessionId = null
349
+ this.events.emit("ownershipReleased", {sessionId})
350
+ }
351
+
328
352
  /**
329
353
  * Runs has subscription.
330
354
  * @param {string} channel - Channel name.
@@ -1339,6 +1363,7 @@ export default class VelociousHttpServerClientWebsocketSession {
1339
1363
  }
1340
1364
 
1341
1365
  this._stopHeartbeat()
1366
+ this._releaseOwnership()
1342
1367
  this.configuration._websocketSessions.delete(this)
1343
1368
  void this._runMessageHandlerClose()
1344
1369
  void this._teardownChannel()
@@ -1355,6 +1380,7 @@ export default class VelociousHttpServerClientWebsocketSession {
1355
1380
  */
1356
1381
  _finalizeGraceExpiry() {
1357
1382
  this._stopHeartbeat()
1383
+ this._releaseOwnership()
1358
1384
  this._resetFragmentBuffer()
1359
1385
  this._clearBufferedFrameChunks()
1360
1386
  this._abandonInboundMessages()
@@ -1499,6 +1525,9 @@ export default class VelociousHttpServerClientWebsocketSession {
1499
1525
 
1500
1526
  this.configuration._clearPausedWebsocketSession(resumeSessionId)
1501
1527
 
1528
+ this._releaseOwnership()
1529
+ paused._releaseOwnership()
1530
+
1502
1531
  // Transfer resumable state onto this (live) session. The paused
1503
1532
  // session shell is discarded after the transfer.
1504
1533
  for (const [connectionId, connection] of paused._connections) {
@@ -1526,6 +1555,7 @@ export default class VelociousHttpServerClientWebsocketSession {
1526
1555
  paused._paused = false
1527
1556
  paused.destroy()
1528
1557
 
1558
+ this._claimOwnership()
1529
1559
  this.sendJson({type: "session-resumed", sessionId: resumeSessionId})
1530
1560
  for (const body of queued) this.sendJson(body)
1531
1561
  await this._fireOnResume()
@@ -6,6 +6,7 @@ import EventEmitter from "../utils/event-emitter.js"
6
6
  import InProcessHandler from "./worker-handler/in-process.js"
7
7
  import Logger from "../logger.js"
8
8
  import Net from "net"
9
+ import os from "node:os"
9
10
  import ServerClient from "./server-client.js"
10
11
  import WorkerHandler from "./worker-handler/index.js"
11
12
 
@@ -14,17 +15,18 @@ import WorkerHandler from "./worker-handler/index.js"
14
15
  * @typedef {{start: () => Promise<void>, stop: () => Promise<void>}} DevelopmentReloaderLike */
15
16
  /**
16
17
  * Defines this typedef.
17
- * @typedef {(args: {configuration: import("../configuration.js").default, workerCount: number}) => (WorkerHandler | InProcessHandler)} WorkerHandlerFactory */
18
+ * @typedef {(args: {configuration: import("../configuration.js").default, onWebsocketSessionOwned: (args: {sessionId: string, workerHandler: WorkerHandler}) => void, onWebsocketSessionReleased: (args: {sessionId: string, workerHandler: WorkerHandler}) => void, onWorkerStopped: (args: {workerHandler: WorkerHandler}) => void, workerCount: number}) => (WorkerHandler | InProcessHandler)} WorkerHandlerFactory */
18
19
 
19
20
  /**
20
21
  * Runs normalize worker count.
21
22
  * @param {object} args - Options object.
22
23
  * @param {number} [args.maxWorkers] - Backward-compatible worker count alias.
23
24
  * @param {number} [args.workers] - Configured worker count.
25
+ * @param {number} args.defaultWorkerCount - Process-available CPU count.
24
26
  * @returns {number} - Normalized worker count.
25
27
  */
26
- function normalizeWorkerCount({maxWorkers, workers}) {
27
- const workerCount = workers ?? maxWorkers ?? 1
28
+ function normalizeWorkerCount({defaultWorkerCount, maxWorkers, workers}) {
29
+ const workerCount = workers ?? maxWorkers ?? defaultWorkerCount
28
30
 
29
31
  if (!Number.isInteger(workerCount) || workerCount < 1) {
30
32
  throw new Error("HTTP server workers must be a positive integer")
@@ -33,6 +35,9 @@ function normalizeWorkerCount({maxWorkers, workers}) {
33
35
  return workerCount
34
36
  }
35
37
 
38
+ const MAX_INITIAL_REQUEST_HEADER_BYTES = 64 * 1024
39
+ const WEBSOCKET_SESSION_ROUTING_PARAMETER = "velociousSessionId"
40
+
36
41
  export default class VelociousHttpServer {
37
42
  clientCount = 0
38
43
  _starting = false
@@ -70,10 +75,8 @@ export default class VelociousHttpServer {
70
75
  * @type {Array<WorkerHandler | InProcessHandler>} */
71
76
  workerHandlers = []
72
77
  nextWorkerHandlerIndex = 0
73
- /**
74
- * Sticky worker handlers.
75
- * @type {Map<string, WorkerHandler | InProcessHandler>} */
76
- stickyWorkerHandlers = new Map()
78
+ /** Worker ownership for live or grace-paused resumable WebSocket sessions. */
79
+ websocketSessionOwners = new Map()
77
80
 
78
81
  /**
79
82
  * Runs constructor.
@@ -84,10 +87,11 @@ export default class VelociousHttpServer {
84
87
  * @param {number} [args.port] - Port.
85
88
  * @param {number} [args.maxWorkers] - Max workers.
86
89
  * @param {number} [args.workers] - Worker handlers to start.
90
+ * @param {() => number} [args.availableParallelism] - CPU availability owner seam.
87
91
  * @param {(args: {configuration: import("../configuration.js").default, onReload: (args: {changedPath: string}) => Promise<void>}) => {start: () => Promise<void>, stop: () => Promise<void>}} [args.developmentReloaderFactory] - Development reloader factory.
88
92
  * @param {WorkerHandlerFactory} [args.workerHandlerFactory] - Worker handler factory.
89
93
  */
90
- constructor({configuration, developmentReloaderFactory, host, inProcess, maxWorkers, port, workerHandlerFactory, workers}) {
94
+ constructor({availableParallelism = os.availableParallelism, configuration, developmentReloaderFactory, host, inProcess, maxWorkers, port, workerHandlerFactory, workers}) {
91
95
  this.configuration = configuration
92
96
  this.developmentReloaderFactory = developmentReloaderFactory
93
97
  this.workerHandlerFactory = workerHandlerFactory
@@ -95,7 +99,8 @@ export default class VelociousHttpServer {
95
99
  this.logger = new Logger(this)
96
100
  this.host = host ?? "0.0.0.0"
97
101
  this.port = port ?? 3006
98
- this.workers = normalizeWorkerCount({maxWorkers, workers})
102
+ this.workers = normalizeWorkerCount({defaultWorkerCount: availableParallelism(), maxWorkers, workers})
103
+ this.effectiveWorkers = this.inProcess && workers === undefined && maxWorkers === undefined ? 1 : this.workers
99
104
  }
100
105
 
101
106
  /**
@@ -167,7 +172,7 @@ export default class VelociousHttpServer {
167
172
  this.developmentReloader = startupState.developmentReloader
168
173
  this.netServer = startupState.netServer
169
174
  this.workerHandlers = startupState.workerHandlers
170
- this.stickyWorkerHandlers.clear()
175
+ this.websocketSessionOwners.clear()
171
176
  }
172
177
 
173
178
  /**
@@ -205,7 +210,7 @@ export default class VelociousHttpServer {
205
210
  * @returns {Promise<void>} - Resolves when complete.
206
211
  */
207
212
  async _ensureWorkers() {
208
- while (this.workerHandlers.length < this.workers) {
213
+ while (this.workerHandlers.length < this.effectiveWorkers) {
209
214
  await this.spawnWorker()
210
215
  }
211
216
  }
@@ -232,6 +237,7 @@ export default class VelociousHttpServer {
232
237
  activeSocketCount: this._activeSockets.size,
233
238
  clientCount: Object.keys(this.clients).length,
234
239
  configuredWorkerCount: this.workers,
240
+ effectiveWorkerCount: this.effectiveWorkers,
235
241
  inProcess: this.inProcess,
236
242
  workerCount: this.workerHandlers.length,
237
243
  workers: await Promise.all(this.workerHandlers.map((handler) => this.workerDebugSnapshot(handler)))
@@ -327,7 +333,7 @@ export default class VelociousHttpServer {
327
333
  const stopTasks = this.workerHandlers.map((handler) => handler.stop())
328
334
  await Promise.all(stopTasks)
329
335
  this.workerHandlers = []
330
- this.stickyWorkerHandlers.clear()
336
+ this.websocketSessionOwners.clear()
331
337
  }
332
338
 
333
339
  /**
@@ -367,9 +373,6 @@ export default class VelociousHttpServer {
367
373
  this.clientCount++
368
374
 
369
375
  try {
370
- // Paused WebSocket sessions are worker-local, so reconnects from
371
- // the same client address must return to the same worker.
372
- const workerHandler = this.workerHandlerToUse({stickyKey: socket.remoteAddress})
373
376
  const client = new ServerClient({
374
377
  clientCount,
375
378
  configuration: this.configuration,
@@ -377,16 +380,184 @@ export default class VelociousHttpServer {
377
380
  })
378
381
 
379
382
  client.events.on("close", this.onClientClose)
380
-
381
- this.logger.debug(`Gave client ${clientCount} to worker ${workerHandler.workerCount}`)
382
- workerHandler.addSocketConnection(client)
383
383
  this.clients[clientCount] = client
384
+ this.routeClientAfterInitialRoutingData(client)
384
385
  } catch (error) {
385
386
  this.logger.error(`Failed to initialize client ${clientCount} on new connection`, error)
386
387
  socket.destroy()
387
388
  }
388
389
  }
389
390
 
391
+ /**
392
+ * Buffers only the bounded initial request data needed to recognize a
393
+ * WebSocket resume routing hint, then replays it to the selected worker.
394
+ * @param {ServerClient} client - Unassigned socket client.
395
+ * @returns {void}
396
+ */
397
+ routeClientAfterInitialRoutingData(client) {
398
+ const {socket} = client
399
+ /** @type {Buffer[]} */
400
+ const chunks = []
401
+ let byteLength = 0
402
+ const cleanup = () => {
403
+ socket.off("data", onData)
404
+ socket.off("close", cleanup)
405
+ }
406
+ const onData = (/** @type {Buffer} */ chunk) => {
407
+ chunks.push(chunk)
408
+ byteLength += chunk.length
409
+ const initialRequest = Buffer.concat(chunks, byteLength)
410
+
411
+ if (this.initialRequestNeedsMoreRoutingData(initialRequest) && byteLength < MAX_INITIAL_REQUEST_HEADER_BYTES) return
412
+
413
+ cleanup()
414
+ this.assignClientToWorker(client, initialRequest)
415
+ }
416
+
417
+ socket.on("data", onData)
418
+ socket.once("close", cleanup)
419
+ }
420
+
421
+ /**
422
+ * Checks whether the buffered initial HTTP headers are complete.
423
+ * @param {Buffer} initialRequest - Buffered initial request bytes.
424
+ * @returns {boolean} - Whether a header terminator is present.
425
+ */
426
+ initialRequestHeadersComplete(initialRequest) {
427
+ return initialRequest.includes("\r\n\r\n") || initialRequest.includes("\n\n")
428
+ }
429
+
430
+ /**
431
+ * Checks whether a possible resumable WebSocket request still needs headers.
432
+ * Ordinary and malformed requests can reach the existing request parser as
433
+ * soon as their first line is complete.
434
+ * @param {Buffer} initialRequest - Buffered initial request bytes.
435
+ * @returns {boolean} - Whether more routing data is required.
436
+ */
437
+ initialRequestNeedsMoreRoutingData(initialRequest) {
438
+ if (this.initialRequestHeadersComplete(initialRequest)) return false
439
+
440
+ const lineEnd = initialRequest.indexOf("\n")
441
+
442
+ if (lineEnd === -1) return true
443
+
444
+ const requestLine = initialRequest.subarray(0, lineEnd).toString("latin1").replace(/\r$/, "")
445
+ const requestLineMatch = requestLine.match(/^GET ([^ ]+) HTTP\/[^ ]+$/)
446
+
447
+ if (!requestLineMatch) return false
448
+
449
+ try {
450
+ const requestUrl = new URL(requestLineMatch[1], "http://velocious.invalid")
451
+
452
+ return requestUrl.searchParams.has(WEBSOCKET_SESSION_ROUTING_PARAMETER)
453
+ } catch {
454
+ return false
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Assigns a buffered client and replays the exact bytes into its worker.
460
+ * @param {ServerClient} client - Client awaiting assignment.
461
+ * @param {Buffer} initialRequest - Initial request bytes.
462
+ * @returns {void}
463
+ */
464
+ assignClientToWorker(client, initialRequest) {
465
+ if (client.socket.destroyed) return
466
+
467
+ try {
468
+ const workerHandler = this.workerHandlerForInitialRequest(initialRequest)
469
+
470
+ this.logger.debug(`Gave client ${client.clientCount} to worker ${workerHandler.workerCount}`)
471
+ workerHandler.addSocketConnection(client)
472
+ client.onSocketData(initialRequest)
473
+ } catch (error) {
474
+ this.logger.error(`Failed to assign client ${client.clientCount} to a worker`, error)
475
+ client.destroy(error instanceof Error ? error : new Error("Failed to assign HTTP client to worker", {cause: error}))
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Selects the owner of a resumable WebSocket session or the next ordinary worker.
481
+ * @param {Buffer} initialRequest - Initial HTTP request headers.
482
+ * @returns {WorkerHandler | InProcessHandler} - Selected worker.
483
+ */
484
+ workerHandlerForInitialRequest(initialRequest) {
485
+ const sessionId = this.websocketResumeSessionId(initialRequest)
486
+
487
+ if (sessionId) {
488
+ const owner = this.websocketSessionOwners.get(sessionId)
489
+
490
+ if (owner && this.workerHandlers.includes(owner)) return owner
491
+ if (owner) this.websocketSessionOwners.delete(sessionId)
492
+ }
493
+
494
+ return this.workerHandlerToUse()
495
+ }
496
+
497
+ /**
498
+ * Reads the resumable WebSocket session routing hint from an upgrade request.
499
+ * @param {Buffer} initialRequest - Initial HTTP request headers.
500
+ * @returns {string | undefined} - Session identity, if present on a WebSocket upgrade.
501
+ */
502
+ websocketResumeSessionId(initialRequest) {
503
+ const headerEnd = initialRequest.indexOf("\r\n\r\n")
504
+ const fallbackHeaderEnd = headerEnd === -1 ? initialRequest.indexOf("\n\n") : headerEnd
505
+
506
+ if (fallbackHeaderEnd === -1) return
507
+
508
+ const lines = initialRequest.subarray(0, fallbackHeaderEnd).toString("latin1").split(/\r?\n/)
509
+ const [method, requestTarget] = lines[0]?.split(" ") || []
510
+
511
+ if (method !== "GET" || !requestTarget) return
512
+
513
+ /** @type {Map<string, string>} */
514
+ const headers = new Map()
515
+
516
+ for (const line of lines.slice(1)) {
517
+ const separatorIndex = line.indexOf(":")
518
+
519
+ if (separatorIndex === -1) continue
520
+
521
+ headers.set(line.slice(0, separatorIndex).trim().toLowerCase(), line.slice(separatorIndex + 1).trim().toLowerCase())
522
+ }
523
+
524
+ if (headers.get("upgrade") !== "websocket" || !headers.get("connection")?.split(",").map((value) => value.trim()).includes("upgrade")) return
525
+
526
+ const sessionId = new URL(requestTarget, "http://velocious.invalid").searchParams.get(WEBSOCKET_SESSION_ROUTING_PARAMETER)
527
+
528
+ return sessionId || undefined
529
+ }
530
+
531
+ /**
532
+ * Records the live worker owner for a resumable session.
533
+ * @param {{sessionId: string, workerHandler: WorkerHandler | InProcessHandler}} args - Ownership claim.
534
+ * @returns {void}
535
+ */
536
+ claimWebsocketSession({sessionId, workerHandler}) {
537
+ if (!this.workerHandlers.includes(workerHandler)) return
538
+ this.websocketSessionOwners.set(sessionId, workerHandler)
539
+ }
540
+
541
+ /**
542
+ * Releases a session only when the releasing worker still owns it.
543
+ * @param {{sessionId: string, workerHandler: WorkerHandler | InProcessHandler}} args - Ownership release.
544
+ * @returns {void}
545
+ */
546
+ releaseWebsocketSession({sessionId, workerHandler}) {
547
+ if (this.websocketSessionOwners.get(sessionId) === workerHandler) this.websocketSessionOwners.delete(sessionId)
548
+ }
549
+
550
+ /**
551
+ * Releases every session owned by a worker leaving service.
552
+ * @param {WorkerHandler | InProcessHandler} workerHandler - Worker leaving service.
553
+ * @returns {void}
554
+ */
555
+ releaseWebsocketSessionsForWorker(workerHandler) {
556
+ for (const [sessionId, owner] of this.websocketSessionOwners) {
557
+ if (owner === workerHandler) this.websocketSessionOwners.delete(sessionId)
558
+ }
559
+ }
560
+
390
561
  /**
391
562
  * On client close.
392
563
  * @param {ServerClient} client - Client instance.
@@ -425,7 +596,7 @@ export default class VelociousHttpServer {
425
596
  * @type {Array<WorkerHandler | InProcessHandler>} */
426
597
  const workerHandlers = []
427
598
 
428
- for (let index = 0; index < this.workers; index += 1) {
599
+ for (let index = 0; index < this.effectiveWorkers; index += 1) {
429
600
  workerHandlers.push(await this._buildWorkerHandler())
430
601
  }
431
602
 
@@ -443,9 +614,18 @@ export default class VelociousHttpServer {
443
614
 
444
615
  const Handler = this.inProcess ? InProcessHandler : WorkerHandler
445
616
  const workerHandler = this.workerHandlerFactory
446
- ? this.workerHandlerFactory({configuration: this.configuration, workerCount})
617
+ ? this.workerHandlerFactory({
618
+ configuration: this.configuration,
619
+ onWebsocketSessionOwned: ({sessionId, workerHandler}) => this.claimWebsocketSession({sessionId, workerHandler}),
620
+ onWebsocketSessionReleased: ({sessionId, workerHandler}) => this.releaseWebsocketSession({sessionId, workerHandler}),
621
+ onWorkerStopped: ({workerHandler}) => this.releaseWebsocketSessionsForWorker(workerHandler),
622
+ workerCount
623
+ })
447
624
  : new Handler({
448
625
  configuration: this.configuration,
626
+ onWebsocketSessionOwned: ({sessionId, workerHandler}) => this.claimWebsocketSession({sessionId, workerHandler}),
627
+ onWebsocketSessionReleased: ({sessionId, workerHandler}) => this.releaseWebsocketSession({sessionId, workerHandler}),
628
+ onWorkerStopped: ({workerHandler}) => this.releaseWebsocketSessionsForWorker(workerHandler),
449
629
  workerCount
450
630
  })
451
631
 
@@ -456,25 +636,9 @@ export default class VelociousHttpServer {
456
636
 
457
637
  /**
458
638
  * Runs worker handler to use.
459
- * @param {object} [args] - Options object.
460
- * @param {string} [args.stickyKey] - Stable key that must keep routing to the same worker.
461
639
  * @returns {WorkerHandler | InProcessHandler} - The worker handler to use.
462
640
  */
463
- workerHandlerToUse({stickyKey} = {}) {
464
- if (stickyKey) {
465
- const stickyWorkerHandler = this.stickyWorkerHandlers.get(stickyKey)
466
-
467
- if (stickyWorkerHandler && this.workerHandlers.includes(stickyWorkerHandler)) {
468
- return stickyWorkerHandler
469
- }
470
-
471
- const workerHandler = this._nextRoundRobinWorkerHandler()
472
-
473
- this.stickyWorkerHandlers.set(stickyKey, workerHandler)
474
-
475
- return workerHandler
476
- }
477
-
641
+ workerHandlerToUse() {
478
642
  return this._nextRoundRobinWorkerHandler()
479
643
  }
480
644
 
@@ -550,7 +714,7 @@ export default class VelociousHttpServer {
550
714
 
551
715
  this.workerHandlers = newWorkerHandlers
552
716
  this.nextWorkerHandlerIndex = 0
553
- this.stickyWorkerHandlers.clear()
717
+ for (const workerHandler of oldWorkerHandlers) this.releaseWebsocketSessionsForWorker(workerHandler)
554
718
 
555
719
  await Promise.all(oldWorkerHandlers.map((workerHandler) => workerHandler.stop()))
556
720
  } while (this._reloadWorkersForDevelopmentQueued && !this._stopping)
@@ -41,8 +41,11 @@ export default class VelociousHttpServerWorker {
41
41
  * @param {object} args - Options object.
42
42
  * @param {import("../../configuration.js").default} args.configuration - Configuration instance.
43
43
  * @param {number} args.workerCount - Worker count.
44
+ * @param {(args: {sessionId: string, workerHandler: VelociousHttpServerWorker}) => void} [args.onWebsocketSessionOwned] - Session ownership callback.
45
+ * @param {(args: {sessionId: string, workerHandler: VelociousHttpServerWorker}) => void} [args.onWebsocketSessionReleased] - Session ownership release callback.
46
+ * @param {(args: {workerHandler: VelociousHttpServerWorker}) => void} [args.onWorkerStopped] - Worker lifecycle callback.
44
47
  */
45
- constructor({configuration, workerCount}) {
48
+ constructor({configuration, onWebsocketSessionOwned, onWebsocketSessionReleased, onWorkerStopped, workerCount}) {
46
49
  this.configuration = configuration
47
50
 
48
51
  /**
@@ -52,6 +55,9 @@ export default class VelociousHttpServerWorker {
52
55
 
53
56
  this.logger = new Logger(this)
54
57
  this.workerCount = workerCount
58
+ this.onWebsocketSessionOwned = onWebsocketSessionOwned
59
+ this.onWebsocketSessionReleased = onWebsocketSessionReleased
60
+ this.onWorkerStopped = onWorkerStopped
55
61
  this.workerStarted = false
56
62
  this._stopping = false
57
63
  this._debugRequestId = 0
@@ -152,6 +158,7 @@ export default class VelociousHttpServerWorker {
152
158
  }
153
159
 
154
160
  this.unregisterFromEventsHostIfNeeded()
161
+ if (this.onWorkerStopped) this.onWorkerStopped({workerHandler: this})
155
162
  if (this._stopResolve) {
156
163
  this._stopResolve()
157
164
  }
@@ -190,6 +197,7 @@ export default class VelociousHttpServerWorker {
190
197
  * @param {number} [data.transferId] - File transfer id.
191
198
  * @param {boolean} [data.websocketFrame] - Whether output is a completed WebSocket frame.
192
199
  * @param {string} [data.channel] - Channel name.
200
+ * @param {string} [data.sessionId] - WebSocket session identity.
193
201
  * @param {number} [data.requestId] - Debug request id.
194
202
  * @param {Record<string, ReturnType<typeof JSON.parse>>} [data.snapshot] - Worker debug snapshot.
195
203
  * @param {ReturnType<typeof JSON.parse>} [data.payload] - Payload data.
@@ -306,6 +314,12 @@ export default class VelociousHttpServerWorker {
306
314
  channel,
307
315
  configuration: this.configuration
308
316
  })
317
+ } else if (command == "websocketSessionOwned") {
318
+ if (typeof data.sessionId !== "string") throw new Error("Worker websocket session id must be a string")
319
+ if (this.onWebsocketSessionOwned) this.onWebsocketSessionOwned({sessionId: data.sessionId, workerHandler: this})
320
+ } else if (command == "websocketSessionReleased") {
321
+ if (typeof data.sessionId !== "string") throw new Error("Worker websocket session id must be a string")
322
+ if (this.onWebsocketSessionReleased) this.onWebsocketSessionReleased({sessionId: data.sessionId, workerHandler: this})
309
323
  } else {
310
324
  throw new Error(`Unknown command: ${command}`)
311
325
  }
@@ -170,6 +170,14 @@ export default class VelociousHttpServerWorkerHandlerWorkerThread {
170
170
  this.parentPort.postMessage({command: "clientClose", clientCount, output})
171
171
  })
172
172
 
173
+ client.events.on("websocketSessionOwned", ({sessionId}) => {
174
+ this.parentPort.postMessage({command: "websocketSessionOwned", sessionId})
175
+ })
176
+
177
+ client.events.on("websocketSessionReleased", ({sessionId}) => {
178
+ this.parentPort.postMessage({command: "websocketSessionReleased", sessionId})
179
+ })
180
+
173
181
  this.clients[clientCount] = client
174
182
  }
175
183