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.
- package/README.md +15 -3
- package/build/background-jobs/job-runner.js +9 -1
- package/build/background-jobs/local-dispatcher.js +23 -1
- package/build/background-jobs/main.js +6 -4
- package/build/background-jobs/perform-job.js +10 -1
- package/build/background-jobs/platform-job.js +62 -0
- package/build/background-jobs/runtime.js +6 -3
- package/build/background-jobs/types.js +8 -0
- package/build/background-jobs/worker.js +3 -1
- package/build/database/pool/async-tracked-multi-connection.js +46 -9
- package/build/database/pool/base.js +3 -0
- package/build/database/pool/checkout-timeout-error.js +16 -0
- package/build/http-client/websocket-client.js +52 -0
- package/build/http-server/client/index.js +6 -0
- package/build/http-server/client/websocket-session.js +30 -0
- package/build/http-server/index.js +202 -38
- package/build/http-server/worker-handler/index.js +15 -1
- package/build/http-server/worker-handler/worker-thread.js +8 -0
- package/build/src/background-jobs/job-runner.d.ts.map +1 -1
- package/build/src/background-jobs/job-runner.js +10 -2
- package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -1
- package/build/src/background-jobs/local-dispatcher.js +24 -2
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +7 -5
- package/build/src/background-jobs/perform-job.d.ts +5 -1
- package/build/src/background-jobs/perform-job.d.ts.map +1 -1
- package/build/src/background-jobs/perform-job.js +11 -2
- package/build/src/background-jobs/platform-job.d.ts +34 -0
- package/build/src/background-jobs/platform-job.d.ts.map +1 -1
- package/build/src/background-jobs/platform-job.js +56 -1
- package/build/src/background-jobs/runtime.d.ts.map +1 -1
- package/build/src/background-jobs/runtime.js +7 -4
- package/build/src/background-jobs/types.d.ts +30 -0
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +9 -1
- package/build/src/background-jobs/worker.d.ts.map +1 -1
- package/build/src/background-jobs/worker.js +4 -2
- package/build/src/database/pool/async-tracked-multi-connection.d.ts +22 -2
- package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
- package/build/src/database/pool/async-tracked-multi-connection.js +44 -10
- package/build/src/database/pool/base.d.ts +15 -0
- package/build/src/database/pool/base.d.ts.map +1 -1
- package/build/src/database/pool/base.js +4 -1
- package/build/src/database/pool/checkout-timeout-error.d.ts +13 -0
- package/build/src/database/pool/checkout-timeout-error.d.ts.map +1 -0
- package/build/src/database/pool/checkout-timeout-error.js +15 -0
- package/build/src/http-client/websocket-client.d.ts +18 -0
- package/build/src/http-client/websocket-client.d.ts.map +1 -1
- package/build/src/http-client/websocket-client.js +48 -1
- package/build/src/http-server/client/index.d.ts.map +1 -1
- package/build/src/http-server/client/index.js +7 -1
- package/build/src/http-server/client/websocket-session.d.ts +6 -0
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +27 -1
- package/build/src/http-server/index.d.ts +82 -10
- package/build/src/http-server/index.d.ts.map +1 -1
- package/build/src/http-server/index.js +186 -33
- package/build/src/http-server/worker-handler/index.d.ts +28 -1
- package/build/src/http-server/worker-handler/index.d.ts.map +1 -1
- package/build/src/http-server/worker-handler/index.js +23 -2
- package/build/src/http-server/worker-handler/worker-thread.d.ts.map +1 -1
- package/build/src/http-server/worker-handler/worker-thread.js +7 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/background-jobs/job-runner.js +9 -1
- package/src/background-jobs/local-dispatcher.js +23 -1
- package/src/background-jobs/main.js +6 -4
- package/src/background-jobs/perform-job.js +10 -1
- package/src/background-jobs/platform-job.js +62 -0
- package/src/background-jobs/runtime.js +6 -3
- package/src/background-jobs/types.js +8 -0
- package/src/background-jobs/worker.js +3 -1
- package/src/database/pool/async-tracked-multi-connection.js +46 -9
- package/src/database/pool/base.js +3 -0
- package/src/database/pool/checkout-timeout-error.js +16 -0
- package/src/http-client/websocket-client.js +52 -0
- package/src/http-server/client/index.js +6 -0
- package/src/http-server/client/websocket-session.js +30 -0
- package/src/http-server/index.js +202 -38
- package/src/http-server/worker-handler/index.js +15 -1
- 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()
|
package/src/http-server/index.js
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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({
|
|
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(
|
|
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.
|
|
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
|
|