dsh-cherry-bridge 0.2.0 → 0.2.2
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 +7 -7
- package/README.md +6 -5
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +808 -47
- package/docs/e2e-checklist.md +72 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +106 -22
- package/src/connection.ts +187 -2
- package/src/deviceStore.ts +314 -1
- package/src/diagnostics.ts +6 -5
- package/src/httpApi.ts +234 -27
- package/src/index.ts +18 -0
- package/src/subscriptions.ts +201 -5
- package/src/tools.ts +158 -14
- package/ui/AGENTS.md +21 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +106 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +39 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +7 -0
package/src/httpApi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
2
|
+
import { LastCandidateHostError, QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
3
3
|
import type { DeviceRecord } from './deviceStore.js'
|
|
4
4
|
import type { DeviceStore } from './deviceStore.js'
|
|
5
5
|
import type { DeviceState, EffectiveLimits } from './connection.js'
|
|
@@ -119,6 +119,14 @@ export interface DeviceSummary {
|
|
|
119
119
|
security: Record<string, 'ws' | 'wss'>
|
|
120
120
|
/** The port each host is dialed with (issue #135); see `DeviceRecord`. */
|
|
121
121
|
ports: Record<string, number>
|
|
122
|
+
/**
|
|
123
|
+
* The addresses the operator has forgotten (issue #185): not dialed, and not
|
|
124
|
+
* re-learned until `cherry_devices` remembers them (or a PATCH puts one back
|
|
125
|
+
* into `hosts`, which clears its mark). Reported beside the address maps for
|
|
126
|
+
* the same reason they are: it is archive state an operator edits, not a
|
|
127
|
+
* credential.
|
|
128
|
+
*/
|
|
129
|
+
forgottenHosts: string[]
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
/** The `device-state` SSE payload. */
|
|
@@ -592,11 +600,17 @@ function parsePatch(body: Record<string, unknown>): DevicePatch {
|
|
|
592
600
|
if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
|
|
593
601
|
return normalizeHost(entry)
|
|
594
602
|
})
|
|
595
|
-
// Checked AFTER normalization: `[::1]` and `::1` collapse to one host
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
|
|
603
|
+
// Checked AFTER normalization: `[::1]` and `::1` collapse to one host, and
|
|
604
|
+
// host comparison is CASE-INSENSITIVE everywhere else on this face (the
|
|
605
|
+
// learn merge, the forget/remember matching, the mark clearing), so
|
|
606
|
+
// `NAS.example` and `nas.example` are one address too. The scheme/port maps
|
|
607
|
+
// are keyed by host, so a duplicate has no single entry and silently
|
|
608
|
+
// last-wins — an ambiguous record, not an edit.
|
|
609
|
+
const seen = new Set<string>()
|
|
610
|
+
for (const host of patch.hosts) {
|
|
611
|
+
const key = host.toLowerCase()
|
|
612
|
+
if (seen.has(key)) throw new PatchError('hosts must not contain duplicates')
|
|
613
|
+
seen.add(key)
|
|
600
614
|
}
|
|
601
615
|
}
|
|
602
616
|
|
|
@@ -1017,6 +1031,28 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
|
|
|
1017
1031
|
return
|
|
1018
1032
|
}
|
|
1019
1033
|
|
|
1034
|
+
// The two endpoint actions (issue #204) sit UNDER the device resource
|
|
1035
|
+
// because that is what they act on: they are the page's door onto #185's
|
|
1036
|
+
// forget/remember, one action per request, so a "patch AND forget" body
|
|
1037
|
+
// with a contradictory ordering never exists.
|
|
1038
|
+
if (first === 'devices' && match.segments.length === 3 && second !== undefined) {
|
|
1039
|
+
const deviceId = decodeSegment(second)
|
|
1040
|
+
if (deviceId === undefined) {
|
|
1041
|
+
writeError(res, 400, 'invalid-request', 'device id is not valid URL encoding')
|
|
1042
|
+
return
|
|
1043
|
+
}
|
|
1044
|
+
const action = match.segments[2]
|
|
1045
|
+
if (action !== 'forget' && action !== 'remember') {
|
|
1046
|
+
writeError(res, 404, 'not-found', 'unknown route')
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
if (method !== 'POST') {
|
|
1050
|
+
writeError(res, 405, 'method-not-allowed', 'POST only')
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
return await changeEndpoints(req, res, route, deviceId, action)
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1020
1056
|
if (first === 'pair' && match.segments.length === 1) {
|
|
1021
1057
|
if (method !== 'POST') {
|
|
1022
1058
|
writeError(res, 405, 'method-not-allowed', 'POST only')
|
|
@@ -1071,7 +1107,23 @@ async function handleRequest(req: HttpRequestLike, res: HttpResponseLike, route:
|
|
|
1071
1107
|
}
|
|
1072
1108
|
|
|
1073
1109
|
function listDevices(res: HttpResponseLike, route: RouteContext): void {
|
|
1074
|
-
|
|
1110
|
+
writeJson(
|
|
1111
|
+
res,
|
|
1112
|
+
200,
|
|
1113
|
+
route.deps.store.list().map((device) => deviceSummary(route, device)),
|
|
1114
|
+
)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* One device as the browser sees it: the public archive projection, never a
|
|
1119
|
+
* credential.
|
|
1120
|
+
*
|
|
1121
|
+
* ONE builder, because FOUR faces answer this shape — `GET /devices`, the
|
|
1122
|
+
* `PATCH`, and the two endpoint actions of issue #204 — and a hand-written copy
|
|
1123
|
+
* per face is how a field ends up on one and not the others.
|
|
1124
|
+
*/
|
|
1125
|
+
function deviceSummary(route: RouteContext, device: DeviceRecord): DeviceSummary {
|
|
1126
|
+
return {
|
|
1075
1127
|
deviceId: device.deviceId,
|
|
1076
1128
|
displayName: device.displayName,
|
|
1077
1129
|
state: projectState(device, route.deps.connect),
|
|
@@ -1080,8 +1132,10 @@ function listDevices(res: HttpResponseLike, route: RouteContext): void {
|
|
|
1080
1132
|
port: device.port,
|
|
1081
1133
|
security: { ...device.security },
|
|
1082
1134
|
ports: { ...device.ports },
|
|
1083
|
-
|
|
1084
|
-
|
|
1135
|
+
// An empty list is the honest "none", not an absent key: the page renders
|
|
1136
|
+
// the forgotten row only when it is non-empty.
|
|
1137
|
+
forgottenHosts: [...(device.forgottenHosts ?? [])],
|
|
1138
|
+
}
|
|
1085
1139
|
}
|
|
1086
1140
|
|
|
1087
1141
|
/**
|
|
@@ -1407,8 +1461,20 @@ async function patchDevice(
|
|
|
1407
1461
|
: patch.hosts === undefined
|
|
1408
1462
|
? previousPorts
|
|
1409
1463
|
: portsFor(patch.hosts, previousPorts, previousPort)
|
|
1464
|
+
// A host the operator puts BACK into `hosts` is dialed again, so its forget
|
|
1465
|
+
// mark must go: `hosts` and `forgottenHosts` are disjoint by contract, and an
|
|
1466
|
+
// explicit list is intent to dial. A PATCH that does not touch `hosts`
|
|
1467
|
+
// (displayName/enabled/port) leaves every mark alone. Case-insensitive, the
|
|
1468
|
+
// same comparison the learn merge uses.
|
|
1469
|
+
const nextForgotten =
|
|
1470
|
+
patch.hosts === undefined || existing.forgottenHosts === undefined
|
|
1471
|
+
? undefined
|
|
1472
|
+
: existing.forgottenHosts.filter(
|
|
1473
|
+
(host) => !patch.hosts!.some((nextHost) => nextHost.toLowerCase() === host.toLowerCase()),
|
|
1474
|
+
)
|
|
1410
1475
|
const next: DeviceRecord = {
|
|
1411
1476
|
...existing,
|
|
1477
|
+
...(nextForgotten === undefined ? {} : { forgottenHosts: nextForgotten }),
|
|
1412
1478
|
...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
|
|
1413
1479
|
...(patch.enabled === undefined ? {} : { enabled: patch.enabled }),
|
|
1414
1480
|
// hosts is WHOLE-REPLACE: the patch wins outright, never merges. The
|
|
@@ -1473,22 +1539,14 @@ async function patchDevice(
|
|
|
1473
1539
|
|
|
1474
1540
|
// The response and the SSE frame always report the projection of the record
|
|
1475
1541
|
// we just wrote: a disabled device is `disabled` regardless of what its
|
|
1476
|
-
// connection last said, and an enabled one reports its live state.
|
|
1477
|
-
|
|
1542
|
+
// connection last said, and an enabled one reports its live state. ONE
|
|
1543
|
+
// projection feeds both, so the frame and the body cannot disagree.
|
|
1544
|
+
const summary = deviceSummary(route, next)
|
|
1478
1545
|
if (patch.enabled !== undefined && patch.enabled !== previousEnabled) {
|
|
1479
|
-
route.hub.publish('device-state', { deviceId, state })
|
|
1546
|
+
route.hub.publish('device-state', { deviceId, state: summary.state })
|
|
1480
1547
|
}
|
|
1481
1548
|
|
|
1482
|
-
writeJson(res, 200,
|
|
1483
|
-
deviceId: next.deviceId,
|
|
1484
|
-
displayName: next.displayName,
|
|
1485
|
-
state,
|
|
1486
|
-
enabled: next.enabled,
|
|
1487
|
-
hosts: [...next.hosts],
|
|
1488
|
-
port: next.port,
|
|
1489
|
-
security: { ...next.security },
|
|
1490
|
-
ports: { ...next.ports },
|
|
1491
|
-
})
|
|
1549
|
+
writeJson(res, 200, summary)
|
|
1492
1550
|
}
|
|
1493
1551
|
|
|
1494
1552
|
function warnConnect(deviceId: string): (error: unknown) => void {
|
|
@@ -1511,12 +1569,129 @@ function sameHosts(a: readonly string[], b: readonly string[]): boolean {
|
|
|
1511
1569
|
return a.length === b.length && a.every((host, index) => host === b[index])
|
|
1512
1570
|
}
|
|
1513
1571
|
|
|
1572
|
+
/**
|
|
1573
|
+
* `POST /devices/:id/forget` and `POST /devices/:id/remember` (issue #204): the
|
|
1574
|
+
* settings page's door onto #185's endpoint maintenance, which until now only
|
|
1575
|
+
* the `cherry_devices` tool could open.
|
|
1576
|
+
*
|
|
1577
|
+
* One action per request, and the action IS the request: there is no composite
|
|
1578
|
+
* "patch the record AND forget a host" body whose ordering would have to be
|
|
1579
|
+
* defined. Every judgement is the store's — the last-candidate refusal, the
|
|
1580
|
+
* case-insensitive matching, the disjointness of `hosts`/`forgottenHosts` — so
|
|
1581
|
+
* the two doors share ONE set of STORE rules; this route only maps the verdicts
|
|
1582
|
+
* onto HTTP and answers the projection the PATCH answers. (The BODY PARSERS do
|
|
1583
|
+
* differ on purpose — see `parseEndpointHosts`.)
|
|
1584
|
+
*
|
|
1585
|
+
* EVICTION on a change, exactly like the tool's own arm and a PATCH's address
|
|
1586
|
+
* maintenance, and for a DIFFERENT reason on each action — which is why both
|
|
1587
|
+
* are stated here rather than waved through as "parity":
|
|
1588
|
+
*
|
|
1589
|
+
* - FORGET: a pooled connection CAPTURES its record at construction, so the
|
|
1590
|
+
* socket built before this write would keep dialing the host we just forgot.
|
|
1591
|
+
* (The mark itself is honoured by the store's learn merge, so a reconnect is
|
|
1592
|
+
* about the DIAL LIST, not about suppressing the address.)
|
|
1593
|
+
* - REMEMBER: learning happens only INSIDE a handshake, against a clone of the
|
|
1594
|
+
* stored record. Without the eviction a desktop that stays connected would
|
|
1595
|
+
* not re-handshake for as long as the link holds, so the restored address
|
|
1596
|
+
* would come back only after some later flap — the button would look inert.
|
|
1597
|
+
*
|
|
1598
|
+
* The cost is real and inherited from the tool's arm: an eviction disposes the
|
|
1599
|
+
* subscription layer and closes a healthy socket, so a write can drop an
|
|
1600
|
+
* in-flight command's link. One operator click now reaches that churn; that is
|
|
1601
|
+
* the price of "the archive is the truth" for a live connection.
|
|
1602
|
+
*/
|
|
1603
|
+
async function changeEndpoints(
|
|
1604
|
+
req: HttpRequestLike,
|
|
1605
|
+
res: HttpResponseLike,
|
|
1606
|
+
route: RouteContext,
|
|
1607
|
+
deviceId: string,
|
|
1608
|
+
action: 'forget' | 'remember',
|
|
1609
|
+
): Promise<void> {
|
|
1610
|
+
const existing = route.deps.store.list().find((device) => device.deviceId === deviceId)
|
|
1611
|
+
if (existing === undefined) {
|
|
1612
|
+
writeError(res, 404, 'not-found', `no device ${deviceId}`)
|
|
1613
|
+
return
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
let hosts: string[]
|
|
1617
|
+
try {
|
|
1618
|
+
hosts = parseEndpointHosts(await readJsonBody(req))
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
writeError(res, 400, 'invalid-request', error instanceof Error ? error.message : String(error))
|
|
1621
|
+
return
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
try {
|
|
1625
|
+
if (action === 'forget') {
|
|
1626
|
+
const { removed, marked } = route.deps.store.forgetEndpoints(deviceId, hosts)
|
|
1627
|
+
if (removed.length > 0 || marked.length > 0) evict(route, deviceId)
|
|
1628
|
+
} else {
|
|
1629
|
+
const { restored } = route.deps.store.rememberEndpoints(deviceId, hosts)
|
|
1630
|
+
if (restored.length > 0) evict(route, deviceId)
|
|
1631
|
+
}
|
|
1632
|
+
} catch (error) {
|
|
1633
|
+
// Quarantine first, and distinctly: it is not the request's fault (the 400s
|
|
1634
|
+
// below) and the browser cannot fix it — the archive is READ-ONLY until a
|
|
1635
|
+
// human moves the bad file aside. A store that was ALREADY quarantined
|
|
1636
|
+
// never reaches this arm: its view is empty, so the `list()` above answered
|
|
1637
|
+
// 404 before any write — the same precedence PATCH and DELETE have. This is
|
|
1638
|
+
// the defence for a store that begins refusing after that read, and it is
|
|
1639
|
+
// what keeps a damaged archive from being reported as a bad request.
|
|
1640
|
+
if (writeQuarantineRefusal(res, route, error)) return
|
|
1641
|
+
// The tool's own code for the same refusal, so the page can translate it by
|
|
1642
|
+
// code rather than by prose.
|
|
1643
|
+
if (error instanceof LastCandidateHostError) {
|
|
1644
|
+
writeError(res, 400, 'last-address', error.message)
|
|
1645
|
+
return
|
|
1646
|
+
}
|
|
1647
|
+
writeError(res, 400, 'invalid-device', error instanceof Error ? error.message : String(error))
|
|
1648
|
+
return
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// The projection of the record we just wrote, never the object we read before
|
|
1652
|
+
// the write: `REMOVED from a declined call` and `UNCHANGED by a no-op` are
|
|
1653
|
+
// both answered from the archive's current row.
|
|
1654
|
+
const fresh = route.deps.store.list().find((device) => device.deviceId === deviceId)
|
|
1655
|
+
writeJson(res, 200, deviceSummary(route, fresh ?? existing))
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* The body of an endpoint action: `{hosts: [...]}`, non-empty, every entry
|
|
1660
|
+
* normalized the same way the PATCH normalizes an address.
|
|
1661
|
+
*
|
|
1662
|
+
* The normalization is not cosmetic: an IPv6 literal is stored bare but a
|
|
1663
|
+
* browser copy-pastes it bracketed, and the store matches by host spelling —
|
|
1664
|
+
* so `[::1]` would be treated as an address the record does not dial and merely
|
|
1665
|
+
* marked, leaving the real `::1` in `hosts`. The PATCH's rule is reused for the
|
|
1666
|
+
* spelling (same `normalizeHost`), and deliberately NOT for its duplicate
|
|
1667
|
+
* refusal: a repeated host in an action body names one host twice, which the
|
|
1668
|
+
* store's own sets collapse, while the PATCH's duplicates would build an
|
|
1669
|
+
* ambiguous record (`hosts`, `security` and `ports` are keyed by host).
|
|
1670
|
+
*/
|
|
1671
|
+
function parseEndpointHosts(body: Record<string, unknown>): string[] {
|
|
1672
|
+
const hosts = body['hosts']
|
|
1673
|
+
if (!Array.isArray(hosts) || hosts.length === 0) {
|
|
1674
|
+
throw new PatchError('hosts must be a non-empty array')
|
|
1675
|
+
}
|
|
1676
|
+
return hosts.map((entry) => {
|
|
1677
|
+
if (typeof entry !== 'string') throw new PatchError('hosts entries must be strings')
|
|
1678
|
+
return normalizeHost(entry)
|
|
1679
|
+
})
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1514
1682
|
/**
|
|
1515
1683
|
* The scheme map for a WHOLE-REPLACE host list (issue #126): each named host
|
|
1516
1684
|
* keeps the scheme it was dialed with, a host the list just gained defaults to
|
|
1517
1685
|
* `ws`, and hosts the list dropped leave no entry behind. An edit is an
|
|
1518
1686
|
* address edit, not a scheme edit — re-typing the same host must not silently
|
|
1519
1687
|
* demote a TLS-proxied desktop to a plain dial.
|
|
1688
|
+
*
|
|
1689
|
+
* "The same host" is CASE-INSENSITIVE (issue #189), the same comparison the
|
|
1690
|
+
* learn merge, the forget/remember actions and the mark clearing already use:
|
|
1691
|
+
* `NAS.example` and `nas.example` are one address, so a PATCH that re-types
|
|
1692
|
+
* the case keeps the remembered scheme instead of resetting it to `ws`.
|
|
1693
|
+
* `sameHosts`/`sameSecurity` stay EXACT on purpose — they decide whether an
|
|
1694
|
+
* address edit happened at all, and a new spelling IS a change to persist.
|
|
1520
1695
|
*/
|
|
1521
1696
|
function securityFor(
|
|
1522
1697
|
hosts: readonly string[],
|
|
@@ -1524,12 +1699,42 @@ function securityFor(
|
|
|
1524
1699
|
): Record<string, 'ws' | 'wss'> {
|
|
1525
1700
|
// `Object.hasOwn`, never `previous[host] ?? 'ws'`: a host named `constructor`
|
|
1526
1701
|
// (or `__proto__`/`toString`) would otherwise inherit a FUNCTION from
|
|
1527
|
-
// Object.prototype and poison the map the store then rejects.
|
|
1702
|
+
// Object.prototype and poison the map the store then rejects. The index is
|
|
1703
|
+
// built from OWN entries only, so it is safe for the same reason.
|
|
1704
|
+
const remembered = indexByHost(previous)
|
|
1528
1705
|
return Object.fromEntries(
|
|
1529
|
-
hosts.map((host) => [host,
|
|
1706
|
+
hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? 'ws']),
|
|
1530
1707
|
)
|
|
1531
1708
|
}
|
|
1532
1709
|
|
|
1710
|
+
/**
|
|
1711
|
+
* A case-insensitive index over a per-host map (issue #189).
|
|
1712
|
+
*
|
|
1713
|
+
* FIRST spelling wins on a collision. A hand-edited archive CAN hold two
|
|
1714
|
+
* spellings of one host (the store does not reject it, and this face does not
|
|
1715
|
+
* touch `previous`), so callers look their exact spelling up FIRST and consult
|
|
1716
|
+
* this index only as the fallback: a surviving spelling therefore keeps its
|
|
1717
|
+
* OWN entry, and the collision rule only decides what a THIRD spelling
|
|
1718
|
+
* inherits.
|
|
1719
|
+
*/
|
|
1720
|
+
function indexByHost<T>(previous: Record<string, T>): Map<string, T> {
|
|
1721
|
+
const index = new Map<string, T>()
|
|
1722
|
+
for (const [host, value] of Object.entries(previous)) {
|
|
1723
|
+
const key = host.toLowerCase()
|
|
1724
|
+
if (!index.has(key)) index.set(key, value)
|
|
1725
|
+
}
|
|
1726
|
+
return index
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/** The exact spelling's entry, else the case-insensitive one, else nothing. */
|
|
1730
|
+
function exactOrRemembered<T>(
|
|
1731
|
+
previous: Record<string, T>,
|
|
1732
|
+
remembered: Map<string, T>,
|
|
1733
|
+
host: string,
|
|
1734
|
+
): T | undefined {
|
|
1735
|
+
return Object.hasOwn(previous, host) ? previous[host] : remembered.get(host.toLowerCase())
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1533
1738
|
/**
|
|
1534
1739
|
* The port map for a host list (issue #135): each named host keeps the port it
|
|
1535
1740
|
* was dialed with, a host the list just gained defaults to `fallback` (the
|
|
@@ -1537,16 +1742,18 @@ function securityFor(
|
|
|
1537
1742
|
*
|
|
1538
1743
|
* `previous` is empty when the caller is re-pointing EVERY host (a CHANGED
|
|
1539
1744
|
* `port` patch): there is no remembered entry to keep, by design — one port
|
|
1540
|
-
* field on this face must move the whole list. Same `Object.hasOwn
|
|
1541
|
-
*
|
|
1745
|
+
* field on this face must move the whole list. Same `Object.hasOwn`/
|
|
1746
|
+
* case-insensitive rules as `securityFor` (issue #189): a re-typed spelling
|
|
1747
|
+
* keeps its own port, and an inherited function is never a remembered port.
|
|
1542
1748
|
*/
|
|
1543
1749
|
function portsFor(
|
|
1544
1750
|
hosts: readonly string[],
|
|
1545
1751
|
previous: Record<string, number>,
|
|
1546
1752
|
fallback: number,
|
|
1547
1753
|
): Record<string, number> {
|
|
1754
|
+
const remembered = indexByHost(previous)
|
|
1548
1755
|
return Object.fromEntries(
|
|
1549
|
-
hosts.map((host) => [host,
|
|
1756
|
+
hosts.map((host) => [host, exactOrRemembered(previous, remembered, host) ?? fallback]),
|
|
1550
1757
|
)
|
|
1551
1758
|
}
|
|
1552
1759
|
|
package/src/index.ts
CHANGED
|
@@ -489,6 +489,19 @@ function connectionFor(deviceId: string): DeviceConnection {
|
|
|
489
489
|
// Spread-if-present, never `wsOptions: undefined`: the option is optional,
|
|
490
490
|
// and an absent key is the honest "no override" (Node's trust store).
|
|
491
491
|
...(state.wsTls === undefined ? {} : { wsOptions: state.wsTls }),
|
|
492
|
+
// The archive write-back (issue #143). The connection has already merged the
|
|
493
|
+
// addresses into its OWN record — this instance's next reconnect dials them
|
|
494
|
+
// — so the only job here is durability. It goes through
|
|
495
|
+
// `DeviceStore.learnEndpoints`, NOT `upsert(record)`: the store's record
|
|
496
|
+
// carries the token of the PAIRING while the connection's mirrors the live
|
|
497
|
+
// one, and an `upsert` of the connection's copy would put a
|
|
498
|
+
// connection-scoped credential into the archive.
|
|
499
|
+
onAdvertisedEndpoints: (endpoints) => {
|
|
500
|
+
// `record` is THIS connection's clone: its `desktopIdentity` is the pin the
|
|
501
|
+
// answer was validated against, so a re-pair under the same deviceId cannot
|
|
502
|
+
// receive an address learned from the previous pairing.
|
|
503
|
+
state.store.learnEndpoints(deviceId, endpoints, record.desktopIdentity)
|
|
504
|
+
},
|
|
492
505
|
})
|
|
493
506
|
state.connectionsBuilt += 1
|
|
494
507
|
connection.onState((deviceState: DeviceState) => {
|
|
@@ -651,6 +664,11 @@ function subsFor(deviceId: string): SessionSubscriptions {
|
|
|
651
664
|
const created = new SessionSubscriptions(connection.rpc, {
|
|
652
665
|
subscriptionsLimit: connection.limits.subscriptions,
|
|
653
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,
|
|
654
672
|
})
|
|
655
673
|
state.subscriptions.set(deviceId, created)
|
|
656
674
|
// INFO (spec §5 「订阅建立」): the layer is announced with the cap it was
|