dsh-cherry-bridge 0.2.1 → 0.2.3
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.en.md +3 -3
- package/README.md +3 -3
- package/dist/index.mjs +253 -20
- package/docs/e2e-checklist.md +46 -0
- package/package.json +1 -1
- package/src/AGENTS.md +12 -16
- package/src/connection.ts +73 -5
- package/src/diagnostics.ts +37 -5
- package/src/index.ts +15 -1
- package/src/subscriptions.ts +280 -8
- package/src/tools.ts +10 -5
package/src/AGENTS.md
CHANGED
|
@@ -36,6 +36,7 @@ in [`docs/e2e-checklist.md`](../docs/e2e-checklist.md).
|
|
|
36
36
|
| `recordBytes` | 65,536 (64 KiB) | one JSON-RPC record above it **aborts the connection** — the transport answers by killing it, so every in-flight request fails |
|
|
37
37
|
| `contentBytes` | 24,576 (24 KiB) | the slice size for one `agent.content.read` |
|
|
38
38
|
| `pageItems` | 100 | the protocol's single-page hard cap; `hello` may negotiate smaller |
|
|
39
|
+
| `unackedBytes` | 262,144 (256 KiB) | the peer HOLDS `agent.events` beyond this much un-acked delivery — terminal included; the layer acks at ¼ of it (hello value, else this) (issue #212) |
|
|
39
40
|
|
|
40
41
|
Through 2.1.7 the desktop's `pageOf` pages by **count alone** (2.1.8's byte
|
|
41
42
|
budget aside, still count-paged): `pageItems: 100` fat parts produced a record
|
|
@@ -187,8 +188,7 @@ projection. An unchanged message costs no repeat `agent.parts.list`
|
|
|
187
188
|
RPC on send → wait → read rounds, while every read still re-fetches and
|
|
188
189
|
sha256-verifies every `{ref}`'s bytes (a corrupted ref behind an unchanged
|
|
189
190
|
revision fails with `ContentIntegrityError`; `toolsReadCache.test.ts` T1b pins
|
|
190
|
-
this beside the cache).
|
|
191
|
-
every part over 4,096 characters.
|
|
191
|
+
this beside the cache).
|
|
192
192
|
|
|
193
193
|
- **Entry & key**: the `agent.parts.list` result — `{revision, page}` — keyed
|
|
194
194
|
`<deviceId>\0<messageId>` (device-scoped: a message id is the peer's; two
|
|
@@ -654,8 +654,7 @@ one host; whole-replace `hosts` defaults new hosts to
|
|
|
654
654
|
`ws`/port; a CHANGED `port` re-points EVERY final host, the current value a map
|
|
655
655
|
no-op (the settings UI sends both on save). `GET /devices` reports both maps and
|
|
656
656
|
`forgottenHosts` (addresses and intent, not credentials); tools/diagnostics show
|
|
657
|
-
only `port`.
|
|
658
|
-
page shows the per-host ports only when they differ from the record-level one.
|
|
657
|
+
only `port`.
|
|
659
658
|
TLS trust is operator config (`wsTls`: FLAT `tls.connect` `rejectUnauthorized`/
|
|
660
659
|
`ca` only), forwarded to both dial sites (pool, pairing). `resolveBridgeConfig`
|
|
661
660
|
validates it: unknown key or unusable value warns by FIELD NAME, WHOLE object
|
|
@@ -785,9 +784,8 @@ that is not valid URL encoding) · `last-address` 400 (the action would leave no
|
|
|
785
784
|
to dial; nothing written) · `invalid-device` 400 (the store refused) · `not-found`
|
|
786
785
|
404 (unknown device OR action — also what a QUARANTINED archive answers, its view
|
|
787
786
|
being empty) · `method-not-allowed` 405 (before the body) · `archive-quarantined`
|
|
788
|
-
503 (`{file, corrupt, recovery}`; a damaged archive
|
|
789
|
-
|
|
790
|
-
shipped store, kept so the arm exists); anything else is the generic 500
|
|
787
|
+
503 (`{file, corrupt, recovery}`; a damaged archive 404s first, so this arm covers
|
|
788
|
+
only a store that refuses after that read); anything else is the generic 500
|
|
791
789
|
`internal-error`.
|
|
792
790
|
|
|
793
791
|
> The minimal `pairing-progress` frame shape is `{stage, detail?}`; this plugin
|
|
@@ -832,10 +830,11 @@ the error message).
|
|
|
832
830
|
- **May be emitted** (`sanitizeDevice`): `deviceId`, `displayName`, `state`,
|
|
833
831
|
`enabled`, `hosts`, `port`, a **12-char digest** of `desktopIdentity`, the
|
|
834
832
|
hello-negotiated `limits` (`heartbeatMs`/`subscriptions`/`pageItems`/
|
|
835
|
-
`inFlightRequests` — **only with a live connection**;
|
|
836
|
-
|
|
837
|
-
hello capability bit, absent when not advertised), `sessionId
|
|
838
|
-
cursors (unsubscribed
|
|
833
|
+
`inFlightRequests` — **only with a live connection**; it never dials or wakes
|
|
834
|
+
a device for fallbacks), `messageSummaries` (the
|
|
835
|
+
hello capability bit, absent when not advertised), `sessionId`, session
|
|
836
|
+
cursors (none when unsubscribed) and their ledger (`deliveredBytes`/
|
|
837
|
+
`unackedBytes`/`acks`, #217), the `stats` counters
|
|
839
838
|
(`connections`/`sockets`/`connectionsBuilt`/`subscriptions`), the two resolved
|
|
840
839
|
`config` keys, and error `reason`/`message`.
|
|
841
840
|
- **Never emitted**: `accessToken`, `tokenExpiresAt`, `invitationSecret`, the
|
|
@@ -844,9 +843,8 @@ the error message).
|
|
|
844
843
|
construction**, not by a deny-list. `limits` is re-projected from its four
|
|
845
844
|
keys: a whitelist inside the whitelist.
|
|
846
845
|
- `stats.connectionsBuilt` counts connections built since the last `apply`, not
|
|
847
|
-
RPC requests
|
|
848
|
-
|
|
849
|
-
statistics describe **only** the current assembly.
|
|
846
|
+
RPC requests (those belong to the transport). Counters and the error ring are
|
|
847
|
+
zeroed on every `apply`: they describe **only** the current assembly.
|
|
850
848
|
|
|
851
849
|
### Logging: levels, identifiers, and what never appears
|
|
852
850
|
|
|
@@ -944,6 +942,4 @@ faces:
|
|
|
944
942
|
answer is the byte-safe page **clamp**: never ask for a page that cannot fit.
|
|
945
943
|
- Its read path refuses an unusable `byteLength`; we fall back to a slice bound
|
|
946
944
|
plus the cumulative arm.
|
|
947
|
-
- Its read path never replays a read; we replay reads **once** (deliberate
|
|
948
|
-
enhancement) while both sides keep commands receipt-recovered.
|
|
949
945
|
|
package/src/connection.ts
CHANGED
|
@@ -38,6 +38,17 @@ export interface EffectiveLimits {
|
|
|
38
38
|
subscriptions: number
|
|
39
39
|
pageItems: number
|
|
40
40
|
inFlightRequests: number
|
|
41
|
+
/**
|
|
42
|
+
* The peer's hello-advertised UN-ACKED DELIVERY WINDOW (protocol
|
|
43
|
+
* `remoteLimits.unackedBytes`). Past this much delivered-but-unacknowledged
|
|
44
|
+
* traffic the desktop holds its event stream (issue #212), so the
|
|
45
|
+
* subscription layer sizes its own ack watermark from it.
|
|
46
|
+
*
|
|
47
|
+
* ABSENT, never defaulted — same rule as `messageSummaries` below: a hello
|
|
48
|
+
* that does not carry the key leaves the layer on the pinned protocol
|
|
49
|
+
* constant rather than on a guess about this peer.
|
|
50
|
+
*/
|
|
51
|
+
unackedBytes?: number
|
|
41
52
|
/**
|
|
42
53
|
* The peer's hello-advertised `agentMessageSummariesVersion` (issue #105),
|
|
43
54
|
* stored BESIDE the limits because it is re-read by that same handshake.
|
|
@@ -885,6 +896,29 @@ export class DeviceConnection {
|
|
|
885
896
|
#pendingPings = new Set<{ nonce: string; sentAt: number }>()
|
|
886
897
|
#token: string | undefined
|
|
887
898
|
#tokenExpiresAt: number | undefined
|
|
899
|
+
/**
|
|
900
|
+
* Hosts whose LAST attempt failed for that ADDRESS's own reason (issue #220).
|
|
901
|
+
* A pass dials the hosts it has not failed yet FIRST, so a stale candidate
|
|
902
|
+
* sitting first in `record.hosts` cannot cost its dial timeout on every
|
|
903
|
+
* reconnect — the rule the official client states as "failed candidates
|
|
904
|
+
* rotate behind untried addresses across retries, so the round budget cannot
|
|
905
|
+
* permanently starve a later route"
|
|
906
|
+
* (`.scratch-csa/docs/references/remote-access/connectivity.md` §Connection
|
|
907
|
+
* lifetime; the tree is gitignored, and its remote-agent directory is the
|
|
908
|
+
* same `16e505fe` at app tags v0.5.2 and v0.5.3, so this is not a rule that
|
|
909
|
+
* arrived with a newer app).
|
|
910
|
+
*
|
|
911
|
+
* Demotion is ORDER, never exclusion: with every host marked the list falls
|
|
912
|
+
* back to record order, so a record whose only address is marked still dials
|
|
913
|
+
* it. A mark is cleared when that host itself connects, so a repaired address
|
|
914
|
+
* returns to the front on its own — no expiry timer, no persistence, no
|
|
915
|
+
* config key. A pairing-level verdict and a cancellation never mark (they are
|
|
916
|
+
* not this address's fault: the line `isPerHostFailure` draws for the
|
|
917
|
+
* handshake face, mirrored explicitly in the dial face, where only
|
|
918
|
+
* `IdentityMismatchError` can arise today), and `close()` keeps the marks
|
|
919
|
+
* because they describe addresses, not this lifecycle.
|
|
920
|
+
*/
|
|
921
|
+
readonly #demotedHosts = new Set<string>()
|
|
888
922
|
|
|
889
923
|
readonly #wsOptions: WsDialOptions | undefined
|
|
890
924
|
|
|
@@ -1244,7 +1278,7 @@ export class DeviceConnection {
|
|
|
1244
1278
|
}
|
|
1245
1279
|
|
|
1246
1280
|
let lastError: Error = new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`)
|
|
1247
|
-
for (const host of this
|
|
1281
|
+
for (const host of this.#dialOrder()) {
|
|
1248
1282
|
// CANCELLATION GATE, at the top of every candidate. `close()` bumps the
|
|
1249
1283
|
// epoch and clears `#running`, so either check is enough — both are here
|
|
1250
1284
|
// because they answer different halves of the same question ("is this
|
|
@@ -1268,7 +1302,15 @@ export class DeviceConnection {
|
|
|
1268
1302
|
link = await this.#dial(host, port, epoch)
|
|
1269
1303
|
} catch (error) {
|
|
1270
1304
|
if (error instanceof IdentityMismatchError) throw error
|
|
1305
|
+
// A cancellation is not this ADDRESS's fault, so it neither marks it
|
|
1306
|
+
// nor continues the pass. UNREACHABLE today — `#dial`'s AbortController
|
|
1307
|
+
// is aborted only through `#teardownLink`, which needs `#link`, and
|
|
1308
|
+
// `#link` is not committed until the dial returns — so this is a
|
|
1309
|
+
// DEFENSIVE arm, not a pinned one: it keeps the rule true if the dial
|
|
1310
|
+
// ever grows a close path, at the cost of two lines and no behaviour.
|
|
1311
|
+
if (error instanceof CancelledError) throw error
|
|
1271
1312
|
lastError = error instanceof Error ? error : new Error(String(error))
|
|
1313
|
+
this.#demotedHosts.add(host)
|
|
1272
1314
|
continue
|
|
1273
1315
|
}
|
|
1274
1316
|
// Commit the link before the RPC handshake so every failure path below
|
|
@@ -1277,6 +1319,8 @@ export class DeviceConnection {
|
|
|
1277
1319
|
this.#link = link
|
|
1278
1320
|
try {
|
|
1279
1321
|
await this.#handshake(link, epoch)
|
|
1322
|
+
// This address works: any mark an earlier pass left on it is stale.
|
|
1323
|
+
this.#demotedHosts.delete(host)
|
|
1280
1324
|
return
|
|
1281
1325
|
} catch (error) {
|
|
1282
1326
|
// A close() that landed mid-handshake surfaces here as the failure of
|
|
@@ -1288,10 +1332,11 @@ export class DeviceConnection {
|
|
|
1288
1332
|
// The terminal faces end the pass: they describe the PAIRING, not this
|
|
1289
1333
|
// address, so trying the next candidate would only repeat them.
|
|
1290
1334
|
if (!isPerHostFailure(error)) throw error
|
|
1291
|
-
// A
|
|
1292
|
-
// ADDRESS's problem:
|
|
1293
|
-
//
|
|
1294
|
-
// fall through to the next candidate.
|
|
1335
|
+
// A peer that answers hello/authenticate with a per-address failure is
|
|
1336
|
+
// this ADDRESS's problem too: demote it for the NEXT pass, tear the
|
|
1337
|
+
// link down here (the loop's next iteration would otherwise overwrite
|
|
1338
|
+
// `#link` and leak this socket), and fall through to the next candidate.
|
|
1339
|
+
this.#demotedHosts.add(host)
|
|
1295
1340
|
this.#teardownLink()
|
|
1296
1341
|
lastError = error instanceof Error ? error : new Error(String(error))
|
|
1297
1342
|
continue
|
|
@@ -1300,6 +1345,27 @@ export class DeviceConnection {
|
|
|
1300
1345
|
throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError)
|
|
1301
1346
|
}
|
|
1302
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* The pass's dial order (issue #220): hosts this connection has NOT failed
|
|
1350
|
+
* yet, in record order, then the demoted ones, in record order.
|
|
1351
|
+
*
|
|
1352
|
+
* Record order is preserved INSIDE each half, so an ordinary single-address
|
|
1353
|
+
* record, a fresh record and an all-demoted record all dial exactly the list
|
|
1354
|
+
* the record carries — the demotion can only move an address that failed
|
|
1355
|
+
* behind addresses that did not, and can never drop one.
|
|
1356
|
+
*/
|
|
1357
|
+
#dialOrder(): readonly string[] {
|
|
1358
|
+
const hosts = this.record.hosts
|
|
1359
|
+
if (this.#demotedHosts.size === 0) return hosts
|
|
1360
|
+
const untried: string[] = []
|
|
1361
|
+
const demoted: string[] = []
|
|
1362
|
+
for (const host of hosts) {
|
|
1363
|
+
if (this.#demotedHosts.has(host)) demoted.push(host)
|
|
1364
|
+
else untried.push(host)
|
|
1365
|
+
}
|
|
1366
|
+
return [...untried, ...demoted]
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1303
1369
|
async #dial(host: string, port: number, epoch: number): Promise<LiveLink> {
|
|
1304
1370
|
// (see {@link DeviceConnectionOptions.wsOptions} for TLS trust)
|
|
1305
1371
|
// The URL comes from the SAME helper the pairing dial uses (issue #126):
|
|
@@ -1402,11 +1468,13 @@ export class DeviceConnection {
|
|
|
1402
1468
|
// reconnect to a peer that no longer advertises the capability (or a
|
|
1403
1469
|
// downgrade) cannot inherit a stale `messageSummaries` from the previous
|
|
1404
1470
|
// link — the exact failure mode that would send `partsMode` to a 2.1.7 peer.
|
|
1471
|
+
const unackedBytes = positive(hello.limits?.['unackedBytes'])
|
|
1405
1472
|
this.#limits = {
|
|
1406
1473
|
heartbeatMs: positive(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
|
|
1407
1474
|
subscriptions: positive(hello.limits?.['subscriptions']) ?? FALLBACK_LIMITS.subscriptions,
|
|
1408
1475
|
pageItems: positive(hello.limits?.['pageItems']) ?? FALLBACK_LIMITS.pageItems,
|
|
1409
1476
|
inFlightRequests: positive(hello.limits?.['inFlightRequests']) ?? FALLBACK_LIMITS.inFlightRequests,
|
|
1477
|
+
...(unackedBytes === undefined ? {} : { unackedBytes }),
|
|
1410
1478
|
...(typeof hello.agentMessageSummariesVersion === 'number'
|
|
1411
1479
|
? { messageSummaries: hello.agentMessageSummariesVersion }
|
|
1412
1480
|
: {}),
|
package/src/diagnostics.ts
CHANGED
|
@@ -199,7 +199,7 @@ export interface DiagnosticsDevice {
|
|
|
199
199
|
* `partsMode: 'paged'`.
|
|
200
200
|
*
|
|
201
201
|
* Its own field, not a fifth key inside `limits`: it is a VERSION, not a
|
|
202
|
-
* count, and `limits` is
|
|
202
|
+
* count, and `limits` here is the four numeric caps this report CARRIES. Present only
|
|
203
203
|
* when a LIVE connection reported it (the same rule as `limits`: the endpoint
|
|
204
204
|
* never dials to find out). ABSENT therefore means "not advertised **or** not
|
|
205
205
|
* connected" — every desktop through 2.1.7, and any device whose link is
|
|
@@ -216,6 +216,16 @@ export interface DiagnosticsSession {
|
|
|
216
216
|
deviceId: string
|
|
217
217
|
subscriptions: number
|
|
218
218
|
cursor?: { sessionId: string; streamEpoch: string; seq: string }
|
|
219
|
+
/**
|
|
220
|
+
* Issue #217: the layer's own delivery ledger for THIS subscription — payload
|
|
221
|
+
* bytes received since it was installed, how many of them the peer may still
|
|
222
|
+
* count as un-acknowledged, and successful acks. Absent when the layer does
|
|
223
|
+
* not report it (an unsubscribed session has no ledger); counts describe the
|
|
224
|
+
* current subscription, so a re-subscribe restarts them.
|
|
225
|
+
*/
|
|
226
|
+
deliveredBytes?: number
|
|
227
|
+
unackedBytes?: number
|
|
228
|
+
acks?: number
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
/** One recent error summary (spec §5: 「最近错误摘要」). */
|
|
@@ -324,6 +334,10 @@ export interface SubscriptionStat {
|
|
|
324
334
|
sessionId: string
|
|
325
335
|
subscriptions: number
|
|
326
336
|
cursor?: { sessionId: string; streamEpoch: string; seq: string }
|
|
337
|
+
/** See {@link DiagnosticsSession.deliveredBytes} (issue #217). */
|
|
338
|
+
deliveredBytes?: number
|
|
339
|
+
unackedBytes?: number
|
|
340
|
+
acks?: number
|
|
327
341
|
}
|
|
328
342
|
|
|
329
343
|
/**
|
|
@@ -555,10 +569,11 @@ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
|
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
/**
|
|
558
|
-
* Re-projects the four numeric limit keys, dropping any
|
|
559
|
-
* caller's `limits` object might carry
|
|
560
|
-
*
|
|
561
|
-
*
|
|
572
|
+
* Re-projects the four numeric limit keys this report carries, dropping any
|
|
573
|
+
* foreign key that a caller's `limits` object might carry — including
|
|
574
|
+
* `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
|
|
575
|
+
* A whitelist inside the whitelist: the shape is rebuilt field by field rather
|
|
576
|
+
* than spread, so a future addition cannot arrive here unvetted.
|
|
562
577
|
*/
|
|
563
578
|
function projectLimits(raw: unknown): { limits?: EffectiveLimits } {
|
|
564
579
|
if (raw === null || typeof raw !== 'object') return {}
|
|
@@ -576,6 +591,17 @@ function numberOr(value: unknown, fallback: number): number {
|
|
|
576
591
|
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
|
577
592
|
}
|
|
578
593
|
|
|
594
|
+
/**
|
|
595
|
+
* A delivery-ledger counter (issue #217): a NON-NEGATIVE finite number, or
|
|
596
|
+
* nothing at all. Absence means "not reported"; a fabricated zero would read as
|
|
597
|
+
* "nothing was delivered", which is a claim this projection has no evidence for.
|
|
598
|
+
*/
|
|
599
|
+
function countEntry(key: 'deliveredBytes' | 'unackedBytes' | 'acks', raw: unknown): Partial<DiagnosticsSession> {
|
|
600
|
+
return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0
|
|
601
|
+
? ({ [key]: raw } as Partial<DiagnosticsSession>)
|
|
602
|
+
: {}
|
|
603
|
+
}
|
|
604
|
+
|
|
579
605
|
/** The loose-bag view every projection in this module takes of its input. */
|
|
580
606
|
function bag(raw: unknown): Record<string, unknown> {
|
|
581
607
|
return (raw ?? {}) as Record<string, unknown>
|
|
@@ -727,6 +753,9 @@ export function sanitizeDiagnostics(source: {
|
|
|
727
753
|
seq: typeof cursor['seq'] === 'string' ? cursor['seq'] : '',
|
|
728
754
|
},
|
|
729
755
|
}),
|
|
756
|
+
...countEntry('deliveredBytes', value['deliveredBytes']),
|
|
757
|
+
...countEntry('unackedBytes', value['unackedBytes']),
|
|
758
|
+
...countEntry('acks', value['acks']),
|
|
730
759
|
}
|
|
731
760
|
}),
|
|
732
761
|
recentErrors: (source.recentErrors ?? []).map((raw): DiagnosticsError => {
|
|
@@ -807,6 +836,9 @@ export function buildDiagnostics(
|
|
|
807
836
|
deviceId: entry.deviceId,
|
|
808
837
|
subscriptions: entry.subscriptions,
|
|
809
838
|
...(entry.cursor === undefined ? {} : { cursor: { ...entry.cursor } }),
|
|
839
|
+
...(entry.deliveredBytes === undefined ? {} : { deliveredBytes: entry.deliveredBytes }),
|
|
840
|
+
...(entry.unackedBytes === undefined ? {} : { unackedBytes: entry.unackedBytes }),
|
|
841
|
+
...(entry.acks === undefined ? {} : { acks: entry.acks }),
|
|
810
842
|
})
|
|
811
843
|
}
|
|
812
844
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -664,6 +664,11 @@ function subsFor(deviceId: string): SessionSubscriptions {
|
|
|
664
664
|
const created = new SessionSubscriptions(connection.rpc, {
|
|
665
665
|
subscriptionsLimit: connection.limits.subscriptions,
|
|
666
666
|
perSessionCacheBytes: state.perSessionCacheBytes,
|
|
667
|
+
// The peer's OWN un-acked window when hello advertised it (protocol 0.7.0
|
|
668
|
+
// peers do); absent falls back to the pinned constant inside the layer.
|
|
669
|
+
// A PROVIDER, not a value: the layer outlives a reconnect, and `limits` is
|
|
670
|
+
// rebuilt by every hello — a pinned number would freeze the first answer.
|
|
671
|
+
unackedBytes: () => connection.limits.unackedBytes,
|
|
667
672
|
})
|
|
668
673
|
state.subscriptions.set(deviceId, created)
|
|
669
674
|
// INFO (spec §5 「订阅建立」): the layer is announced with the cap it was
|
|
@@ -873,7 +878,16 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
|
|
|
873
878
|
[...state.subscriptions.entries()].flatMap(([deviceId, layer]) =>
|
|
874
879
|
layer.activeSessions().map((sessionId) => {
|
|
875
880
|
const cursor = layer.cursorOf(sessionId)
|
|
876
|
-
|
|
881
|
+
// The layer's own delivery ledger (issue #217), beside the cursor it
|
|
882
|
+
// is measured against: both come from ONE session state read.
|
|
883
|
+
const delivery = layer.deliveryOf(sessionId)
|
|
884
|
+
return {
|
|
885
|
+
deviceId,
|
|
886
|
+
sessionId,
|
|
887
|
+
subscriptions: 1,
|
|
888
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
889
|
+
...(delivery === undefined ? {} : delivery),
|
|
890
|
+
}
|
|
877
891
|
}),
|
|
878
892
|
),
|
|
879
893
|
diagnosticsErrors: () => [...state.recentErrors],
|