Skip to content

Commit f95da55

Browse files
Apollon77claude
andauthored
TCP: Add Matter TCP transport support (#3472)
* Generalize transport abstractions for TCP support Rename ConnectionlessTransport to Transport as the base interface. Add ChannelType enum (UDP/TCP) to Channel and ServerAddressTcp type. Update all consumers (BLE, UDP, protocol layer) to use the new names. This prepares the transport layer for connection-oriented (TCP) support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add TCP socket platform abstraction and implementations Add TcpSocket/TcpServerSocket interfaces with shared constants (DEFAULT_MAX_TCP_MESSAGE_SIZE=64000, timeouts, keep-alive). Implement NodeJsTcpSocket/NodeJsTcpServer for Node.js with TCP_NODELAY, keep-alive, graceful close with timeout fallback. Add React Native TCP support via react-native-tcp-socket. Add mock TCP socket/server for testing infrastructure. Add connectTcp/createTcpServer to Network abstraction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add TCP transport core: framing, connection pool, DoS hardening TcpConnection: Matter message framing with 4-byte LE length prefix, stream reassembly, receive buffer cap (2x maxPayloadSize), oversized message rejection, incoming vs outgoing connection distinction. TcpTransport: connection pool keyed by ip:port, max 3 connections per peer IP, 10s idle timeout on new inbound connections, combined TCP server and client roles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Wire TCP into server/client runtime and configuration Add TCP configuration to NetworkServer (tcp: boolean | {incoming, outgoing}, transportPreference: "tcp" | "udp") and NetworkClient (per-peer preference). Create TcpTransport in ServerNetworkRuntime with port retry loop ensuring UDP IPv4/IPv6 and TCP all bind to the same port. Set DNS-SD TCP bitmap on DeviceAdvertiser. Add multicast interface fallback for TCP-preferred controllers where no inbound UDP traffic provides interface discovery. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Integrate TCP into session, exchange, and peer infrastructure ExchangeManager: session-to-connection binding — connection drop evicts all sessions, last session close triggers connection close. Safety cleanup for session observers on close. MessageChannel: TCP channels not closed by MessageChannel.close() (lifecycle managed by ExchangeManager). Add supportsLargeMessages. Peer: newestSession() method with optional ChannelType filter, transportPreference field, TCP operational address from mDNS. PeerExchangeProvider: transport selection — requiredTransport is hard (no fallback), preference is soft (TCP → fall back to any). PeerConnection: transportConstraint converts addresses to TCP type. PeerSet: skip TCP sessions in operationalAddressOf (ephemeral ports). Sessions: TCP suffix in NodeSession.via and UnsecuredSession.via, group sessions always use UDP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add TCP transport selection for invoke and Large Message Quality ClientCommandMethod reads commandModel.effectiveQuality.largeMessage at factory time and sets invoke.largeMessage = true for Large Message Quality commands, which maps to requiredTransport: ChannelType.TCP. CommissioningController/MatterController forward tcp and transportPreference options. PASE always uses UDP (never TCP). ControllerCommissioningFlow enforces UDP for commissioning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add TCP test suite 85+ tests across 8 test files covering: - TcpConnection: framing, reassembly, buffer cap, oversized rejection (16) - TcpTransport: connection pool, idle timeout, per-IP limits (11) - MockTcp: mock socket/server integration (11) - TcpSessionBinding: session eviction on disconnect, connection close on last session removal, lifecycle management (17) - InvokeLargeMessage: Large Message Quality → TCP requirement (7) - NodeJsTcpSocket: connect, close, destroy, timeout (6) - NodeJsTcpServer: listen, accept, close cleanup (5) - ClientNodeTcp: transport preference, TCP session selection (12) - DeviceAdvertiser: DNS-SD T key TCP advertisement (3) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Enable TCP in shell, examples, and CI test infrastructure Shell: TCP enabled by default, 'nodes tcp <id> on/off' command for per-node transport preference, 'nodes descriptor <id>' for diagnostics. Device example: --tcp flag enables TCP on the onoff device. CHIP testing: TCP enabled on AllClusters/RVC/TV/Bridge test apps, TEST_PREFER_TCP=1 env var sets transport preference for controller identities (outgoing-only TCP). New matterjs-tests-core-tcp CI job. PICS: MCORE.SC.TCP=1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address PR review: fix boundary checks, close on zero-length frames, await server close - Fix >= to > in send/receive size checks so messages exactly at maxMessageSize are accepted (inclusive boundary) - Close connection on zero-length TCP frames instead of silently discarding (DoS protection) - Await server.close() callback in NodeJsTcpServer so callers don't race with still-open ports - Use ServerAddressIp for Observable type to match IpNetworkChannel - Remove Buffer dependency in RN TCP socket (use TextEncoder instead) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Incorporate default network profile for device nodes (#3469) The merge from main silently dropped changes from #3469. Re-apply: add "unknown" template to NetworkProfiles.Templates, auto-detect fallback profile at startup based on endpoint device classification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Restore accidentally reverted main changes in 3 non-TCP files ProtocolService.ts: restore deprecated/disallowed attribute exclusion ProtocolServiceTest.ts: restore EventList deprecation test Shell.ts: restore SIGINT handling fix These were accidentally reverted when checking out files from the old tcp-backup branch during the rebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address PR review: transport-agnostic addresses, naming, code quality ServerAddressIp is now transport-agnostic (no type field). Transport type is only present on ServerAddressUdp/ServerAddressTcp subtypes. Commissionable discovery stamps addresses as UDP (PASE is always UDP), operational discovery leaves them typeless. Address helpers: isIp(), isBle(), protocolOf() replace switch on type. isEqual() compares IP addresses by ip+port only, ignoring transport. Renames: TcpServerSocket → TcpServer, tcp → supportedTransports on DeviceAdvertiser, transportConstraint → transport on Peer/PeerConnection. TcpConnection: use Bytes.dataViewOf, subarray instead of slice, extracted #consume helper. connectTcp accepts optional timeout. NetworkServer: removed redundant defaults. Peer.connect respects transport filter. Removed verbose framing comments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add default discovery timeout for PaseDiscovery (60s) and test controller (30s) PaseDiscovery now defaults to 60s when no timeout is provided, matching CommissioningDiscovery behavior. The legacy test controller overrides to 30s for faster test turnaround. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * TCP: Restructure transport abstractions: semantic naming and ConnectedChannel -> TCP (#3508) * Restructure transport abstractions: semantic naming and ConnectedChannel Rename transport layer types to match their actual roles: - Layer 1 (Platform): TcpSocket→TcpConnection, TcpServer→TcpListener, UdpChannel→UdpSocket (+ all platform implementations and mocks) - Layer 2 (Channel): TcpConnection class→TcpChannel, UdpConnection→UdpChannel - Layer 3 (Transport): UdpInterface→UdpTransport Introduce ConnectedChannel interface (AsyncIterable<Bytes> + send + onClose) shared by TcpChannel and BleChannel for stream-oriented transports. TcpChannel and all BLE implementations now support async iteration for message delivery alongside existing callback-based onMessage/onData. Simplify ExchangeManager coupling: - Add transportChannel getter on MessageChannel (replaces .channel.channel) - Add isConnectionOrientedTransport() type guard (replaces duck-typing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add tests for TcpChannel async iteration and ConnectedChannel type guard Tests cover: basic iteration, close termination, remote disconnect termination, queued-then-close delivery, callback+iterator coexistence, and isConnectedChannel() type guard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address PR review: rename TcpTransport internals, remove duplicate channel getter TcpTransport: #connections→#channels, #server→#listener, #registerConnection→#registerChannel, update comment to "registry" with 1:1 session:channel binding. Remove old MessageChannel.channel getter — all callers now use transportChannel. Migrate remaining .channel.channel references in CASE/PASE/session code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Move TcpChannel and TcpTransport to @matter/protocol TcpChannel (Matter framing) and TcpTransport (connection registry) are protocol-level concerns, not platform abstractions. Move them from @matter/general to @matter/protocol/src/transport/tcp/. TcpConnection interface (platform socket abstraction) and constants stay in @matter/general where platform implementations consume them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Push AsyncIterable down to TcpConnection socket layer with backpressure TcpConnection interface now extends AsyncIterable<Bytes>. Platform implementations deliver raw byte chunks via async iteration: - NodeJsTcpConnection: socket starts paused, resumes on iterator pull, pauses when queue grows — true OS-level TCP backpressure - MockTcpConnection: queue-based iteration for testing - TcpConnectionReactNative: queue-based (RN socket lacks pause/resume) TcpChannel now consumes socket via for-await loop instead of onData callback, with natural backpressure propagation. The old onData callback is kept as deprecated optional method for backward compatibility with existing tests and consumers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix unhandled promise rejections and double-fire in transport code - BlenoBleServer: replace void handleIncomingBleData with .catch(log) - TcpChannel: guard #handleClose against double-fire on socket error, fire close listeners from close() so they always execute exactly once - NobleBleChannel: replace silent .catch(() => {}) with error logging - ReactNativeBleChannel: replace silent .catch(() => {}) with error logging Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove onData from TcpConnection — async iteration is the only API Remove deprecated onData callback from TcpConnection interface and all platform implementations (NodeJs, Mock, ReactNative). All consumers now use async iteration for receiving data, which provides natural backpressure. Update all tests to use for-await / iterator.next(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix review findings: unbounded queue, RN close/listener, naming, races 1. TcpChannel: iterator queue only fills when no onMessage listeners are active (mutually exclusive) — prevents unbounded memory growth 2. TcpConnectionReactNative.close(): await socket teardown with timeout + destroy fallback (matching NodeJs pattern) 3. TcpListenerReactNative: track active sockets, destroy on close so server.close() completes (matching NodeJs pattern) 4. TcpChannel.name: incoming connections show tcp://[ip]«port using Mark.INBOUND to distinguish client ephemeral port from our listener 5. NobleBleChannel.send(): throw BleDisconnectedError instead of silent return so callers know the send failed 6. MockTcpConnection.send(): re-check peer.closed after macrotask yield to prevent delivery to closed connections 7. TcpTransport.openChannel: in-flight promise map deduplicates concurrent connect attempts for the same address Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix CI timeout: don't start TCP socket paused Starting the socket paused in the constructor caused close events to not fire on some Node.js versions/CI environments. Instead, let data flow normally and only pause when the iterator queue grows beyond 1 chunk. Resume when the queue drains or the iterator waits for data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Skip address/liveness probes for TCP sessions TCP has 1:1 session-connection binding plus OS keep-alive — connection drops evict the session directly. Probing is a UDP-only optimization to avoid full reconnect on transient mDNS churn. - PeerAddressMonitor.#check: skip when session uses TCP - ClientInteraction.probe: TCP path returns success immediately after confirming session exists (no empty Read needed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Gate subscription probe on transport: skip for TCP sessions TCP has 1:1 session-connection binding plus OS keep-alive — the session is evicted directly on connection drop, so the liveness probe adds no value. Gate the SustainedSubscription probe callback by transport so TCP sessions skip the empty Read. ClientInteraction.probe stays generic and works if called directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update Ble.ts * test(general): drop type:"udp" from IpService link-local expectations IpService now emits transport-agnostic ServerAddressIp (no `type` field) since the TCP branch generalized the DNS-SD address type. Align the newly-merged link-local zone tests with that shape. * fix(general,node): ServerAddress type guards check value not property Persisted addresses (NetworkAddress schema in CommissioningClient) had every optional field assigned even when undefined. In JS that creates an enumerable property, so `"peripheralAddress" in addr` returned true for IP addresses; isBle then false-matched and urlFor rendered them as `ble://[ip]:port`. Fallback addressing in PeerConnection used those misrouted entries, breaking the multi-address connect path under a particular mDNS TTL race (ClientConnectivityTest "connects to second address after delay when first is unreachable"). isIp / isBle now check the value rather than property presence, and the NetworkAddress constructor skips undefined assignments so the underlying noise is not introduced in the first place. Adds regression tests for the type guards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e8d6747 commit f95da55

114 files changed

Lines changed: 4770 additions & 449 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/chip-tool-tests.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,44 @@ jobs:
254254
--app-path lit-icd:../support/chip-testing/AllClustersTestApp.sh \
255255
'
256256
257+
# Execute core tests with TCP preferred to verify TCP transport
258+
matterjs-tests-core-tcp:
259+
needs: [prepare-chip-build, chip-tests-needed]
260+
if: ${{ github.repository == 'matter-js/matter.js' && (needs.chip-tests-needed.outputs.chip-tool-rebuild == 'true' || needs.chip-tests-needed.outputs.chip-tests-required == 'true' || needs.chip-tests-needed.outputs.chip-changes == 'true') }}
261+
runs-on: ubuntu-latest
262+
steps:
263+
- name: Check out matter.js
264+
uses: actions/checkout@v6
265+
266+
- name: Initialize chip tests
267+
uses: ./.github/actions/prepare-chip-testing
268+
with:
269+
rebuild-chip-tool: "false"
270+
patch-test-yaml: "true"
271+
272+
- name: Run Core cluster tests with TCP preferred
273+
id: test-execution-08-core-matterjs-tcp
274+
shell: bash
275+
env:
276+
TEST_PREFER_TCP: "1"
277+
run: |
278+
cd connectedhomeip
279+
./scripts/run_in_build_env.sh \
280+
'TEST_PREFER_TCP=1 ./scripts/tests/run_test_suite.py \
281+
--runner chip_tool_python \
282+
--log-level info \
283+
--target-glob "{Test_TC_ACE_*,Test_TC_ACL_*,Test_TC_BINFO_*,Test_TC_CADMIN_*,Test_TC_CGEN_*,Test_TC_CNET_*,Test_TC_DESC_*,Test_TC_OPCREDS_*,TestAccessControlC*,TestArmFailSafe,TestCommandsById,TestCommissionerNodeId,TestCommissioningWindow,TestGeneralCommissioning,TestMultiAdmin,TestSubscribe_*}" \
284+
run \
285+
--iterations 1 \
286+
--tool-path chip-tool:../support/chip-testing/dist/esm/ControllerWebSocketTestApp.js \
287+
--app-path all-clusters:../support/chip-testing/dist/esm/AllClustersTestApp.js \
288+
--app-path all-devices:../support/chip-testing/dist/esm/AllClustersTestApp.js \
289+
--app-path bridge:../support/chip-testing/dist/esm/BridgeTestApp.js \
290+
--app-path tv:../support/chip-testing/dist/esm/TvTestApp.js \
291+
--app-path rvc:../support/chip-testing/AllClustersTestApp.sh \
292+
--app-path lit-icd:../support/chip-testing/AllClustersTestApp.sh \
293+
'
294+
257295
# Execute the fast application cluster tests
258296
chip-tests-app-fast:
259297
needs: [prepare-chip-build, chip-tests-needed]

examples/controller/src/ControllerNode.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ class ControllerNode {
138138
const options: NodeCommissioningOptions = {
139139
commissioning: commissioningOptions,
140140
discovery: {
141-
knownAddress: ip !== undefined && port !== undefined ? { ip, port, type: "udp" } : undefined,
141+
knownAddress:
142+
ip !== undefined && port !== undefined ? { ip, port, type: "udp" as const } : undefined,
142143
identifierData:
143144
longDiscriminator !== undefined
144145
? { longDiscriminator }

examples/device-onoff/src/DeviceNode.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ async function main() {
3030
productId,
3131
port,
3232
uniqueId,
33+
tcp,
3334
} = await getConfiguration();
3435

3536
/**
@@ -43,6 +44,7 @@ async function main() {
4344
// Optional when operating only one device on a host, Default port is 5540
4445
network: {
4546
port,
47+
tcp,
4648
},
4749

4850
// Provide Commissioning relevant settings
@@ -162,6 +164,7 @@ async function getConfiguration() {
162164
const productId = environment.vars.number("productid") ?? (await deviceStorage.get("productid", 0x8000));
163165

164166
const port = environment.vars.number("port") ?? 5540;
167+
const tcp = environment.vars.get("tcp") !== undefined ? true : undefined;
165168

166169
const uniqueId =
167170
environment.vars.string("uniqueid") ?? (await deviceStorage.get("uniqueid", Time.nowMs)).toString();
@@ -189,5 +192,6 @@ async function getConfiguration() {
189192
productId,
190193
port,
191194
uniqueId,
195+
tcp,
192196
};
193197
}

package-lock.json

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/general/src/net/Channel.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
* Copyright 2022-2026 Matter.js Authors
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import { ServerAddressUdp } from "#net/ServerAddress.js";
7-
import { isObject } from "#util/Type.js";
6+
import { ServerAddressIp, ServerAddressUdp } from "#net/ServerAddress.js";
7+
import type { Transport } from "#net/Transport.js";
8+
import { Bytes } from "#util/Bytes.js";
89
import { Observable } from "#util/index.js";
10+
import { isObject } from "#util/Type.js";
911

1012
export enum ChannelType {
1113
UDP = "udp",
@@ -35,28 +37,70 @@ export interface Channel<T> {
3537
close(): Promise<void>;
3638
}
3739

38-
// TODO Enhance when we add TCP support
3940
export interface IpNetworkChannel<T> extends Channel<T> {
41+
networkAddress: ServerAddressIp;
42+
networkAddressChanged: Observable<[ServerAddressIp]>;
43+
44+
/** Send data to the remote endpoint */
45+
send(data: T): Promise<void>;
46+
}
47+
48+
/** UDP-specific channel with per-send address override capability. */
49+
export interface UdpNetworkChannel<T> extends IpNetworkChannel<T> {
4050
networkAddress: ServerAddressUdp;
4151
networkAddressChanged: Observable<[ServerAddressUdp]>;
4252

4353
/** Send data, optionally overriding the destination address for this single send. */
4454
send(data: T, addressOverride?: ServerAddressUdp): Promise<void>;
4555
}
4656

57+
/**
58+
* Stream-oriented channel with a fixed peer (TCP, BLE/BTP).
59+
*
60+
* Both TCP and BLE channels are inherently reliable and have a connection lifecycle.
61+
* Incoming messages are delivered as an async iterable; outgoing messages via send().
62+
*/
63+
export interface ConnectedChannel extends Channel<Bytes>, AsyncIterable<Bytes> {
64+
readonly isReliable: true;
65+
readonly supportsLargeMessages: boolean;
66+
readonly type: ChannelType;
67+
68+
/** Send a complete Matter message to the peer. */
69+
send(data: Bytes): Promise<void>;
70+
71+
/** Close the connection. */
72+
close(): Promise<void>;
73+
74+
/** Register a listener for connection close/disconnect. */
75+
onClose(listener: () => void): Transport.Listener;
76+
}
77+
78+
/**
79+
* Type guard for connected (stream-oriented) channels.
80+
*/
81+
export function isConnectedChannel(channel?: Channel<unknown>): channel is ConnectedChannel {
82+
return channel !== undefined && channel.isReliable && typeof (channel as ConnectedChannel).onClose === "function";
83+
}
84+
4785
/**
4886
* Returns true (and guards types) if the channel is an IP channel
4987
*/
5088
export function isIpNetworkChannel<T>(channel?: Channel<T>): channel is IpNetworkChannel<T> {
5189
return isObject((channel as IpNetworkChannel<T> | undefined)?.networkAddress);
5290
}
5391

92+
/**
93+
* Returns true if the channel is a UDP network channel (supports address override).
94+
*/
95+
export function isUdpNetworkChannel<T>(channel?: Channel<T>): channel is UdpNetworkChannel<T> {
96+
return isIpNetworkChannel(channel) && channel.type === ChannelType.UDP;
97+
}
98+
5499
/**
55100
* Checks if two IPNetworkChannels are referencing the same address.
56-
* Both the channel type (UDP/TCP) and the address (including port) need to match.
57101
*/
58102
export function sameIpNetworkChannel<T>(channel1: IpNetworkChannel<T>, channel2: IpNetworkChannel<T>) {
59103
const { networkAddress: addr1 } = channel1;
60104
const { networkAddress: addr2 } = channel2;
61-
return addr1.type === addr2.type && addr1.ip === addr2.ip && addr1.port === addr2.port;
105+
return addr1.ip === addr2.ip && addr1.port === addr2.port;
62106
}

packages/general/src/net/Network.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
import { MatterError } from "../MatterError.js";
88
import type { MaybePromise } from "../util/Promises.js";
9-
import type { UdpChannel, UdpChannelOptions } from "./udp/UdpChannel.js";
9+
import type { TcpConnection, TcpListener, TcpListenerOptions } from "./tcp/TcpConnection.js";
10+
import type { UdpSocket, UdpSocketOptions } from "./udp/UdpSocket.js";
1011

1112
export class NetworkError extends MatterError {}
1213

@@ -68,7 +69,17 @@ export type NetworkInterfaceDetailed = NetworkInterface & NetworkInterfaceDetail
6869
export abstract class Network {
6970
abstract getNetInterfaces(configuration?: NetworkInterface[]): MaybePromise<NetworkInterface[]>;
7071
abstract getIpMac(netInterface: string): MaybePromise<NetworkInterfaceDetails | undefined>;
71-
abstract createUdpChannel(options: UdpChannelOptions): Promise<UdpChannel>;
72+
abstract createUdpSocket(options: UdpSocketOptions): Promise<UdpSocket>;
73+
74+
/** Create a TCP server socket. Override in platform implementations that support TCP. */
75+
createTcpListener(_options: TcpListenerOptions): Promise<TcpListener> {
76+
throw new NetworkError("TCP server not supported on this platform");
77+
}
78+
79+
/** Connect to a remote TCP endpoint. Override in platform implementations that support TCP. */
80+
connectTcp(_host: string, _port: number, _options?: { timeout?: number }): Promise<TcpConnection> {
81+
throw new NetworkError("TCP client not supported on this platform");
82+
}
7283

7384
async close() {
7485
// Nothing to do

packages/general/src/net/ServerAddress.ts

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,24 @@ export interface AddressStatus extends Partial<AddressLifespan> {
4242
weight?: number;
4343
}
4444

45-
export type ServerAddressUdp = {
46-
type: "udp";
45+
/** Transport-agnostic IP address as discovered via DNS-SD. */
46+
export type ServerAddressIp = {
4747
ip: string;
4848
port: number;
4949
} & AddressStatus;
5050

51-
export type ServerAddressTcp = {
52-
type: "tcp";
53-
ip: string;
54-
port: number;
55-
} & AddressStatus;
51+
/** IP address with explicit UDP transport. */
52+
export type ServerAddressUdp = ServerAddressIp & { type: "udp" };
53+
54+
/** IP address with explicit TCP transport. */
55+
export type ServerAddressTcp = ServerAddressIp & { type: "tcp" };
5656

5757
export type ServerAddressBle = {
5858
type: "ble";
5959
peripheralAddress: string;
6060
} & AddressStatus;
6161

62-
export type ServerAddress = ServerAddressUdp | ServerAddressTcp | ServerAddressBle;
62+
export type ServerAddress = ServerAddressIp | ServerAddressUdp | ServerAddressTcp | ServerAddressBle;
6363

6464
export function ServerAddress(definition: ServerAddress) {
6565
return {
@@ -70,41 +70,54 @@ export function ServerAddress(definition: ServerAddress) {
7070
priority: undefined,
7171
weight: undefined,
7272
...definition,
73-
} as ServerAddress;
73+
} as unknown as ServerAddress;
7474
}
7575

7676
export namespace ServerAddress {
77-
export function urlFor(address: ServerAddress): string {
78-
switch (address.type) {
79-
case "udp":
80-
case "tcp":
81-
const ip = address.ip;
82-
return `${address.type}://${ip.includes(":") ? `[${ip}]` : ip}:${address.port}`;
77+
/** Type guard for IP-based addresses (with or without explicit transport type). */
78+
export function isIp(address: ServerAddress): address is ServerAddressIp {
79+
return (address as ServerAddressIp).ip !== undefined;
80+
}
8381

84-
case "ble":
85-
return `ble://${address.peripheralAddress}`;
82+
/** Type guard for BLE addresses. */
83+
export function isBle(address: ServerAddress): address is ServerAddressBle {
84+
return (address as ServerAddressBle).peripheralAddress !== undefined;
85+
}
8686

87-
default:
88-
return `${(address as any).type}://`;
87+
/** Returns the transport protocol label for display — "udp", "tcp", or "ip" if unspecified. */
88+
export function protocolOf(address: ServerAddress): string {
89+
if (isBle(address)) {
90+
return "ble";
91+
}
92+
if ("type" in address && typeof address.type === "string") {
93+
return address.type;
8994
}
95+
return "ip";
9096
}
9197

92-
export function diagnosticFor(address: ServerAddress) {
93-
const diagnostic = Array<unknown>();
98+
export function urlFor(address: ServerAddress): string {
99+
if (isIp(address)) {
100+
const proto = protocolOf(address);
101+
const host = address.ip.includes(":") ? `[${address.ip}]` : address.ip;
102+
return `${proto}://${host}:${address.port}`;
103+
}
94104

95-
switch (address.type) {
96-
case "udp":
97-
case "tcp":
98-
diagnostic.push(`${address.type}://`, Diagnostic.strong(address.ip), ":", address.port);
99-
break;
105+
if (isBle(address)) {
106+
return `ble://${address.peripheralAddress}`;
107+
}
100108

101-
case "ble":
102-
diagnostic.push("ble://", Diagnostic.strong(address.peripheralAddress));
103-
break;
109+
return `unknown://`;
110+
}
104111

105-
default:
106-
diagnostic.push(`${(address as any).type}://`);
107-
break;
112+
export function diagnosticFor(address: ServerAddress) {
113+
const diagnostic = Array<unknown>();
114+
115+
if (isIp(address)) {
116+
diagnostic.push(`${protocolOf(address)}://`, Diagnostic.strong(address.ip), ":", address.port);
117+
} else if (isBle(address)) {
118+
diagnostic.push("ble://", Diagnostic.strong(address.peripheralAddress));
119+
} else {
120+
diagnostic.push("unknown://");
108121
}
109122

110123
if ("ttl" in address && typeof address.ttl === "number") {
@@ -114,16 +127,13 @@ export namespace ServerAddress {
114127
return Diagnostic.squash(...diagnostic);
115128
}
116129

130+
/** IP addresses are equal if ip and port match, regardless of transport type. */
117131
export function isEqual(a: ServerAddress, b: ServerAddress): boolean {
118-
if (a.type !== b.type) {
119-
return false;
120-
}
121-
122-
if (a.type === "udp" && b.type === "udp") {
132+
if (isIp(a) && isIp(b)) {
123133
return a.ip === b.ip && a.port === b.port;
124134
}
125135

126-
if (a.type === "ble" && b.type === "ble") {
136+
if (isBle(a) && isBle(b)) {
127137
return a.peripheralAddress === b.peripheralAddress;
128138
}
129139

@@ -170,7 +180,7 @@ export namespace ServerAddress {
170180
}
171181

172182
export function selectionPreferenceOf(address: ServerAddress) {
173-
if (!("ip" in address)) {
183+
if (!isIp(address)) {
174184
return SelectionPreference.NOT_IP;
175185
}
176186

0 commit comments

Comments
 (0)