velocious 1.0.621 → 1.0.623
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 +7 -3
- package/build/database/drivers/base.js +20 -3
- package/build/database/drivers/mssql/index.js +42 -36
- package/build/environment-handlers/base.js +9 -0
- package/build/environment-handlers/node.js +16 -0
- package/build/frontend-model-controller.js +20 -10
- package/build/frontend-models/base.js +90 -25
- package/build/frontend-models/remote-request-context.js +29 -0
- package/build/http-client/websocket-client.js +32 -0
- package/build/http-server/client/websocket-session.js +4 -1
- package/build/remote-request-context.js +97 -0
- package/build/src/database/drivers/base.d.ts +8 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +16 -4
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +47 -41
- package/build/src/environment-handlers/base.d.ts +8 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +9 -1
- package/build/src/environment-handlers/node.d.ts +8 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +15 -1
- package/build/src/frontend-model-controller.d.ts +3 -1
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +11 -7
- package/build/src/frontend-models/base.d.ts +4 -0
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +78 -25
- package/build/src/frontend-models/remote-request-context.d.ts +15 -0
- package/build/src/frontend-models/remote-request-context.d.ts.map +1 -0
- package/build/src/frontend-models/remote-request-context.js +26 -0
- package/build/src/http-client/websocket-client.d.ts +8 -0
- package/build/src/http-client/websocket-client.d.ts.map +1 -1
- package/build/src/http-client/websocket-client.js +31 -1
- package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
- package/build/src/http-server/client/websocket-session.js +4 -2
- package/build/src/remote-request-context.d.ts +34 -0
- package/build/src/remote-request-context.d.ts.map +1 -0
- package/build/src/remote-request-context.js +83 -0
- package/build/src/sync/sync-client-types.d.ts +10 -0
- package/build/src/sync/sync-client-types.d.ts.map +1 -1
- package/build/src/sync/sync-client-types.js +3 -1
- package/build/src/sync/sync-client.d.ts.map +1 -1
- package/build/src/sync/sync-client.js +31 -7
- package/build/src/sync/sync-envelope-replay-service.d.ts +3 -2
- package/build/src/sync/sync-envelope-replay-service.d.ts.map +1 -1
- package/build/src/sync/sync-envelope-replay-service.js +18 -11
- package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
- package/build/src/sync/sync-realtime-bridge.js +8 -2
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts +9 -0
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-connection-coordinator.js +15 -1
- package/build/src/testing/test-runner.js +2 -2
- package/build/sync/sync-client-types.js +2 -0
- package/build/sync/sync-client.js +31 -6
- package/build/sync/sync-envelope-replay-service.js +17 -10
- package/build/sync/sync-realtime-bridge.js +7 -1
- package/build/testing/shared-transaction-connection-coordinator.js +16 -0
- package/build/testing/test-runner.js +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/database/drivers/base.js +20 -3
- package/src/database/drivers/mssql/index.js +42 -36
- package/src/environment-handlers/base.js +9 -0
- package/src/environment-handlers/node.js +16 -0
- package/src/frontend-model-controller.js +20 -10
- package/src/frontend-models/base.js +90 -25
- package/src/frontend-models/remote-request-context.js +29 -0
- package/src/http-client/websocket-client.js +32 -0
- package/src/http-server/client/websocket-session.js +4 -1
- package/src/remote-request-context.js +97 -0
- package/src/sync/sync-client-types.js +2 -0
- package/src/sync/sync-client.js +31 -6
- package/src/sync/sync-envelope-replay-service.js +17 -10
- package/src/sync/sync-realtime-bridge.js +7 -1
- package/src/testing/shared-transaction-connection-coordinator.js +16 -0
- package/src/testing/test-runner.js +1 -1
|
@@ -558,52 +558,58 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
558
558
|
}
|
|
559
559
|
|
|
560
560
|
async _startTransactionAction() {
|
|
561
|
-
|
|
562
|
-
|
|
561
|
+
await this._runPhysicalConnectionRequest(async () => {
|
|
562
|
+
if (this._currentTransaction) throw new Error("A transaction is already running")
|
|
563
|
+
if (!this.connection) await this.connect()
|
|
563
564
|
|
|
564
|
-
|
|
565
|
+
this._currentTransaction = new mssql.Transaction(this.connection)
|
|
565
566
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
567
|
+
try {
|
|
568
|
+
await this._currentTransaction.begin()
|
|
569
|
+
} catch (error) {
|
|
570
|
+
this._currentTransaction = null
|
|
571
|
+
throw error
|
|
572
|
+
}
|
|
573
|
+
})
|
|
572
574
|
}
|
|
573
575
|
|
|
574
576
|
async _commitTransactionAction() {
|
|
575
|
-
|
|
577
|
+
await this._runPhysicalConnectionRequest(async () => {
|
|
578
|
+
if (!this._currentTransaction) throw new Error("A transaction isn't running")
|
|
576
579
|
|
|
577
|
-
|
|
578
|
-
|
|
580
|
+
await this._currentTransaction.commit()
|
|
581
|
+
this._currentTransaction = null
|
|
582
|
+
})
|
|
579
583
|
}
|
|
580
584
|
|
|
581
585
|
async _rollbackTransactionAction() {
|
|
582
|
-
|
|
583
|
-
this.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
+
await this._runPhysicalConnectionRequest(async () => {
|
|
587
|
+
if (!this._currentTransaction) {
|
|
588
|
+
this.logger.debug("A transaction isn't running - ignoring because that can happen if something else has failed in the db")
|
|
589
|
+
return
|
|
590
|
+
}
|
|
586
591
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
592
|
+
try {
|
|
593
|
+
await this._currentTransaction.rollback()
|
|
594
|
+
} catch (transactionRollbackError) {
|
|
595
|
+
// When SQL Server has already aborted the transaction (e.g., a
|
|
596
|
+
// stale concurrent request triggered XACT_ABORT), the
|
|
597
|
+
// mssql.Transaction.rollback() call fails because the
|
|
598
|
+
// Transaction object is dead. Issue a raw ROLLBACK on the
|
|
599
|
+
// underlying connection to clear SQL Server's session-level
|
|
600
|
+
// aborted-transaction state so the connection is usable for the
|
|
601
|
+
// next BEGIN TRANSACTION.
|
|
602
|
+
this.logger.warn("Transaction.rollback() failed, clearing session state with raw ROLLBACK", {
|
|
603
|
+
error: transactionRollbackError instanceof Error ? transactionRollbackError.message : transactionRollbackError
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
const request = new mssql.Request(this.connection)
|
|
607
|
+
|
|
608
|
+
await request.query("IF @@TRANCOUNT > 0 ROLLBACK")
|
|
609
|
+
} finally {
|
|
610
|
+
this._currentTransaction = null
|
|
611
|
+
}
|
|
612
|
+
})
|
|
607
613
|
}
|
|
608
614
|
|
|
609
615
|
/**
|
|
@@ -54,6 +54,15 @@ export default class VelociousEnvironmentHandlerBase {
|
|
|
54
54
|
*/
|
|
55
55
|
runWithSharedTransactionCoordinatorOwner(_connection, _owner, callback) { return callback() }
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Runs work without inherited shared-transaction ownership for one connection.
|
|
59
|
+
* @template T
|
|
60
|
+
* @param {import("../database/drivers/base.js").default} _connection - Physical connection whose owner is cleared.
|
|
61
|
+
* @param {() => T} callback - Physical connection work.
|
|
62
|
+
* @returns {T} - Callback result.
|
|
63
|
+
*/
|
|
64
|
+
runWithoutSharedTransactionCoordinatorOwner(_connection, callback) { return callback() }
|
|
65
|
+
|
|
57
66
|
/**
|
|
58
67
|
* Runs work without inherited shared-transaction coordinator ownership.
|
|
59
68
|
* @template T
|
|
@@ -154,6 +154,22 @@ export default class VelociousEnvironmentHandlerNode extends Base{
|
|
|
154
154
|
return this._sharedTransactionCoordinatorAsyncLocalStorage.run(owners, callback)
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Runs work without inherited shared-transaction ownership for one connection.
|
|
159
|
+
* @template T
|
|
160
|
+
* @param {import("../database/drivers/base.js").default} connection - Physical connection whose owner is cleared.
|
|
161
|
+
* @param {() => T} callback - Physical connection work.
|
|
162
|
+
* @returns {T} - Callback result.
|
|
163
|
+
*/
|
|
164
|
+
runWithoutSharedTransactionCoordinatorOwner(connection, callback) {
|
|
165
|
+
if (!this._sharedTransactionCoordinatorAsyncLocalStorage) return callback()
|
|
166
|
+
|
|
167
|
+
const owners = new Map(this._sharedTransactionCoordinatorAsyncLocalStorage.getStore())
|
|
168
|
+
|
|
169
|
+
owners.delete(connection)
|
|
170
|
+
return this._sharedTransactionCoordinatorAsyncLocalStorage.run(owners, callback)
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
/**
|
|
158
174
|
* Runs work without inherited shared-transaction coordinator ownership.
|
|
159
175
|
* @template T
|
|
@@ -16,6 +16,7 @@ import {requestDetails} from "./error-reporting/request-details.js"
|
|
|
16
16
|
import RoutesResolver from "./routes/resolver.js"
|
|
17
17
|
import {ValidationError} from "./database/record/index.js"
|
|
18
18
|
import RecordNotFoundError from "./database/record/record-not-found-error.js"
|
|
19
|
+
import {captureFrontendModelRemoteRequestContext, mergeFrontendModelRemoteRequestContext} from "./frontend-models/remote-request-context.js"
|
|
19
20
|
import { normalizeDateStringForWrite } from "./database/datetime-storage.js"
|
|
20
21
|
import VelociousError from "./velocious-error.js"
|
|
21
22
|
import isDate from "./utils/is-date.js"
|
|
@@ -4360,13 +4361,17 @@ export default class FrontendModelController extends Controller {
|
|
|
4360
4361
|
}
|
|
4361
4362
|
|
|
4362
4363
|
try {
|
|
4364
|
+
const requestContext = captureFrontendModelRemoteRequestContext(requestEntry?.requestContext)
|
|
4363
4365
|
let responsePayload
|
|
4364
4366
|
|
|
4365
4367
|
if (isBuiltInCommand) {
|
|
4366
|
-
const commandParams =
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4368
|
+
const commandParams = mergeFrontendModelRemoteRequestContext(
|
|
4369
|
+
requestContext,
|
|
4370
|
+
{
|
|
4371
|
+
...(payload && typeof payload === "object" ? payload : {}),
|
|
4372
|
+
model
|
|
4373
|
+
}
|
|
4374
|
+
)
|
|
4370
4375
|
|
|
4371
4376
|
responsePayload = await this.withFrontendModelParams(commandParams, async () => {
|
|
4372
4377
|
return await this.withFrontendModelRequestContext(commandParams, this.response(), async () => {
|
|
@@ -4376,7 +4381,8 @@ export default class FrontendModelController extends Controller {
|
|
|
4376
4381
|
} else {
|
|
4377
4382
|
responsePayload = await this.frontendApiCustomCommandPayload({
|
|
4378
4383
|
customPath,
|
|
4379
|
-
payload
|
|
4384
|
+
payload,
|
|
4385
|
+
requestContext
|
|
4380
4386
|
})
|
|
4381
4387
|
}
|
|
4382
4388
|
|
|
@@ -4415,9 +4421,10 @@ export default class FrontendModelController extends Controller {
|
|
|
4415
4421
|
* @param {object} args - Arguments.
|
|
4416
4422
|
* @param {string} args.customPath - Custom backend route path.
|
|
4417
4423
|
* @param {ReturnType<typeof JSON.parse>} args.payload - Request payload.
|
|
4424
|
+
* @param {import("./remote-request-context.js").RemoteRequestContext} args.requestContext - Captured remote request context.
|
|
4418
4425
|
* @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Parsed JSON response payload.
|
|
4419
4426
|
*/
|
|
4420
|
-
async frontendApiCustomCommandPayload({customPath, payload}) {
|
|
4427
|
+
async frontendApiCustomCommandPayload({customPath, payload, requestContext}) {
|
|
4421
4428
|
const configuration = this.getConfiguration()
|
|
4422
4429
|
const response = new Response({configuration})
|
|
4423
4430
|
const resolver = new RoutesResolver({
|
|
@@ -4449,10 +4456,13 @@ export default class FrontendModelController extends Controller {
|
|
|
4449
4456
|
const viewPath = routeHookMatch?.viewPath || `${configuration.getDirectory()}/src/routes/${controller}`
|
|
4450
4457
|
resolver.routeHookControllerClass = routeHookMatch?.controllerClass
|
|
4451
4458
|
const controllerClass = await resolver.resolveControllerClass({controllerPath})
|
|
4452
|
-
const controllerParams =
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4459
|
+
const controllerParams = mergeFrontendModelRemoteRequestContext(
|
|
4460
|
+
requestContext,
|
|
4461
|
+
{
|
|
4462
|
+
...((payload && typeof payload === "object") ? payload : {}),
|
|
4463
|
+
...resolver.params
|
|
4464
|
+
}
|
|
4465
|
+
)
|
|
4456
4466
|
const controllerInstance = new controllerClass({
|
|
4457
4467
|
action,
|
|
4458
4468
|
configuration,
|
|
@@ -12,6 +12,8 @@ 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 {remoteRequestContextKey} from "../remote-request-context.js"
|
|
16
|
+
import {captureFrontendModelRemoteRequestContext, mergeFrontendModelRemoteRequestContext} from "./remote-request-context.js"
|
|
15
17
|
import {bufferOutgoingEvent, clearBufferedOutgoingEvents, drainBufferedOutgoingEvents} from "./outgoing-event-buffer.js"
|
|
16
18
|
import {defineModelScope} from "../utils/model-scope.js"
|
|
17
19
|
import isPlainObject from "../utils/plain-object.js"
|
|
@@ -132,6 +134,7 @@ import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQuer
|
|
|
132
134
|
* @property {string | (() => string | undefined | null)} [websocketUrl] - Optional websocket URL. When set, Velocious creates and manages its own websocket client internally. Subscriptions use the websocket; CRUD uses HTTP and falls back gracefully. Example: `"ws://localhost:3006/websocket"`.
|
|
133
135
|
* @property {{post: (path: string, body?: ReturnType<typeof JSON.parse>, options?: {headers?: Record<string, string>, signal?: AbortSignal}) => Promise<{json: () => ReturnType<typeof JSON.parse>}>, subscribe: (channel: string, options: {params?: Record<string, ReturnType<typeof JSON.parse>>}, callback: (payload: ReturnType<typeof JSON.parse>) => void) => (() => void), subscribeAndWait?: (channel: string, options: {params?: Record<string, ReturnType<typeof JSON.parse>>}, callback: (payload: ReturnType<typeof JSON.parse>) => void) => Promise<(() => void)>}} [websocketClient] - Optional websocket client for shared frontend-model API requests and subscriptions. Its `post` receives the bounded-deadline `signal` and should forward it into the underlying transport so the deadline can abort the live request and its response-body read.
|
|
134
136
|
* @property {Record<string, string> | (() => Record<string, string>)} [requestHeaders] - Extra HTTP/WS headers to attach to every frontend-model API request. Pass a function to compute them at request time (for example to include the current locale).
|
|
137
|
+
* @property {import("../remote-request-context.js").RemoteRequestContext | (() => import("../remote-request-context.js").RemoteRequestContext | undefined | null)} [requestContext] - Immutable scalar context captured independently when each operation or event subscription starts and sent for remote tenant/ability resolution.
|
|
135
138
|
* @property {number | (() => number | undefined | null)} [timeout] - Bounded deadline in milliseconds covering connection, response headers, and response-body consumption for each frontend-model API request. On expiry the live fetch/adapter request is aborted (built on awaitery's `timeout`) and awaitery's `TimeoutError` is thrown, so callers can classify a timeout via `error instanceof TimeoutError`. Pass a function to resolve it per request. Falsy/absent means no deadline.
|
|
136
139
|
* @property {AbortSignal | (() => AbortSignal | undefined | null)} [signal] - Optional caller/session AbortSignal composed with the deadline. Aborting it cancels the live request (for example on session shutdown or offline transition); the resulting abort error stays distinguishable from a timeout. Pass a function to resolve the current signal per request.
|
|
137
140
|
* @property {{get: () => string | null | undefined | Promise<string | null | undefined>, set: (sessionId: string) => void | Promise<void>, clear: () => void | Promise<void>}} [sessionStore] - Optional sessionId persistence hook forwarded to the internal `VelociousWebsocketClient` so WS sessions can be resumed across page reloads / app restarts.
|
|
@@ -157,8 +160,9 @@ const QUERY_DATA_KEY = "__queryData"
|
|
|
157
160
|
const ABILITIES_KEY = "__abilities"
|
|
158
161
|
/**
|
|
159
162
|
* Pending shared frontend model requests.
|
|
160
|
-
* @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: FrontendModelClass, payload: Record<string, ReturnType<typeof JSON.parse>>, requestId: string, resolve: (response: Record<string, ReturnType<typeof JSON.parse>>) => void, reject: (error: ReturnType<typeof JSON.parse>) => void, resourcePath?: string | null}>} */
|
|
163
|
+
* @type {Array<{commandName?: string, commandType: FrontendModelRequestCommandType, customPath?: string, modelClass: FrontendModelClass, payload: Record<string, ReturnType<typeof JSON.parse>>, requestContext: import("../remote-request-context.js").RemoteRequestContext, requestId: string, resolve: (response: Record<string, ReturnType<typeof JSON.parse>>) => void, reject: (error: ReturnType<typeof JSON.parse>) => void, resourcePath?: string | null}>} */
|
|
161
164
|
let pendingSharedFrontendModelRequests = []
|
|
165
|
+
|
|
162
166
|
let sharedFrontendModelRequestId = 0
|
|
163
167
|
let sharedFrontendModelFlushScheduled = false
|
|
164
168
|
let activeFrontendModelTransportRequestCount = 0
|
|
@@ -1450,9 +1454,11 @@ class FrontendModelEventSubscription {
|
|
|
1450
1454
|
/**
|
|
1451
1455
|
* Runs constructor.
|
|
1452
1456
|
* @param {FrontendModelClass} ModelClass - Frontend model class for this subscription bucket.
|
|
1457
|
+
* @param {import("../remote-request-context.js").RemoteRequestContext} requestContext - Captured subscription context.
|
|
1453
1458
|
*/
|
|
1454
|
-
constructor(ModelClass) {
|
|
1459
|
+
constructor(ModelClass, requestContext) {
|
|
1455
1460
|
this.ModelClass = ModelClass
|
|
1461
|
+
this.requestContext = requestContext
|
|
1456
1462
|
/**
|
|
1457
1463
|
* Narrows the runtime value to the documented type.
|
|
1458
1464
|
* @type {Set<FrontendModelModelEventCallbackEntry>} */
|
|
@@ -1530,11 +1536,14 @@ class FrontendModelEventSubscription {
|
|
|
1530
1536
|
}
|
|
1531
1537
|
: {}
|
|
1532
1538
|
|
|
1533
|
-
return
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1539
|
+
return mergeFrontendModelRemoteRequestContext(
|
|
1540
|
+
this.requestContext,
|
|
1541
|
+
{
|
|
1542
|
+
model: this.ModelClass.getModelName(),
|
|
1543
|
+
...eventFilterParams,
|
|
1544
|
+
...projectionPayload
|
|
1545
|
+
}
|
|
1546
|
+
)
|
|
1538
1547
|
}
|
|
1539
1548
|
|
|
1540
1549
|
/**
|
|
@@ -1681,41 +1690,79 @@ class FrontendModelEventSubscription {
|
|
|
1681
1690
|
|| this.instanceListeners.size > 0
|
|
1682
1691
|
|
|
1683
1692
|
if (hasAnyListener) return
|
|
1684
|
-
if (!this.channelHandle) return
|
|
1685
1693
|
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1694
|
+
if (this.channelHandle) {
|
|
1695
|
+
try {
|
|
1696
|
+
this.channelHandle.close()
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
console.error(error)
|
|
1699
|
+
}
|
|
1690
1700
|
}
|
|
1691
1701
|
|
|
1692
1702
|
this.channelHandle = null
|
|
1693
1703
|
this.readyPromise = null
|
|
1694
1704
|
this.subscriptionParamsKey = null
|
|
1705
|
+
releaseFrontendModelEventSubscription(this)
|
|
1695
1706
|
}
|
|
1696
1707
|
}
|
|
1697
1708
|
|
|
1698
1709
|
/**
|
|
1699
1710
|
* Frontend model event subscriptions.
|
|
1700
|
-
* @type {WeakMap<FrontendModelClass, FrontendModelEventSubscription
|
|
1711
|
+
* @type {WeakMap<FrontendModelClass, Map<string, FrontendModelEventSubscription>>} */
|
|
1701
1712
|
const frontendModelEventSubscriptions = new WeakMap()
|
|
1702
1713
|
|
|
1703
1714
|
/**
|
|
1704
1715
|
* Runs ensure frontend model event subscription.
|
|
1705
1716
|
* @param {FrontendModelClass} ModelClass - Model class.
|
|
1717
|
+
* @param {import("../remote-request-context.js").RemoteRequestContext} requestContext - Captured subscription context.
|
|
1706
1718
|
* @returns {FrontendModelEventSubscription} - Per-class subscription helper.
|
|
1707
1719
|
*/
|
|
1708
|
-
function ensureFrontendModelEventSubscription(ModelClass) {
|
|
1709
|
-
let
|
|
1720
|
+
function ensureFrontendModelEventSubscription(ModelClass, requestContext) {
|
|
1721
|
+
let subscriptions = frontendModelEventSubscriptions.get(ModelClass)
|
|
1722
|
+
|
|
1723
|
+
if (!subscriptions) {
|
|
1724
|
+
subscriptions = new Map()
|
|
1725
|
+
frontendModelEventSubscriptions.set(ModelClass, subscriptions)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
const contextKey = remoteRequestContextKey(requestContext)
|
|
1729
|
+
let sub = subscriptions.get(contextKey)
|
|
1710
1730
|
|
|
1711
1731
|
if (!sub) {
|
|
1712
|
-
sub = new FrontendModelEventSubscription(ModelClass)
|
|
1713
|
-
|
|
1732
|
+
sub = new FrontendModelEventSubscription(ModelClass, requestContext)
|
|
1733
|
+
subscriptions.set(contextKey, sub)
|
|
1714
1734
|
}
|
|
1715
1735
|
|
|
1716
1736
|
return sub
|
|
1717
1737
|
}
|
|
1718
1738
|
|
|
1739
|
+
/**
|
|
1740
|
+
* Removes an empty context bucket so switching through many tenants does not retain every snapshot.
|
|
1741
|
+
* @param {FrontendModelEventSubscription} subscription - Empty subscription bucket.
|
|
1742
|
+
* @returns {void}
|
|
1743
|
+
*/
|
|
1744
|
+
function releaseFrontendModelEventSubscription(subscription) {
|
|
1745
|
+
const subscriptions = frontendModelEventSubscriptions.get(subscription.ModelClass)
|
|
1746
|
+
const contextKey = remoteRequestContextKey(subscription.requestContext)
|
|
1747
|
+
|
|
1748
|
+
if (subscriptions?.get(contextKey) !== subscription) return
|
|
1749
|
+
|
|
1750
|
+
subscriptions.delete(contextKey)
|
|
1751
|
+
if (subscriptions.size === 0) frontendModelEventSubscriptions.delete(subscription.ModelClass)
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* Captures the current frontend-model transport context for one operation.
|
|
1756
|
+
* @returns {import("../remote-request-context.js").RemoteRequestContext} Frozen context snapshot.
|
|
1757
|
+
*/
|
|
1758
|
+
function frontendModelRequestContext() {
|
|
1759
|
+
const configuredContext = typeof frontendModelTransportConfig.requestContext === "function"
|
|
1760
|
+
? frontendModelTransportConfig.requestContext()
|
|
1761
|
+
: frontendModelTransportConfig.requestContext
|
|
1762
|
+
|
|
1763
|
+
return captureFrontendModelRemoteRequestContext(configuredContext)
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1719
1766
|
/**
|
|
1720
1767
|
* Runs ensure frontend model instance listener.
|
|
1721
1768
|
* @param {FrontendModelEventSubscription} sub - Event subscription bucket.
|
|
@@ -2007,6 +2054,7 @@ async function flushPendingSharedFrontendModelRequests() {
|
|
|
2007
2054
|
customPath: request.customPath,
|
|
2008
2055
|
model: request.modelClass.getModelName(),
|
|
2009
2056
|
payload: request.payload,
|
|
2057
|
+
...(Object.keys(request.requestContext).length > 0 ? {requestContext: request.requestContext} : {}),
|
|
2010
2058
|
requestId: request.requestId
|
|
2011
2059
|
}
|
|
2012
2060
|
}
|
|
@@ -2015,6 +2063,7 @@ async function flushPendingSharedFrontendModelRequests() {
|
|
|
2015
2063
|
commandType: request.commandType,
|
|
2016
2064
|
model: request.modelClass.getModelName(),
|
|
2017
2065
|
payload: request.payload,
|
|
2066
|
+
...(Object.keys(request.requestContext).length > 0 ? {requestContext: request.requestContext} : {}),
|
|
2018
2067
|
requestId: request.requestId
|
|
2019
2068
|
}
|
|
2020
2069
|
})
|
|
@@ -3016,6 +3065,10 @@ export default class FrontendModelBase {
|
|
|
3016
3065
|
frontendModelTransportConfig.requestHeaders = config.requestHeaders
|
|
3017
3066
|
}
|
|
3018
3067
|
|
|
3068
|
+
if (Object.prototype.hasOwnProperty.call(config, "requestContext")) {
|
|
3069
|
+
frontendModelTransportConfig.requestContext = config.requestContext
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3019
3072
|
if (Object.prototype.hasOwnProperty.call(config, "timeout")) {
|
|
3020
3073
|
frontendModelTransportConfig.timeout = config.timeout
|
|
3021
3074
|
}
|
|
@@ -3286,9 +3339,14 @@ export default class FrontendModelBase {
|
|
|
3286
3339
|
throw new Error("subscribeWebsocketChannel requires configureTransport({websocketUrl})")
|
|
3287
3340
|
}
|
|
3288
3341
|
|
|
3289
|
-
const {signal, timeoutMs, ...channelOptions} = options
|
|
3342
|
+
const {params, signal, timeoutMs, ...channelOptions} = options
|
|
3343
|
+
const requestContext = frontendModelRequestContext()
|
|
3344
|
+
const scopedParams = mergeFrontendModelRemoteRequestContext(requestContext, params === undefined ? {} : params)
|
|
3290
3345
|
const startupControls = frontendModelWebsocketStartupControls({signal, timeoutMs})
|
|
3291
|
-
const
|
|
3346
|
+
const scopedParamsOption = params === undefined && Object.keys(requestContext).length === 0
|
|
3347
|
+
? {}
|
|
3348
|
+
: {params: scopedParams}
|
|
3349
|
+
const handle = client.subscribeChannel(channelType, {...channelOptions, ...scopedParamsOption, ...startupControls})
|
|
3292
3350
|
|
|
3293
3351
|
if (typeof client.connect === "function") {
|
|
3294
3352
|
void client.connect(startupControls).catch(() => handle.close())
|
|
@@ -3649,7 +3707,7 @@ export default class FrontendModelBase {
|
|
|
3649
3707
|
* @returns {Promise<() => void>} - Unsubscribe callback.
|
|
3650
3708
|
*/
|
|
3651
3709
|
static async onCreate(callback, options = {}) {
|
|
3652
|
-
const sub = ensureFrontendModelEventSubscription(this)
|
|
3710
|
+
const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
|
|
3653
3711
|
const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
|
|
3654
3712
|
|
|
3655
3713
|
sub.classCreateCallbacks.add(entry)
|
|
@@ -3669,7 +3727,7 @@ export default class FrontendModelBase {
|
|
|
3669
3727
|
* @returns {Promise<() => void>} - Unsubscribe callback.
|
|
3670
3728
|
*/
|
|
3671
3729
|
static async onUpdate(callback, options = {}) {
|
|
3672
|
-
const sub = ensureFrontendModelEventSubscription(this)
|
|
3730
|
+
const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
|
|
3673
3731
|
const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
|
|
3674
3732
|
|
|
3675
3733
|
sub.classUpdateCallbacks.add(entry)
|
|
@@ -3691,7 +3749,7 @@ export default class FrontendModelBase {
|
|
|
3691
3749
|
static async onDestroy(callback, options = {}) {
|
|
3692
3750
|
assertNoDestroyEventFilter(this, options)
|
|
3693
3751
|
|
|
3694
|
-
const sub = ensureFrontendModelEventSubscription(this)
|
|
3752
|
+
const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
|
|
3695
3753
|
const entry = {callback}
|
|
3696
3754
|
|
|
3697
3755
|
sub.classDestroyCallbacks.add(entry)
|
|
@@ -3715,7 +3773,7 @@ export default class FrontendModelBase {
|
|
|
3715
3773
|
async onUpdate(callback, options = {}) {
|
|
3716
3774
|
const self = /** @type {ReturnType<typeof JSON.parse>} */ (this)
|
|
3717
3775
|
const ModelClass = frontendModelClassFor(this)
|
|
3718
|
-
const sub = ensureFrontendModelEventSubscription(ModelClass)
|
|
3776
|
+
const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
|
|
3719
3777
|
const id = String(self.id())
|
|
3720
3778
|
const entry = {callback, ...frontendModelEventOptionsPayload(ModelClass, options)}
|
|
3721
3779
|
const listener = ensureFrontendModelInstanceListener(sub, id, this)
|
|
@@ -3748,7 +3806,7 @@ export default class FrontendModelBase {
|
|
|
3748
3806
|
|
|
3749
3807
|
assertNoDestroyEventFilter(ModelClass, options)
|
|
3750
3808
|
|
|
3751
|
-
const sub = ensureFrontendModelEventSubscription(ModelClass)
|
|
3809
|
+
const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
|
|
3752
3810
|
const id = String(self.id())
|
|
3753
3811
|
const entry = {callback}
|
|
3754
3812
|
const listener = ensureFrontendModelInstanceListener(sub, id, this)
|
|
@@ -4596,6 +4654,8 @@ export default class FrontendModelBase {
|
|
|
4596
4654
|
const commandName = this.commandName(commandType)
|
|
4597
4655
|
const timeZone = frontendModelTransportTimeZone()
|
|
4598
4656
|
const serializedPayload = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
|
|
4657
|
+
const requestContext = frontendModelRequestContext()
|
|
4658
|
+
const requestPayload = mergeFrontendModelRemoteRequestContext(requestContext, serializedPayload)
|
|
4599
4659
|
const resourcePath = this.resourcePath()
|
|
4600
4660
|
const containsAttachmentUpload = frontendModelPayloadContainsAttachmentUpload(serializedPayload)
|
|
4601
4661
|
const useSharedTransport = !containsAttachmentUpload
|
|
@@ -4608,6 +4668,7 @@ export default class FrontendModelBase {
|
|
|
4608
4668
|
commandType,
|
|
4609
4669
|
modelClass: this,
|
|
4610
4670
|
payload: serializedPayload,
|
|
4671
|
+
requestContext,
|
|
4611
4672
|
reject,
|
|
4612
4673
|
requestId: `${++sharedFrontendModelRequestId}`,
|
|
4613
4674
|
resolve,
|
|
@@ -4635,7 +4696,7 @@ export default class FrontendModelBase {
|
|
|
4635
4696
|
},
|
|
4636
4697
|
async (signal) => {
|
|
4637
4698
|
const directResponse = await fetch(url, {
|
|
4638
|
-
body: JSON.stringify(
|
|
4699
|
+
body: JSON.stringify(requestPayload),
|
|
4639
4700
|
credentials: "include",
|
|
4640
4701
|
headers: frontendModelRequestHeaders(timeZone),
|
|
4641
4702
|
method: "POST",
|
|
@@ -4675,6 +4736,9 @@ export default class FrontendModelBase {
|
|
|
4675
4736
|
const {commandName, commandType, memberId = null, payload, resourcePath} = args
|
|
4676
4737
|
const timeZone = frontendModelTransportTimeZone()
|
|
4677
4738
|
const serializedPayload = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (serializeFrontendModelTransportValue(payload, {timeZone}))
|
|
4739
|
+
const requestContext = frontendModelRequestContext()
|
|
4740
|
+
|
|
4741
|
+
mergeFrontendModelRemoteRequestContext(requestContext, serializedPayload)
|
|
4678
4742
|
const customPath = frontendModelCustomCommandPath({
|
|
4679
4743
|
commandName,
|
|
4680
4744
|
memberId,
|
|
@@ -4688,6 +4752,7 @@ export default class FrontendModelBase {
|
|
|
4688
4752
|
customPath,
|
|
4689
4753
|
modelClass: this,
|
|
4690
4754
|
payload: serializedPayload,
|
|
4755
|
+
requestContext,
|
|
4691
4756
|
reject,
|
|
4692
4757
|
requestId: `${++sharedFrontendModelRequestId}`,
|
|
4693
4758
|
resolve
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {captureRemoteRequestContext, mergeRemoteRequestContext} from "../remote-request-context.js"
|
|
4
|
+
|
|
5
|
+
const RESERVED_KEYS = ["commandType", "customPath", "model", "payload", "requestContext", "requestId", "requests"]
|
|
6
|
+
const REQUEST_CONTEXT_LABEL = "Frontend model request context"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Captures one frontend-model operation's immutable remote request context.
|
|
10
|
+
* @param {ReturnType<typeof JSON.parse> | undefined} value - Configured or untrusted context value.
|
|
11
|
+
* @returns {import("../remote-request-context.js").RemoteRequestContext} Frozen context snapshot.
|
|
12
|
+
*/
|
|
13
|
+
export function captureFrontendModelRemoteRequestContext(value) {
|
|
14
|
+
return captureRemoteRequestContext(value, {
|
|
15
|
+
label: REQUEST_CONTEXT_LABEL,
|
|
16
|
+
reservedKeys: RESERVED_KEYS
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Merges captured context into frontend-model command or subscription params.
|
|
22
|
+
* @template {Record<string, ReturnType<typeof JSON.parse>>} TParams
|
|
23
|
+
* @param {import("../remote-request-context.js").RemoteRequestContext} context - Captured context.
|
|
24
|
+
* @param {TParams} params - Framework-owned params.
|
|
25
|
+
* @returns {TParams & import("../remote-request-context.js").RemoteRequestContext} Merged params.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeFrontendModelRemoteRequestContext(context, params) {
|
|
28
|
+
return mergeRemoteRequestContext({context, label: REQUEST_CONTEXT_LABEL, params})
|
|
29
|
+
}
|
|
@@ -26,6 +26,8 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
|
|
|
26
26
|
this.reconnectGeneration = 0
|
|
27
27
|
/** @type {Set<Promise<void>>} */
|
|
28
28
|
this.runningReconnectTasks = new Set()
|
|
29
|
+
/** @type {Promise<void> | null} */
|
|
30
|
+
this.gracefulClosePromise = null
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -55,6 +57,36 @@ export default class VelociousWebsocketClient extends SnapReqWebSocketClient {
|
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Closes the WebSocket as a normal shutdown so the server permanently
|
|
62
|
+
* releases resumable session state.
|
|
63
|
+
* @returns {Promise<void>} - Resolves once closed.
|
|
64
|
+
*/
|
|
65
|
+
async close() {
|
|
66
|
+
if (this.gracefulClosePromise) return await this.gracefulClosePromise
|
|
67
|
+
|
|
68
|
+
this.autoReconnect = false
|
|
69
|
+
const socket = this.socket
|
|
70
|
+
const closePromise = (async () => {
|
|
71
|
+
if (socket && socket.readyState === socket.OPEN) {
|
|
72
|
+
await new Promise((resolve) => {
|
|
73
|
+
socket.addEventListener("close", () => resolve(undefined), {once: true})
|
|
74
|
+
socket.close(1000)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await super.close()
|
|
79
|
+
})()
|
|
80
|
+
|
|
81
|
+
this.gracefulClosePromise = closePromise
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await closePromise
|
|
85
|
+
} finally {
|
|
86
|
+
if (this.gracefulClosePromise === closePromise) this.gracefulClosePromise = null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
/**
|
|
59
91
|
* Stops reconnect, drains work that already passed SnapReq's reconnect guard,
|
|
60
92
|
* and clears state changed by a stale attempt while it settled.
|
|
@@ -39,6 +39,7 @@ const WEBSOCKET_OPCODE_CLOSE = 0x8
|
|
|
39
39
|
const WEBSOCKET_OPCODE_PING = 0x9
|
|
40
40
|
const WEBSOCKET_OPCODE_PONG = 0xA
|
|
41
41
|
|
|
42
|
+
const WEBSOCKET_CLOSE_NORMAL = 1000
|
|
42
43
|
const WEBSOCKET_CLOSE_POLICY_VIOLATION = 1008
|
|
43
44
|
const WEBSOCKET_INBOUND_BACKLOG_CLOSE_REASON = "Inbound message backlog exceeded"
|
|
44
45
|
const WEBSOCKET_MAX_CLOSE_REASON_BYTES = 123
|
|
@@ -785,8 +786,10 @@ export default class VelociousHttpServerClientWebsocketSession {
|
|
|
785
786
|
}
|
|
786
787
|
|
|
787
788
|
if (opcode === WEBSOCKET_OPCODE_CLOSE) {
|
|
789
|
+
const allowResume = payload.length < 2 || payload.readUInt16BE(0) !== WEBSOCKET_CLOSE_NORMAL
|
|
790
|
+
|
|
788
791
|
this.sendGoodbye(this.client)
|
|
789
|
-
this._handleClose()
|
|
792
|
+
this._handleClose({allowResume})
|
|
790
793
|
continue
|
|
791
794
|
}
|
|
792
795
|
|