velocious 1.0.557 → 1.0.559
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 +17 -3
- package/build/background-jobs/main.js +35 -12
- package/build/background-jobs/store.js +262 -16
- package/build/background-jobs/web/authorization.js +5 -4
- package/build/background-jobs/web/controller.js +13 -16
- package/build/background-jobs/web/counts-channel.js +79 -0
- package/build/background-jobs/web/index.js +2 -0
- package/build/background-jobs/web/registry.js +1 -1
- package/build/background-jobs/worker.js +53 -28
- package/build/database/record/index.js +12 -0
- package/build/frontend-models/base.js +192 -41
- package/build/http-client/websocket-client.js +48 -0
- package/build/src/background-jobs/main.d.ts +14 -1
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +37 -14
- package/build/src/background-jobs/store.d.ts +79 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +235 -17
- package/build/src/background-jobs/web/authorization.d.ts +5 -3
- package/build/src/background-jobs/web/authorization.d.ts.map +1 -1
- package/build/src/background-jobs/web/authorization.js +6 -5
- package/build/src/background-jobs/web/controller.d.ts.map +1 -1
- package/build/src/background-jobs/web/controller.js +14 -15
- package/build/src/background-jobs/web/counts-channel.d.ts +31 -0
- package/build/src/background-jobs/web/counts-channel.d.ts.map +1 -0
- package/build/src/background-jobs/web/counts-channel.js +71 -0
- package/build/src/background-jobs/web/index.d.ts.map +1 -1
- package/build/src/background-jobs/web/index.js +3 -1
- package/build/src/background-jobs/web/registry.d.ts +1 -1
- package/build/src/background-jobs/web/registry.d.ts.map +1 -1
- package/build/src/background-jobs/web/registry.js +2 -2
- package/build/src/background-jobs/worker.d.ts +18 -1
- package/build/src/background-jobs/worker.d.ts.map +1 -1
- package/build/src/background-jobs/worker.js +55 -31
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +9 -1
- package/build/src/frontend-models/base.d.ts +13 -4
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +173 -44
- package/build/src/http-client/websocket-client.d.ts +3 -0
- package/build/src/http-client/websocket-client.d.ts.map +1 -1
- package/build/src/http-client/websocket-client.js +42 -1
- package/build/src/tenants/tenant.d.ts +20 -9
- package/build/src/tenants/tenant.d.ts.map +1 -1
- package/build/src/tenants/tenant.js +47 -12
- package/build/src/testing/factory/factory-registry.d.ts +22 -3
- package/build/src/testing/factory/factory-registry.d.ts.map +1 -1
- package/build/src/testing/factory/factory-registry.js +32 -9
- package/build/src/testing/factory/factory-runner.d.ts +16 -4
- package/build/src/testing/factory/factory-runner.d.ts.map +1 -1
- package/build/src/testing/factory/factory-runner.js +33 -10
- package/build/src/utils/shutdown-lifecycle.d.ts +12 -0
- package/build/src/utils/shutdown-lifecycle.d.ts.map +1 -0
- package/build/src/utils/shutdown-lifecycle.js +36 -0
- package/build/tenants/tenant.js +51 -11
- package/build/testing/factory/factory-registry.js +33 -8
- package/build/testing/factory/factory-runner.js +38 -12
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/utils/shutdown-lifecycle.js +41 -0
- package/package.json +3 -2
- package/src/background-jobs/main.js +35 -12
- package/src/background-jobs/store.js +262 -16
- package/src/background-jobs/web/authorization.js +5 -4
- package/src/background-jobs/web/controller.js +13 -16
- package/src/background-jobs/web/counts-channel.js +79 -0
- package/src/background-jobs/web/index.js +2 -0
- package/src/background-jobs/web/registry.js +1 -1
- package/src/background-jobs/worker.js +53 -28
- package/src/database/record/index.js +12 -0
- package/src/frontend-models/base.js +192 -41
- package/src/http-client/websocket-client.js +48 -0
- package/src/tenants/tenant.js +51 -11
- package/src/testing/factory/factory-registry.js +33 -8
- package/src/testing/factory/factory-runner.js +38 -12
- package/src/utils/shutdown-lifecycle.js +41 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {BACKGROUND_JOB_COUNTS_CHANNEL} from "../store.js"
|
|
4
|
+
import VelociousWebsocketChannel from "../../http-server/websocket-channel.js"
|
|
5
|
+
import {authorizeJobsRequest} from "./authorization.js"
|
|
6
|
+
import {getJobsMount} from "./registry.js"
|
|
7
|
+
import {normalizeMountPrefix} from "./path-matcher.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Authorized dashboard count-delta channel. Clients subscribe with the mount
|
|
11
|
+
* path and their normal bearer token as `authenticationToken`.
|
|
12
|
+
*/
|
|
13
|
+
export default class BackgroundJobCountsChannel extends VelociousWebsocketChannel {
|
|
14
|
+
/**
|
|
15
|
+
* Authorizes the subscription.
|
|
16
|
+
* @returns {Promise<boolean>} Whether the mount's normal dashboard authorization allows the subscription.
|
|
17
|
+
*/
|
|
18
|
+
async canSubscribe() {
|
|
19
|
+
if (typeof this.params.mountAt !== "string") return false
|
|
20
|
+
|
|
21
|
+
const mountAt = normalizeMountPrefix(this.params.mountAt)
|
|
22
|
+
const options = getJobsMount(this.session.configuration, mountAt)
|
|
23
|
+
|
|
24
|
+
if (!options || !this.session.upgradeRequest) return false
|
|
25
|
+
|
|
26
|
+
const token = typeof this.params.authenticationToken === "string"
|
|
27
|
+
? this.params.authenticationToken
|
|
28
|
+
: null
|
|
29
|
+
const ability = await this.session.configuration.resolveAbility({
|
|
30
|
+
params: this.params,
|
|
31
|
+
request: this.session.upgradeRequest
|
|
32
|
+
})
|
|
33
|
+
const authorized = await authorizeJobsRequest({
|
|
34
|
+
ability,
|
|
35
|
+
configuration: this.session.configuration,
|
|
36
|
+
options,
|
|
37
|
+
request: this.session.upgradeRequest,
|
|
38
|
+
token
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (!authorized) return false
|
|
42
|
+
|
|
43
|
+
this.databaseIdentifier = options.databaseIdentifier
|
|
44
|
+
|| this.session.configuration.getBackgroundJobsConfig().databaseIdentifier
|
|
45
|
+
|| "default"
|
|
46
|
+
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Matches only events from the database selected by the authorized mount.
|
|
52
|
+
* @param {import("../../http-server/websocket-channel.js").WebsocketJsonValue} broadcastParams - Publisher scope.
|
|
53
|
+
* @returns {boolean} Whether this subscription should receive the event.
|
|
54
|
+
*/
|
|
55
|
+
matches(broadcastParams) {
|
|
56
|
+
if (!broadcastParams || typeof broadcastParams !== "object" || Array.isArray(broadcastParams)) return false
|
|
57
|
+
|
|
58
|
+
return String(/** @type {Record<string, ?>} */ (broadcastParams).databaseIdentifier) === this.databaseIdentifier
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds diagnostics.
|
|
63
|
+
* @returns {Record<string, string>} Non-sensitive diagnostics.
|
|
64
|
+
*/
|
|
65
|
+
debugSnapshot() {
|
|
66
|
+
return {databaseIdentifier: this.databaseIdentifier}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @type {string} */
|
|
70
|
+
databaseIdentifier = ""
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Registers the framework channel used by mounted jobs dashboards.
|
|
74
|
+
* @param {import("../../configuration.js").default} configuration - Configuration.
|
|
75
|
+
*/
|
|
76
|
+
static register(configuration) {
|
|
77
|
+
configuration.registerWebsocketChannel(BACKGROUND_JOB_COUNTS_CHANNEL, this)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import VelociousBackgroundJobsWebController from "./controller.js"
|
|
4
|
+
import BackgroundJobCountsChannel from "./counts-channel.js"
|
|
4
5
|
import {matchJobsApiPath, normalizeMountPrefix} from "./path-matcher.js"
|
|
5
6
|
import {registerJobsMount} from "./registry.js"
|
|
6
7
|
|
|
@@ -40,6 +41,7 @@ export default class VelociousBackgroundJobsApi {
|
|
|
40
41
|
const prefix = normalizeMountPrefix(at)
|
|
41
42
|
|
|
42
43
|
registerJobsMount(configuration, prefix, {accessTokens, allowedOrigins, authorize, databaseIdentifier, redactArgs})
|
|
44
|
+
BackgroundJobCountsChannel.register(configuration)
|
|
43
45
|
|
|
44
46
|
configuration.addRouteResolverHook(({currentPath, request}) => {
|
|
45
47
|
const match = matchJobsApiPath({method: request.httpMethod(), path: currentPath, prefix})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* JobsMountOptions type.
|
|
5
5
|
* @typedef {object} JobsMountOptions
|
|
6
|
-
* @property {(args: {request: import("../../http-server/client/request.js").default, ability: (import("../../authorization/ability.js").default | undefined), token: (string | null), configuration: import("../../configuration.js").default}) => (boolean | void | Promise<boolean | void>)} [authorize] - Authorization callback. Return true to allow the request.
|
|
6
|
+
* @property {(args: {request: import("../../http-server/client/request.js").default | import("../../http-server/client/websocket-request.js").default, ability: (import("../../authorization/ability.js").default | undefined), token: (string | null), configuration: import("../../configuration.js").default}) => (boolean | void | Promise<boolean | void>)} [authorize] - Authorization callback. Return true to allow the request.
|
|
7
7
|
* @property {string[]} [accessTokens] - Bearer tokens accepted for cross-origin/native access.
|
|
8
8
|
* @property {string[]} [allowedOrigins] - Origins allowed for cross-origin browser access.
|
|
9
9
|
* @property {boolean} [redactArgs] - When true, job arguments are omitted from API responses.
|
|
@@ -8,6 +8,7 @@ import configurationResolver from "../configuration-resolver.js"
|
|
|
8
8
|
import BackgroundJobsStatusReporter from "./status-reporter.js"
|
|
9
9
|
import {randomUUID} from "crypto"
|
|
10
10
|
import {fileURLToPath} from "node:url"
|
|
11
|
+
import shutdownLifecycle from "../utils/shutdown-lifecycle.js"
|
|
11
12
|
|
|
12
13
|
/** Grace period after SIGTERM before a lingering process runner is SIGKILLed. */
|
|
13
14
|
const FORKED_CHILD_SIGKILL_GRACE_MS = 5000
|
|
@@ -73,8 +74,10 @@ export default class BackgroundJobsWorker {
|
|
|
73
74
|
* @param {number} [args.forkedChildSigkillGraceMs] - Override the grace period between SIGTERM and SIGKILL when reaping lingering process runners on stop.
|
|
74
75
|
* @param {number} [args.heartbeatIntervalMs] - Override the liveness heartbeat interval (default 15000ms).
|
|
75
76
|
* @param {number} [args.jobTimeoutMs] - Override the wall-clock timeout for forked and pooled jobs from `configuration.getBackgroundJobsConfig()`. `0` disables it.
|
|
77
|
+
* @param {boolean} [args.closeDatabaseConnectionsOnStop] - Whether stop owns closing the configuration's database pools (default true).
|
|
78
|
+
* @param {() => void | Promise<void>} [args.onStopped] - Lifecycle hook invoked after the worker finishes stopping.
|
|
76
79
|
*/
|
|
77
|
-
constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
|
|
80
|
+
constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs, closeDatabaseConnectionsOnStop = true, onStopped} = {}) {
|
|
78
81
|
/**
|
|
79
82
|
* Narrows the runtime value to the documented type.
|
|
80
83
|
* @type {Promise<import("../configuration.js").default>} */
|
|
@@ -85,6 +88,8 @@ export default class BackgroundJobsWorker {
|
|
|
85
88
|
this.configuration = undefined
|
|
86
89
|
this.host = host
|
|
87
90
|
this.port = port
|
|
91
|
+
this.closeDatabaseConnectionsOnStop = closeDatabaseConnectionsOnStop
|
|
92
|
+
this.onStopped = onStopped
|
|
88
93
|
/**
|
|
89
94
|
* Constructor override for the inline-job concurrency cap. When unset
|
|
90
95
|
* the cap is read from `configuration.getBackgroundJobsConfig()` in
|
|
@@ -136,6 +141,8 @@ export default class BackgroundJobsWorker {
|
|
|
136
141
|
*/
|
|
137
142
|
this.jobTimeoutMsOverride = typeof jobTimeoutMs === "number" ? jobTimeoutMs : undefined
|
|
138
143
|
this.shouldStop = false
|
|
144
|
+
/** @type {Promise<void> | undefined} */
|
|
145
|
+
this.stopPromise = undefined
|
|
139
146
|
this.workerId = randomUUID()
|
|
140
147
|
this.heartbeatIntervalMs = typeof heartbeatIntervalMs === "number" && heartbeatIntervalMs >= 1
|
|
141
148
|
? heartbeatIntervalMs
|
|
@@ -195,6 +202,8 @@ export default class BackgroundJobsWorker {
|
|
|
195
202
|
* @returns {Promise<void>} - Resolves when connected.
|
|
196
203
|
*/
|
|
197
204
|
async start() {
|
|
205
|
+
this.shouldStop = false
|
|
206
|
+
this.stopPromise = undefined
|
|
198
207
|
this.configuration = await this.configurationPromise
|
|
199
208
|
this.configuration.setCurrent()
|
|
200
209
|
await this.configuration.initialize({type: "background-jobs-worker"})
|
|
@@ -241,37 +250,53 @@ export default class BackgroundJobsWorker {
|
|
|
241
250
|
* @param {number} [args.timeoutMs] - Max wait for in-flight jobs (per phase) in ms.
|
|
242
251
|
* @returns {Promise<void>} - Resolves when stopped.
|
|
243
252
|
*/
|
|
244
|
-
|
|
245
|
-
if (this.
|
|
246
|
-
this.shouldStop = true
|
|
247
|
-
this._stopHeartbeat()
|
|
253
|
+
stop({timeoutMs} = {}) {
|
|
254
|
+
if (!this.stopPromise) this.stopPromise = this._stop({timeoutMs})
|
|
248
255
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (this.jsonSocket) {
|
|
252
|
-
try {
|
|
253
|
-
this.jsonSocket.send({type: "draining"})
|
|
254
|
-
} catch {
|
|
255
|
-
// Socket may already be closing; nothing to do.
|
|
256
|
-
}
|
|
257
|
-
}
|
|
256
|
+
return this.stopPromise
|
|
257
|
+
}
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Runs the worker shutdown lifecycle once.
|
|
261
|
+
* @param {object} [args] - Options.
|
|
262
|
+
* @param {number} [args.timeoutMs] - Max wait for in-flight jobs (per phase) in ms.
|
|
263
|
+
* @returns {Promise<void>} - Resolves when stopped.
|
|
264
|
+
*/
|
|
265
|
+
async _stop({timeoutMs} = {}) {
|
|
266
|
+
this.shouldStop = true
|
|
267
|
+
this._stopHeartbeat()
|
|
266
268
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
269
|
+
await shutdownLifecycle({
|
|
270
|
+
onStopped: this.onStopped,
|
|
271
|
+
shutdown: async () => {
|
|
272
|
+
// Announce drain so main stops dispatching but keeps the connection
|
|
273
|
+
// open until we close it ourselves below.
|
|
274
|
+
if (this.jsonSocket) {
|
|
275
|
+
try {
|
|
276
|
+
this.jsonSocket.send({type: "draining"})
|
|
277
|
+
} catch {
|
|
278
|
+
// Socket may already be closing; nothing to do.
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
await this._drainInflight(this.inflightInlineJobs, timeoutMs)
|
|
283
|
+
await this._drainInflight(this.inflightPooledJobs, timeoutMs)
|
|
284
|
+
await this._drainInflight(this.inflightProcessJobs, timeoutMs)
|
|
285
|
+
await this._terminateProcessChildren()
|
|
286
|
+
// Give in-flight result reports (now decoupled from job slots) a bounded
|
|
287
|
+
// chance to land before the socket closes.
|
|
288
|
+
await this._drainInflight(this.inflightReports, timeoutMs)
|
|
289
|
+
|
|
290
|
+
if (this.jsonSocket) this.jsonSocket.close()
|
|
291
|
+
if (!this.configuration) return
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
await this.configuration.disconnectBeacon()
|
|
295
|
+
} finally {
|
|
296
|
+
if (this.closeDatabaseConnectionsOnStop) await this.configuration.closeDatabaseConnections()
|
|
297
|
+
}
|
|
273
298
|
}
|
|
274
|
-
}
|
|
299
|
+
})
|
|
275
300
|
}
|
|
276
301
|
|
|
277
302
|
/**
|
|
@@ -1710,6 +1710,12 @@ class VelociousDatabaseRecord {
|
|
|
1710
1710
|
*/
|
|
1711
1711
|
static switchesTenantDatabase(databaseIdentifierOrResolver) {
|
|
1712
1712
|
this._tenantDatabaseIdentifierResolver = databaseIdentifierOrResolver
|
|
1713
|
+
|
|
1714
|
+
if (this._translationClass) {
|
|
1715
|
+
const translatedModelClass = this
|
|
1716
|
+
|
|
1717
|
+
this._translationClass.switchesTenantDatabase(({tenant}) => translatedModelClass.getTenantDatabaseIdentifier(tenant))
|
|
1718
|
+
}
|
|
1713
1719
|
}
|
|
1714
1720
|
|
|
1715
1721
|
/**
|
|
@@ -2814,6 +2820,12 @@ class VelociousDatabaseRecord {
|
|
|
2814
2820
|
TranslationClass.setTableName(this.getTranslationsTableName())
|
|
2815
2821
|
TranslationClass.belongsTo(belongsTo)
|
|
2816
2822
|
|
|
2823
|
+
if (this.hasTenantDatabaseIdentifierResolver()) {
|
|
2824
|
+
const translatedModelClass = this
|
|
2825
|
+
|
|
2826
|
+
TranslationClass.switchesTenantDatabase(({tenant}) => translatedModelClass.getTenantDatabaseIdentifier(tenant))
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2817
2829
|
this._translationClass = TranslationClass
|
|
2818
2830
|
|
|
2819
2831
|
return this._translationClass
|
|
@@ -12,7 +12,7 @@ import {deserializeFrontendModelTransportValue, serializeFrontendModelTransportV
|
|
|
12
12
|
import runWithTransportDeadline from "./transport-deadline.js"
|
|
13
13
|
import {REQUEST_TIME_ZONE_HEADER, validateTimeZone} from "../time-zone.js"
|
|
14
14
|
import VelociousWebsocketClient from "../http-client/websocket-client.js"
|
|
15
|
-
import {bufferOutgoingEvent, drainBufferedOutgoingEvents} from "./outgoing-event-buffer.js"
|
|
15
|
+
import {bufferOutgoingEvent, clearBufferedOutgoingEvents, drainBufferedOutgoingEvents} from "./outgoing-event-buffer.js"
|
|
16
16
|
import {defineModelScope} from "../utils/model-scope.js"
|
|
17
17
|
import isPlainObject from "../utils/plain-object.js"
|
|
18
18
|
import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQueryData, setPayloadAssociationCount, setPayloadComputedAbility, setPayloadQueryData} from "../record-payload-values.js"
|
|
@@ -154,6 +154,64 @@ let frontendModelIdleResolvers = []
|
|
|
154
154
|
* Internal websocket client.
|
|
155
155
|
* @type {VelociousWebsocketClient | null} */
|
|
156
156
|
let internalWebsocketClient = null
|
|
157
|
+
/** @type {AbortSignal | null} */
|
|
158
|
+
let internalWebsocketClientSignal = null
|
|
159
|
+
/** @type {(() => void) | null} */
|
|
160
|
+
let internalWebsocketClientSignalCleanup = null
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Detaches an owned WebSocket client from the shared cache if it is still current.
|
|
164
|
+
* @param {VelociousWebsocketClient} client - Client whose ownership is ending.
|
|
165
|
+
* @returns {void}
|
|
166
|
+
*/
|
|
167
|
+
function detachInternalWebsocketClient(client) {
|
|
168
|
+
if (internalWebsocketClient !== client) return
|
|
169
|
+
|
|
170
|
+
internalWebsocketClient = null
|
|
171
|
+
internalWebsocketClientSignalCleanup?.()
|
|
172
|
+
internalWebsocketClientSignal = null
|
|
173
|
+
internalWebsocketClientSignalCleanup = null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Disposes the owned WebSocket client before transport/session configuration changes.
|
|
178
|
+
* @returns {void}
|
|
179
|
+
*/
|
|
180
|
+
function resetInternalWebsocketClient() {
|
|
181
|
+
const client = internalWebsocketClient
|
|
182
|
+
|
|
183
|
+
if (!client) return
|
|
184
|
+
|
|
185
|
+
detachInternalWebsocketClient(client)
|
|
186
|
+
void client.disconnectAndStopReconnect()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Binds the owned WebSocket client lifetime to the current session signal.
|
|
191
|
+
* @param {AbortSignal | undefined} sessionSignal - Current session signal.
|
|
192
|
+
* @returns {void}
|
|
193
|
+
*/
|
|
194
|
+
function bindInternalWebsocketClientSignal(sessionSignal) {
|
|
195
|
+
if (internalWebsocketClientSignal === sessionSignal) return
|
|
196
|
+
|
|
197
|
+
internalWebsocketClientSignalCleanup?.()
|
|
198
|
+
internalWebsocketClientSignal = sessionSignal || null
|
|
199
|
+
internalWebsocketClientSignalCleanup = null
|
|
200
|
+
|
|
201
|
+
if (!sessionSignal || !internalWebsocketClient) return
|
|
202
|
+
|
|
203
|
+
const client = internalWebsocketClient
|
|
204
|
+
const onSessionAbort = () => {
|
|
205
|
+
detachInternalWebsocketClient(client)
|
|
206
|
+
clearBufferedOutgoingEvents()
|
|
207
|
+
void client.disconnectAndStopReconnect()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
sessionSignal.addEventListener("abort", onSessionAbort, {once: true})
|
|
211
|
+
internalWebsocketClientSignalCleanup = () => sessionSignal.removeEventListener("abort", onSessionAbort)
|
|
212
|
+
|
|
213
|
+
if (sessionSignal.aborted) onSessionAbort()
|
|
214
|
+
}
|
|
157
215
|
|
|
158
216
|
/**
|
|
159
217
|
* Runs frontend model transport is idle.
|
|
@@ -237,7 +295,13 @@ async function trackFrontendModelTransportRequest(callback) {
|
|
|
237
295
|
* @returns {VelociousWebsocketClient | null} Websocket client or null.
|
|
238
296
|
*/
|
|
239
297
|
function resolveInternalWebsocketClient() {
|
|
240
|
-
if (internalWebsocketClient)
|
|
298
|
+
if (internalWebsocketClient) {
|
|
299
|
+
const client = internalWebsocketClient
|
|
300
|
+
|
|
301
|
+
bindInternalWebsocketClientSignal(frontendModelTransportSignal())
|
|
302
|
+
|
|
303
|
+
return client
|
|
304
|
+
}
|
|
241
305
|
|
|
242
306
|
const websocketUrl = frontendModelTransportConfig.websocketUrl
|
|
243
307
|
|
|
@@ -248,41 +312,68 @@ function resolveInternalWebsocketClient() {
|
|
|
248
312
|
|
|
249
313
|
if (!resolvedUrl) return null
|
|
250
314
|
|
|
251
|
-
|
|
315
|
+
const client = new VelociousWebsocketClient({
|
|
252
316
|
autoReconnect: true,
|
|
253
317
|
sessionStore: frontendModelTransportConfig.sessionStore,
|
|
254
318
|
url: resolvedUrl
|
|
255
319
|
})
|
|
256
|
-
internalWebsocketClient
|
|
320
|
+
internalWebsocketClient = client
|
|
321
|
+
client.onReconnect = async () => await flushBufferedOutgoingEventsAfterReconnect(client)
|
|
257
322
|
|
|
258
|
-
|
|
323
|
+
bindInternalWebsocketClientSignal(frontendModelTransportSignal())
|
|
324
|
+
|
|
325
|
+
return client
|
|
259
326
|
}
|
|
260
327
|
|
|
261
328
|
/**
|
|
262
329
|
* Runs flush buffered outgoing events after reconnect.
|
|
330
|
+
* @param {VelociousWebsocketClient} client - Reconnected client that owns this flush.
|
|
263
331
|
* @returns {Promise<void>} */
|
|
264
|
-
async function flushBufferedOutgoingEventsAfterReconnect() {
|
|
265
|
-
if (
|
|
332
|
+
async function flushBufferedOutgoingEventsAfterReconnect(client) {
|
|
333
|
+
if (internalWebsocketClient !== client) return
|
|
266
334
|
|
|
267
335
|
const events = drainBufferedOutgoingEvents()
|
|
336
|
+
const sessionSignal = frontendModelTransportSignal()
|
|
268
337
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
338
|
+
await runWithTransportDeadline(
|
|
339
|
+
{
|
|
340
|
+
errorMessage: "Buffered frontend-model WebSocket flush timed out",
|
|
341
|
+
signal: sessionSignal,
|
|
342
|
+
timeoutMs: frontendModelTransportTimeoutMs()
|
|
343
|
+
},
|
|
344
|
+
async (signal) => {
|
|
345
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
346
|
+
if (internalWebsocketClient !== client) return
|
|
274
347
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
348
|
+
try {
|
|
349
|
+
await client.post(events[index].customPath, events[index].payload, {signal})
|
|
278
350
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
351
|
+
if (internalWebsocketClient !== client) return
|
|
352
|
+
} catch {
|
|
353
|
+
if (internalWebsocketClient !== client) return
|
|
354
|
+
if (sessionSignal?.aborted) return
|
|
282
355
|
|
|
283
|
-
|
|
356
|
+
if (signal.aborted) {
|
|
357
|
+
for (let remaining = index; remaining < events.length; remaining += 1) {
|
|
358
|
+
bufferOutgoingEvent(events[remaining])
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const socketOpen = client.socket?.readyState === client.socket?.OPEN
|
|
365
|
+
|
|
366
|
+
if (socketOpen) continue
|
|
367
|
+
|
|
368
|
+
for (let remaining = index; remaining < events.length; remaining += 1) {
|
|
369
|
+
bufferOutgoingEvent(events[remaining])
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
}
|
|
284
375
|
}
|
|
285
|
-
|
|
376
|
+
)
|
|
286
377
|
}
|
|
287
378
|
|
|
288
379
|
/**
|
|
@@ -1784,6 +1875,29 @@ function frontendModelTransportSignal() {
|
|
|
1784
1875
|
return configuredSignal || undefined
|
|
1785
1876
|
}
|
|
1786
1877
|
|
|
1878
|
+
/**
|
|
1879
|
+
* Resolves per-startup controls with the configured session cancellation.
|
|
1880
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal}} controls - Call controls.
|
|
1881
|
+
* @returns {{timeoutMs?: number, signal?: AbortSignal}} - Effective startup controls.
|
|
1882
|
+
*/
|
|
1883
|
+
function frontendModelWebsocketStartupControls(controls) {
|
|
1884
|
+
const sessionSignal = frontendModelTransportSignal()
|
|
1885
|
+
let signal = controls.signal || sessionSignal
|
|
1886
|
+
|
|
1887
|
+
if (controls.signal && sessionSignal && controls.signal !== sessionSignal) {
|
|
1888
|
+
signal = AbortSignal.any([controls.signal, sessionSignal])
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const configuredTimeoutMs = frontendModelTransportTimeoutMs()
|
|
1892
|
+
const timeoutMs = controls.timeoutMs === undefined
|
|
1893
|
+
? configuredTimeoutMs
|
|
1894
|
+
: configuredTimeoutMs === undefined
|
|
1895
|
+
? controls.timeoutMs
|
|
1896
|
+
: Math.min(controls.timeoutMs, configuredTimeoutMs)
|
|
1897
|
+
|
|
1898
|
+
return {signal, timeoutMs}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1787
1901
|
/**
|
|
1788
1902
|
* Runs perform shared frontend model api request.
|
|
1789
1903
|
* @param {Record<string, ?>} requestPayload - Shared request payload.
|
|
@@ -2897,7 +3011,7 @@ export default class FrontendModelBase {
|
|
|
2897
3011
|
if (Object.prototype.hasOwnProperty.call(config, "websocketUrl")) {
|
|
2898
3012
|
frontendModelTransportConfig.websocketUrl = config.websocketUrl
|
|
2899
3013
|
// Reset cached internal client so the new URL takes effect on next subscribe
|
|
2900
|
-
|
|
3014
|
+
resetInternalWebsocketClient()
|
|
2901
3015
|
}
|
|
2902
3016
|
|
|
2903
3017
|
if (Object.prototype.hasOwnProperty.call(config, "requestHeaders")) {
|
|
@@ -2909,7 +3023,10 @@ export default class FrontendModelBase {
|
|
|
2909
3023
|
}
|
|
2910
3024
|
|
|
2911
3025
|
if (Object.prototype.hasOwnProperty.call(config, "signal")) {
|
|
2912
|
-
frontendModelTransportConfig.signal
|
|
3026
|
+
if (frontendModelTransportConfig.signal !== config.signal) {
|
|
3027
|
+
frontendModelTransportConfig.signal = config.signal
|
|
3028
|
+
resetInternalWebsocketClient()
|
|
3029
|
+
}
|
|
2913
3030
|
}
|
|
2914
3031
|
|
|
2915
3032
|
if (Object.prototype.hasOwnProperty.call(config, "timeZone")) {
|
|
@@ -2923,7 +3040,7 @@ export default class FrontendModelBase {
|
|
|
2923
3040
|
if (Object.prototype.hasOwnProperty.call(config, "sessionStore")) {
|
|
2924
3041
|
frontendModelTransportConfig.sessionStore = config.sessionStore
|
|
2925
3042
|
// Reset cached internal client so the new sessionStore is picked up.
|
|
2926
|
-
|
|
3043
|
+
resetInternalWebsocketClient()
|
|
2927
3044
|
}
|
|
2928
3045
|
|
|
2929
3046
|
if (Object.prototype.hasOwnProperty.call(config, "offlineSync")) {
|
|
@@ -2933,16 +3050,17 @@ export default class FrontendModelBase {
|
|
|
2933
3050
|
|
|
2934
3051
|
/**
|
|
2935
3052
|
* Connect the internal WebSocket and enable auto-reconnect.
|
|
3053
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal}} [options] - Startup controls composed with the configured transport controls.
|
|
2936
3054
|
* @returns {Promise<void>} - Resolves when connected.
|
|
2937
3055
|
*/
|
|
2938
|
-
static async connectWebsocket() {
|
|
3056
|
+
static async connectWebsocket(options = {}) {
|
|
2939
3057
|
const client = resolveInternalWebsocketClient()
|
|
2940
3058
|
|
|
2941
3059
|
if (!client) {
|
|
2942
3060
|
throw new Error("connectWebsocket requires configureTransport({websocketUrl})")
|
|
2943
3061
|
}
|
|
2944
3062
|
|
|
2945
|
-
await client.connect()
|
|
3063
|
+
await client.connect(frontendModelWebsocketStartupControls(options))
|
|
2946
3064
|
}
|
|
2947
3065
|
|
|
2948
3066
|
/**
|
|
@@ -2952,7 +3070,10 @@ export default class FrontendModelBase {
|
|
|
2952
3070
|
static async disconnectWebsocket() {
|
|
2953
3071
|
if (!internalWebsocketClient) return
|
|
2954
3072
|
|
|
2955
|
-
|
|
3073
|
+
const client = internalWebsocketClient
|
|
3074
|
+
|
|
3075
|
+
detachInternalWebsocketClient(client)
|
|
3076
|
+
await client.disconnectAndStopReconnect()
|
|
2956
3077
|
}
|
|
2957
3078
|
|
|
2958
3079
|
/**
|
|
@@ -3026,7 +3147,7 @@ export default class FrontendModelBase {
|
|
|
3026
3147
|
* functions change (e.g. current-user sign-in/out). The handle
|
|
3027
3148
|
* retries when the WS client isn't ready and reopens on close.
|
|
3028
3149
|
* @param {string} connectionType - Connection class name registered on the server.
|
|
3029
|
-
* @param {{shouldConnect: () => boolean, params: () => Record<string, ?>, onMessage?: (body: ?) => void}} options - Connection lifecycle and payload callbacks.
|
|
3150
|
+
* @param {{shouldConnect: () => boolean, params: () => Record<string, ?>, signal?: AbortSignal, onMessage?: (body: ?) => void}} options - Connection lifecycle, cancellation, and payload callbacks.
|
|
3030
3151
|
* @returns {{sync: () => void, close: () => void}} - Handle used to resync or close the managed connection.
|
|
3031
3152
|
*/
|
|
3032
3153
|
static openManagedConnection(connectionType, options) {
|
|
@@ -3040,11 +3161,29 @@ export default class FrontendModelBase {
|
|
|
3040
3161
|
* @type {ReturnType<typeof setTimeout> | null} */
|
|
3041
3162
|
let retryTimer = null
|
|
3042
3163
|
let lastParamsJson = ""
|
|
3164
|
+
const controls = frontendModelWebsocketStartupControls({signal: options.signal})
|
|
3165
|
+
const clearRetryTimer = () => {
|
|
3166
|
+
if (retryTimer === null) return
|
|
3167
|
+
|
|
3168
|
+
globalThis.clearTimeout(retryTimer)
|
|
3169
|
+
retryTimer = null
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
const close = () => {
|
|
3173
|
+
if (closed) return
|
|
3174
|
+
|
|
3175
|
+
closed = true
|
|
3176
|
+
clearRetryTimer()
|
|
3177
|
+
controls.signal?.removeEventListener("abort", close)
|
|
3178
|
+
if (connection && !connection.isClosed()) connection.close()
|
|
3179
|
+
connection = null
|
|
3180
|
+
}
|
|
3043
3181
|
|
|
3044
3182
|
const sync = () => {
|
|
3045
3183
|
if (closed) return
|
|
3046
3184
|
|
|
3047
3185
|
if (!options.shouldConnect()) {
|
|
3186
|
+
clearRetryTimer()
|
|
3048
3187
|
if (connection && !connection.isClosed()) connection.close()
|
|
3049
3188
|
connection = null
|
|
3050
3189
|
lastParamsJson = ""
|
|
@@ -3087,7 +3226,7 @@ export default class FrontendModelBase {
|
|
|
3087
3226
|
}
|
|
3088
3227
|
|
|
3089
3228
|
lastParamsJson = nextParamsJson
|
|
3090
|
-
connection =
|
|
3229
|
+
connection = client.openConnection(connectionType, {
|
|
3091
3230
|
params: nextParams,
|
|
3092
3231
|
onMessage: options.onMessage,
|
|
3093
3232
|
onClose: () => {
|
|
@@ -3100,14 +3239,13 @@ export default class FrontendModelBase {
|
|
|
3100
3239
|
})
|
|
3101
3240
|
}
|
|
3102
3241
|
|
|
3103
|
-
|
|
3104
|
-
closed = true
|
|
3105
|
-
if (retryTimer !== null) globalThis.clearTimeout(retryTimer)
|
|
3106
|
-
if (connection && !connection.isClosed()) connection.close()
|
|
3107
|
-
connection = null
|
|
3108
|
-
}
|
|
3242
|
+
controls.signal?.addEventListener("abort", close, {once: true})
|
|
3109
3243
|
|
|
3110
|
-
|
|
3244
|
+
if (controls.signal?.aborted) {
|
|
3245
|
+
close()
|
|
3246
|
+
} else {
|
|
3247
|
+
sync()
|
|
3248
|
+
}
|
|
3111
3249
|
|
|
3112
3250
|
return {sync, close}
|
|
3113
3251
|
}
|
|
@@ -3118,34 +3256,47 @@ export default class FrontendModelBase {
|
|
|
3118
3256
|
* `openConnection`. Apps use this for per-session state/messaging
|
|
3119
3257
|
* that doesn't fit the pub/sub Channel model (locale, presence).
|
|
3120
3258
|
* @param {string} connectionType - Name the server registered the class under.
|
|
3121
|
-
* @param {{params?: Record<string, ?>, onConnect?: () => void, onMessage?: (body: Record<string, unknown>) => void, onDisconnect?: () => void, onResume?: () => void, onClose?: (reason: string) => void}} [options] - Connection options and event handlers.
|
|
3259
|
+
* @param {{params?: Record<string, ?>, timeoutMs?: number, signal?: AbortSignal, onConnect?: () => void, onMessage?: (body: Record<string, unknown>) => void, onDisconnect?: () => void, onResume?: () => void, onClose?: (reason: string) => void}} [options] - Connection options, readiness controls, and event handlers. Connect the client first; the timeout covers server-confirmed readiness and the signal cancels readiness without entering the wire payload.
|
|
3122
3260
|
* @returns {{ready: Promise<void>, close: () => void}} - Websocket connection handle.
|
|
3123
3261
|
*/
|
|
3124
|
-
static openWebsocketConnection(connectionType, options) {
|
|
3262
|
+
static openWebsocketConnection(connectionType, options = {}) {
|
|
3125
3263
|
const client = /** @type {?} */ (frontendModelTransportConfig.websocketClient || resolveInternalWebsocketClient())
|
|
3126
3264
|
|
|
3127
3265
|
if (!client || typeof client.openConnection !== "function") {
|
|
3128
3266
|
throw new Error("openWebsocketConnection requires configureTransport({websocketUrl})")
|
|
3129
3267
|
}
|
|
3130
3268
|
|
|
3131
|
-
|
|
3269
|
+
const {signal, timeoutMs, ...connectionOptions} = options
|
|
3270
|
+
|
|
3271
|
+
return client.openConnection(connectionType, {
|
|
3272
|
+
...connectionOptions,
|
|
3273
|
+
...frontendModelWebsocketStartupControls({signal, timeoutMs})
|
|
3274
|
+
})
|
|
3132
3275
|
}
|
|
3133
3276
|
|
|
3134
3277
|
/**
|
|
3135
3278
|
* Subscribes to a pub/sub `WebsocketChannel`. Thin wrapper around
|
|
3136
3279
|
* the internal client's `subscribeChannel`.
|
|
3137
3280
|
* @param {string} channelType - Channel class name registered on the server.
|
|
3138
|
-
* @param {{params?: Record<string, ?>, onMessage?: (body: Record<string, unknown>) => void, onDisconnect?: () => void, onResume?: () => void, onClose?: (reason: string) => void}} [options] - Channel
|
|
3281
|
+
* @param {{params?: Record<string, ?>, timeoutMs?: number, signal?: AbortSignal, onMessage?: (body: Record<string, unknown>) => void, onDisconnect?: () => void, onResume?: () => void, onClose?: (reason: string) => void}} [options] - Channel options, startup controls, and event handlers. The timeout covers connect and server-confirmed readiness only; the signal cancels startup without entering the wire payload.
|
|
3139
3282
|
* @returns {{ready: Promise<void>, close: () => void}} - Websocket channel handle from the configured client.
|
|
3140
3283
|
*/
|
|
3141
|
-
static subscribeWebsocketChannel(channelType, options) {
|
|
3284
|
+
static subscribeWebsocketChannel(channelType, options = {}) {
|
|
3142
3285
|
const client = /** @type {?} */ (frontendModelTransportConfig.websocketClient || resolveInternalWebsocketClient())
|
|
3143
3286
|
|
|
3144
3287
|
if (!client || typeof client.subscribeChannel !== "function") {
|
|
3145
3288
|
throw new Error("subscribeWebsocketChannel requires configureTransport({websocketUrl})")
|
|
3146
3289
|
}
|
|
3147
3290
|
|
|
3148
|
-
|
|
3291
|
+
const {signal, timeoutMs, ...channelOptions} = options
|
|
3292
|
+
const startupControls = frontendModelWebsocketStartupControls({signal, timeoutMs})
|
|
3293
|
+
const handle = client.subscribeChannel(channelType, {...channelOptions, ...startupControls})
|
|
3294
|
+
|
|
3295
|
+
if (typeof client.connect === "function") {
|
|
3296
|
+
void client.connect(startupControls).catch(() => handle.close())
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
return handle
|
|
3149
3300
|
}
|
|
3150
3301
|
|
|
3151
3302
|
/**
|