Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions .claude/skills/uts-to-kotlin/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,13 +676,12 @@ Recognise a **proxy** spec by a reference to `create_proxy_session()`, proxy `ru

A **direct-sandbox** spec (no `create_proxy_session`, no rules — just happy-path interop against `nonprod:sandbox`) uses the same `SandboxApp` provisioning and the same `runTest` / `@BeforeAll`+`runBlocking` lifecycle as a proxy test, but **drops all proxy wiring**: no `ProxyManager.ensureProxy()`, no `ProxySession`, no `connectThroughProxy`. The client connects straight to the sandbox host. `ChannelHistoryTest` (realtime) and `ObjectsLifecycleTest` (liveobjects) are the reference examples — read one before translating a direct-sandbox spec.

**Client wiring** — point both transports at the sandbox host (explicit hosts auto-disable fallback hosts, so no `fallbackHosts`):
**Client wiring** — point the client at the sandbox endpoint:

```kotlin
private fun newClient(useBinaryProtocol: Boolean): AblyRealtime = TestRealtimeClient {
key = app.defaultKey
realtimeHost = SandboxApp.sandboxHost // sandbox.realtime.ably-nonprod.net
restHost = SandboxApp.sandboxHost
endpoint = SandboxApp.sandboxEndpoint // nonprod:sandbox
this.useBinaryProtocol = useBinaryProtocol
autoConnect = false
}
Expand Down Expand Up @@ -728,7 +727,7 @@ Three helpers live under `uts/src/main/kotlin/io/ably/lib/uts/infra/integration/

- **`ProxyManager`** (`infra/integration/proxy/ProxyManager.kt`, package `io.ably.lib.uts.infra.integration.proxy`) — downloads/starts the shared `uts-proxy` process. Call `ProxyManager.ensureProxy()` once per suite in setup.
- **`ProxySession`** (`infra/integration/proxy/ProxySession.kt`, same package) — one programmable session wrapping the proxy control API; also defines the `connectThroughProxy` extension and the rule-builder helpers.
- **`SandboxApp`** (`infra/integration/SandboxApp.kt`, package `io.ably.lib.uts.infra.integration`) — provisions/deletes a sandbox test app from the shared `test-app-setup.json` in ably-common. `SandboxApp.create()` returns a `SandboxApp` with `appId`, `defaultKey`, and `keys` (`defaultKey` is a full-capability `appId.keyId:keySecret`); `app.delete()` tears it down. Provision in suite setup, delete in teardown. Also owns the single upstream sandbox host constant `SandboxApp.sandboxHost` (`sandbox.realtime.ably-nonprod.net`, the resolved `nonprod:sandbox` endpoint) — the default target of every `ProxySession` (both `realtimeHost` and `restHost`), and what direct-sandbox clients set `realtimeHost` / `restHost` from.
- **`SandboxApp`** (`infra/integration/SandboxApp.kt`, package `io.ably.lib.uts.infra.integration`) — provisions/deletes a sandbox test app from the shared `test-app-setup.json` in ably-common. `SandboxApp.create()` returns a `SandboxApp` with `appId`, `defaultKey`, and `keys` (`defaultKey` is a full-capability `appId.keyId:keySecret`); `app.delete()` tears it down. Provision in suite setup, delete in teardown. Also owns `SandboxApp.sandboxEndpoint` (`nonprod:sandbox`), which direct-sandbox clients set as their `endpoint`, and `SandboxApp.sandboxHost` (`sandbox.realtime.ably-nonprod.net`, the hostname that endpoint resolves to) — the default upstream target of every `ProxySession`.

Import what the tier needs: a **direct-sandbox** test imports `io.ably.lib.uts.infra.integration.SandboxApp` plus `io.ably.lib.uts.infra.unit.TestRealtimeClient` and `io.ably.lib.uts.infra.{awaitState, pollUntil}`; a **proxy** test additionally imports `io.ably.lib.uts.infra.integration.proxy.{ProxyManager, ProxySession, connectThroughProxy}`.

Expand Down Expand Up @@ -799,11 +798,11 @@ val client = TestRealtimeClient {
}
```

ably-java has **no `endpoint` ClientOptions field**; `connectThroughProxy` sets the discrete host fields for you:
`connectThroughProxy` sets the proxy-def options for you:

| Proxy-def option | What `connectThroughProxy` sets |
|---|---|
| `endpoint: "localhost"` | `realtimeHost` **and** `restHost` = `session.proxyHost` (`"localhost"`) |
| `endpoint: "localhost"` | `endpoint = session.proxyHost` (`"localhost"`) |
| `port: proxy_port` | `port = session.proxyPort` |
| `tls: false` | `tls = false` |
| `useBinaryProtocol: false` | already the `ClientOptionsBuilder` default — left untouched |
Expand Down
7 changes: 3 additions & 4 deletions .claude/skills/uts-to-kotlin/references/objects-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,10 +623,9 @@ REST provisioning" convention survives even though the module's internals are vi
> envelope on the legacy `sandbox-rest.ably.io` host; both were aligned upstream — to this V2 shape and to
> the canonical nonprod sandbox host `sandbox.realtime.ably-nonprod.net` — in ably/specification#497.
>
> **Sandbox host.** `provisionObjectsViaRest` sets `restHost = SandboxApp.sandboxHost`
> (`sandbox.realtime.ably-nonprod.net`) — the same nonprod host `SandboxApp` and the realtime clients use,
> **not** `environment="sandbox"` (which resolves to the legacy `sandbox-rest.ably.io`, and
> can't be combined with `restHost` per `Hosts.java` TO3k2/TO3k3). The REST call hits the live sandbox
> **Sandbox host.** `provisionObjectsViaRest` sets `endpoint = SandboxApp.sandboxEndpoint`
> (`nonprod:sandbox`, resolving to `sandbox.realtime.ably-nonprod.net`) — the same endpoint `SandboxApp`
> and the realtime clients use. The REST call hits the live sandbox
> today, and the realtime client observes the provisioned data through the SDK's OBJECT_SYNC +
> `RealtimeObject.get()` (both implemented).

Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ for the JRE-specific library:

./gradlew core:testRealtimeSuite

To run tests against a specific host, specify in the environment:
To run tests against a specific endpoint, specify it in the environment:

env ABLY_ENV=staging ./gradlew testRealtimeSuite
env ABLY_ENDPOINT=nonprod:staging ./gradlew testRealtimeSuite

Tests will run against the sandbox environment by default.
Tests will run against the `nonprod:sandbox` endpoint by default.

Tests can be run on the Android-specific library. An Android device must be connected,
either a real device or the Android emulator.
Expand Down
50 changes: 50 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,53 @@ find src -name '*.java' -o -name '*.kt' | xargs sed -i \
| `TokenDetails.fromJSON(…)`, `TokenRequest.fromJSON(…)` | `fromJsonElement(…)` |
| `ClientOptions.fallbackHostsUseDefault` | Drop it. Default fallback hosts apply automatically; set `fallbackHosts` only for custom hosts. |
| `RegistrationToken.Type.GCM` (Android) | `RegistrationToken.Type.FCM` |

## 5. Choose the host with `endpoint`

2.0 replaces `ClientOptions.environment`, `restHost` and `realtimeHost` with a single
`ClientOptions.endpoint` option, and the builder methods `environment(…)`, `restHost(…)` and
`realtimeHost(…)` with `endpoint(…)`. REST requests and the realtime connection now always use the
same host.

| 1.x | 2.0 |
|-----|-----|
| No host options | No change; traffic moves to `main.realtime.ably.net` (see below) |
| `environment = "sandbox"` | `endpoint = "nonprod:sandbox"` |
| `environment = "acme"` (dedicated cluster) | `endpoint = "acme"` |
| `restHost` / `realtimeHost = "localhost"` | `endpoint = "localhost"` |
| `restHost` and `realtimeHost` set to the same custom host | `endpoint = "<that host>"` |
| `restHost` and `realtimeHost` set to different hosts | Not supported; use one host that serves both, or contact Ably |
| Custom `fallbackHosts` | Unchanged |

How `endpoint` resolves:

| `endpoint` | Primary host | Default fallback hosts |
|------------|--------------|------------------------|
| Unset | `main.realtime.ably.net` | `main.[a-e].fallback.ably-realtime.com` |
| A routing policy name, e.g. `acme` | `acme.realtime.ably.net` | `acme.[a-e].fallback.ably-realtime.com` |
| `nonprod:` plus a name, e.g. `nonprod:sandbox` | `sandbox.realtime.ably-nonprod.net` | `sandbox.[a-e].fallback.ably-realtime-nonprod.com` |
| A hostname (contains `.` or `::`, or is `localhost`) | The value as given | None |

An explicit `fallbackHosts` always replaces the default fallback hosts. A custom `port` or `tlsPort`
no longer disables the default fallback hosts.

**Before (1.x):**

```java
ClientOptions options = new ClientOptions("xVLyHw.MHOCLg:...");
options.environment = "sandbox";
AblyRealtime realtime = new AblyRealtime(options);
```

**After (2.0):**

```java
PubSubRealtimeClient realtime = PubSubServer.realtimeClientBuilder()
.key("xVLyHw.MHOCLg:...")
.endpoint("nonprod:sandbox")
.build();
```

**Firewalls and proxies.** 1.x connected to `rest.ably.io`, `realtime.ably.io` and
`[a-e].ably-realtime.com` by default. If your network restricts outbound traffic, allow
`*.realtime.ably.net` and `*.fallback.ably-realtime.com` before upgrading.
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public void android_http_header_test() {
ClientOptions opts = new ClientOptions(testVars.keys[0].keyStr);
opts.tls = false;
opts.port = server.getListeningPort();
opts.restHost = "localhost";
opts.endpoint = "localhost";
PubSubHttpClient ably = HttpClientFactory.create(opts);

ably.time();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ PubSubHttpClient getAbly() throws AblyException {
throw AblyException.fromErrorInfo(new ErrorInfo("Unable to get Ably library instance; no device identity token", 40000, 400));
}
Log.v(TAG, "getAbly(): returning Ably instance using deviceIdentityToken");
// TODO: We need to persist Ably client options such as the environment with `deviceIdentityToken` and use these options during initialization.
// TODO: We need to persist Ably client options such as the endpoint with `deviceIdentityToken` and use these options during initialization.
return (ably = HttpClientFactory.create(new ClientOptions(deviceIdentityToken)));
}

Expand Down
22 changes: 3 additions & 19 deletions device/src/commonMain/kotlin/io/ably/pubsub/device/PubSubDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,12 @@ public object PubSubDevice {
public fun headers(headers: Map<String, String>): ClientBuilder = apply { options.headers = headers }

/**
* Sets [ClientOptions.restHost].
* Sets [ClientOptions.endpoint].
*
* @param restHost the value to set.
* @param endpoint the value to set.
* @return this builder.
*/
public fun restHost(restHost: String): ClientBuilder = apply { options.restHost = restHost }
public fun endpoint(endpoint: String): ClientBuilder = apply { options.endpoint = endpoint }

/**
* Sets [ClientOptions.port].
Expand Down Expand Up @@ -232,14 +232,6 @@ public object PubSubDevice {
*/
public fun proxy(proxy: ProxyOptions): ClientBuilder = apply { options.proxy = proxy }

/**
* Sets [ClientOptions.environment].
*
* @param environment the value to set.
* @return this builder.
*/
public fun environment(environment: String): ClientBuilder = apply { options.environment = environment }

/**
* Sets [ClientOptions.idempotentRestPublishing].
*
Expand Down Expand Up @@ -344,14 +336,6 @@ public object PubSubDevice {
*/
public fun agents(agents: Map<String, String?>): ClientBuilder = apply { options.agents = agents }

/**
* Sets [ClientOptions.realtimeHost].
*
* @param realtimeHost the value to set.
* @return this builder.
*/
public fun realtimeHost(realtimeHost: String): ClientBuilder = apply { options.realtimeHost = realtimeHost }

/**
* Sets [ClientOptions.autoConnect].
*
Expand Down
4 changes: 2 additions & 2 deletions examples/src/main/kotlin/com/ably/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ class MainActivity : ComponentActivity() {
// A throwaway client that only signs a token; it never connects.
PubSubDevice.clientBuilder()
.key(apiKey)
.environment("sandbox")
.endpoint("nonprod:sandbox")
.autoConnect(false)
.build()
.use { it.auth.requestToken(null, null) }
}
)
environment("sandbox")
endpoint("nonprod:sandbox")
} else {
key(BuildConfig.ABLY_KEY)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import io.ably.pubsub.realtime.PubSubRealtimeClient
@Composable
fun MainScreen(realtimeClient: PubSubRealtimeClient) {
var selectedTab by remember { mutableIntStateOf(0) }
val isSandbox = realtimeClient.options.environment == "sandbox"
val isSandbox = realtimeClient.options.endpoint == "nonprod:sandbox"

val tabs = listOf(
TabItem("Color Voting", Icons.Default.Favorite),
Expand Down
4 changes: 1 addition & 3 deletions lib/src/main/java/io/ably/pubsub/debug/DebugOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ public DebugOptions copy() {
copied.logLevel = logLevel;
copied.logHandler = logHandler;
copied.tls = tls;
copied.restHost = restHost;
copied.realtimeHost = realtimeHost;
copied.endpoint = endpoint;
copied.port = port;
copied.tlsPort = tlsPort;
copied.autoConnect = autoConnect;
Expand All @@ -60,7 +59,6 @@ public DebugOptions copy() {
copied.echoMessages = echoMessages;
copied.recover = recover;
copied.proxy = proxy;
copied.environment = environment;
copied.idempotentRestPublishing = idempotentRestPublishing;
copied.httpOpenTimeout = httpOpenTimeout;
copied.httpRequestTimeout = httpRequestTimeout;
Expand Down
2 changes: 1 addition & 1 deletion lib/src/main/java/io/ably/pubsub/http/HttpCore.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public HttpCore(ClientOptions options, Auth auth, PlatformAgentProvider platform
this.platformAgentProvider = platformAgentProvider;
this.scheme = options.tls ? "https://" : "http://";
this.port = Defaults.getPort(options);
this.hosts = new Hosts(options.restHost, Defaults.HOST_REST, options);
this.hosts = new Hosts(options);
ProxyOptions proxyOptions = options.proxy;
if (proxyOptions != null) {
String proxyHost = proxyOptions.host;
Expand Down
13 changes: 12 additions & 1 deletion lib/src/main/java/io/ably/pubsub/http/HttpUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,20 @@ public static String encodeURIComponent(String str) {
return builder.toString();
}

/**
* Formats a host for the authority component of a URL, bracketing an IPv6 literal
* (RFC 3986 section 3.2.2) so that its colons are not mistaken for the port separator.
*
* @param host a hostname, IPv4 address, or IPv6 address, bracketed or not
* @return the host, safe to place between a scheme and a port
*/
public static String hostForUrl(String host) {
return host.indexOf(':') >= 0 && !host.startsWith("[") ? '[' + host + ']' : host;
}

static URL buildURL(String scheme, String host, int port, String path, Param[] params) {
StringBuilder builder = new StringBuilder(scheme)
.append(host)
.append(hostForUrl(host))
.append(':')
.append(port)
.append(HttpUtils.encodeParams(path, params));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ public ConnectionManager(final PubSubRealtimeClient ably, final Connection conne
this.liveObjectsPlugin = liveObjectsPlugin;

ClientOptions options = ably.options;
this.hosts = new Hosts(options.realtimeHost, Defaults.HOST_REALTIME, options);
this.hosts = new Hosts(options);

/* debug options */
ITransport.Factory transportFactory = null;
Expand Down
75 changes: 65 additions & 10 deletions lib/src/main/java/io/ably/pubsub/transport/Defaults.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ public class Defaults {
public static final String ABLY_AGENT_HEADER = "Ably-Agent";

/* Hosts */
public static final String[] HOST_FALLBACKS = { "A.ably-realtime.com", "B.ably-realtime.com", "C.ably-realtime.com", "D.ably-realtime.com", "E.ably-realtime.com" };
public static final String HOST_REST = "rest.ably.io";
public static final String HOST_REALTIME = "realtime.ably.io";
/* REC1a: the endpoint used when ClientOptions#endpoint is unset */
public static final String ENDPOINT = "main";
/* REC2c1: the fallback domains for the default endpoint */
public static final String[] HOST_FALLBACKS = getEndpointFallbackHosts(ENDPOINT);
public static final int PORT = 80;
public static final int TLS_PORT = 443;

Expand Down Expand Up @@ -74,14 +75,68 @@ public static int getPort(ClientOptions options) {
: ((options.port != 0) ? options.port : Defaults.PORT);
}

/* Construct environment fallback hosts as per RSC15i */
public static String[] getEnvironmentFallbackHosts(String environment) {
/**
* Resolves the primary domain that both REST requests and realtime connections use.
* <p>
* Spec: REC1
*
* @param endpoint the configured endpoint, or null for the default
* @return the primary domain
*/
public static String getPrimaryDomain(String endpoint) {
if (endpoint == null) {
endpoint = ENDPOINT;
}
/* REC1b2 */
if (isHostname(endpoint)) {
return endpoint;
Comment on lines +91 to +92
}
/* REC1b3 */
if (endpoint.startsWith(NONPROD_PREFIX)) {
return endpoint.substring(NONPROD_PREFIX.length()) + ".realtime.ably-nonprod.net";
}
/* REC1b4 */
return endpoint + ".realtime.ably.net";
}

/**
* Resolves the default fallback domains for an endpoint, used when ClientOptions#fallbackHosts is unset.
* <p>
* Spec: REC2c
*
* @param endpoint the configured endpoint, or null for the default
* @return the fallback domains; empty when the endpoint is a hostname
*/
public static String[] getEndpointFallbackHosts(String endpoint) {
if (endpoint == null) {
endpoint = ENDPOINT;
}
/* REC2c2 */
if (isHostname(endpoint)) {
return new String[0];
}
/* REC2c3 */
if (endpoint.startsWith(NONPROD_PREFIX)) {
return fallbackHosts(endpoint.substring(NONPROD_PREFIX.length()), "ably-realtime-nonprod.com");
}
/* REC2c1, REC2c4 */
return fallbackHosts(endpoint, "ably-realtime.com");
}

private static final String NONPROD_PREFIX = "nonprod:";

/* REC1b2: an endpoint is a hostname if it contains '.' or '::', or is "localhost" */
private static boolean isHostname(String endpoint) {
return endpoint.contains(".") || endpoint.contains("::") || endpoint.equals("localhost");
}

private static String[] fallbackHosts(String routingPolicyId, String domain) {
return new String[] {
environment + "-a-fallback.ably-realtime.com",
environment + "-b-fallback.ably-realtime.com",
environment + "-c-fallback.ably-realtime.com",
environment + "-d-fallback.ably-realtime.com",
environment + "-e-fallback.ably-realtime.com"
routingPolicyId + ".a.fallback." + domain,
routingPolicyId + ".b.fallback." + domain,
routingPolicyId + ".c.fallback." + domain,
routingPolicyId + ".d.fallback." + domain,
routingPolicyId + ".e.fallback." + domain
};
}
}
Loading
Loading