From d84474d5d6ab8b8065db2b8443080c937e7dec6f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 29 Apr 2026 15:02:06 +0530 Subject: [PATCH 01/30] added presence api update --- .../java/io/package/services/Realtime.kt.twig | 79 +++++++++++++++- .../Sources/Services/Realtime.swift.twig | 84 ++++++++++++++++- templates/flutter/lib/src/realtime.dart.twig | 19 ++++ .../flutter/lib/src/realtime_base.dart.twig | 8 ++ .../lib/src/realtime_browser.dart.twig | 15 ++++ .../flutter/lib/src/realtime_io.dart.twig | 15 ++++ .../flutter/lib/src/realtime_mixin.dart.twig | 71 +++++++++++++++ templates/web/src/services/realtime.ts.twig | 90 ++++++++++++++++++- 8 files changed, 377 insertions(+), 4 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index 888d0fa6e2..cf1df68a33 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -43,6 +43,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var socket: RealWebSocket? = null private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() + private val pendingPresenceRequests = ArrayDeque>>() private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -50,6 +51,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var heartbeatJob: Job? = null private val subscriptionLock = Any() + private val presenceLock = Any() } private fun createSocket() { @@ -191,6 +193,51 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } } + /** + * Create or upsert a presence entry for the current authenticated user + * over the existing realtime connection. + * + * Requires an authenticated user and an open WebSocket connection + * (subscribe to a channel first if you don't have one yet). + * + * @param status Presence status (required). + * @param permissions Optional permission list to attach to the presence document. + * @param metadata Optional metadata payload. + * @param presenceId Optional presence ID. Defaults server-side to a new unique ID. + * @return The created or updated presence document as a map. + */ + suspend fun createPresence( + status: String, + permissions: List? = null, + metadata: Map? = null, + presenceId: String? = null, + ): Map { + val ws = socket ?: throw {{ spec.title | caseUcfirst }}Exception( + "Realtime connection is not open. Subscribe to a channel first." + ) + + val data = mutableMapOf("status" to status) + permissions?.let { data["permissions"] = it } + metadata?.let { data["metadata"] = it } + presenceId?.let { data["presenceId"] = it } + + val deferred = CompletableDeferred>() + synchronized(presenceLock) { + pendingPresenceRequests.addLast(deferred) + } + + try { + ws.send(mapOf("type" to "presence", "data" to data).toJson()) + } catch (e: Throwable) { + synchronized(presenceLock) { + pendingPresenceRequests.remove(deferred) + } + throw e + } + + return deferred.await() + } + fun subscribe( vararg channels: Channel<*>, callback: (RealtimeResponseEvent) -> Unit, @@ -357,13 +404,43 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } private fun handleResponseAction(message: RealtimeResponse) { + val data = message.data?.jsonCast>() + if (data != null && data["to"] == "presence") { + @Suppress("UNCHECKED_CAST") + val presence = data["presence"] as? Map + if (presence != null) { + // Presence responses arrive in the same order as the requests on a + // single websocket, so a FIFO queue is enough to correlate them. + val deferred: CompletableDeferred>? + synchronized(presenceLock) { + deferred = if (pendingPresenceRequests.isNotEmpty()) { + pendingPresenceRequests.removeFirst() + } else { + null + } + } + deferred?.complete(presence) + return + } + } // The SDK generates subscriptionIds client-side and sends them on every // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state // the SDK needs to reconcile. } private fun handleResponseError(message: RealtimeResponse) { - throw message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") + val ex = message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") + + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving deferred awaits hanging. + val pending: List>> + synchronized(presenceLock) { + pending = pendingPresenceRequests.toList() + pendingPresenceRequests.clear() + } + pending.forEach { it.completeExceptionally(ex) } + + throw ex } private suspend fun handleResponseEvent(message: RealtimeResponse) { diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index e0bef860e9..e78476e458 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -21,9 +21,11 @@ open class Realtime : Service { private var socketClient: WebSocketClient? = nil private var activeSubscriptions = [String: RealtimeCallback]() private var pendingSubscribes = [String: [String: Any]]() + private var pendingPresenceRequests: [CheckedContinuation<[String: Any], Swift.Error>] = [] private var heartbeatTask: Task? = nil let connectSync = DispatchQueue(label: "ConnectSync") + let presenceSync = DispatchQueue(label: "PresenceSync") private var subCallDepth = 0 private var reconnectAttempts = 0 @@ -196,6 +198,57 @@ open class Realtime : Service { return channel.toString() } + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + /// + /// - Parameters: + /// - status: The presence status (required). + /// - permissions: Optional permission list to attach to the presence document. + /// - metadata: Optional metadata payload. + /// - presenceId: Optional presence ID. Defaults server-side to a new unique ID. + /// - Returns: The created or updated presence document as a dictionary. + public func createPresence( + status: String, + permissions: [String]? = nil, + metadata: [String: Any]? = nil, + presenceId: String? = nil + ) async throws -> [String: Any] { + guard let ws = socketClient, ws.isConnected else { + throw {{ spec.title | caseUcfirst }}Error(message: "Realtime connection is not open. Subscribe to a channel first.") + } + + var data: [String: Any] = ["status": status] + if let permissions = permissions { + data["permissions"] = permissions + } + if let metadata = metadata { + data["metadata"] = metadata + } + if let presenceId = presenceId { + data["presenceId"] = presenceId + } + + let payload: [String: Any] = [ + "type": "presence", + "data": data + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), + let text = String(data: jsonData, encoding: .utf8) else { + throw {{ spec.title | caseUcfirst }}Error(message: "Failed to encode presence payload") + } + + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Swift.Error>) in + presenceSync.sync { + pendingPresenceRequests.append(continuation) + } + ws.send(text: text) + } + } + public func subscribe( channel: ChannelValue, callback: @escaping (RealtimeResponseEvent) -> Void, @@ -360,6 +413,21 @@ extension Realtime: WebSocketClientDelegate { } private func handleResponseAction(from json: [String: Any]) { + if let data = json["data"] as? [String: Any], + let to = data["to"] as? String, + to == "presence", + let presence = data["presence"] as? [String: Any] { + // Presence responses arrive in the same order as the requests on a + // single websocket, so a FIFO queue is enough to correlate them. + var continuation: CheckedContinuation<[String: Any], Swift.Error>? + presenceSync.sync { + if !pendingPresenceRequests.isEmpty { + continuation = pendingPresenceRequests.removeFirst() + } + } + continuation?.resume(returning: presence) + return + } // The SDK generates subscriptionIds client-side and sends them on every // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state // the SDK needs to reconcile. @@ -421,7 +489,21 @@ extension Realtime: WebSocketClientDelegate { } func handleResponseError(from json: [String: Any]) throws { - throw {{ spec.title | caseUcfirst }}Error(message: json["message"] as? String ?? "Unknown error") + let message = json["message"] as? String ?? "Unknown error" + let error = {{ spec.title | caseUcfirst }}Error(message: message) + + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving continuations hanging. + var pending: [CheckedContinuation<[String: Any], Swift.Error>] = [] + presenceSync.sync { + pending = pendingPresenceRequests + pendingPresenceRequests.removeAll() + } + for continuation in pending { + continuation.resume(throwing: error) + } + + throw error } func handleResponseEvent(from json: [String: Any]) { diff --git a/templates/flutter/lib/src/realtime.dart.twig b/templates/flutter/lib/src/realtime.dart.twig index 2126620288..e83611ab96 100644 --- a/templates/flutter/lib/src/realtime.dart.twig +++ b/templates/flutter/lib/src/realtime.dart.twig @@ -60,6 +60,25 @@ abstract class Realtime extends Service { /// subscription when you want to tear everything down. Future disconnect(); + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + /// + /// ```dart + /// final presence = await realtime.createPresence( + /// status: 'online', + /// metadata: {'device': 'web'}, + /// ); + /// ``` + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }); + /// The [close code](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5) set when the WebSocket connection is closed. /// /// Before the connection has been closed, this will be `null`. diff --git a/templates/flutter/lib/src/realtime_base.dart.twig b/templates/flutter/lib/src/realtime_base.dart.twig index 11c7a4ffdf..b041571502 100644 --- a/templates/flutter/lib/src/realtime_base.dart.twig +++ b/templates/flutter/lib/src/realtime_base.dart.twig @@ -10,4 +10,12 @@ abstract class RealtimeBase implements Realtime { @override Future disconnect(); + + @override + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }); } diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index 8ae3c74033..b211033736 100644 --- a/templates/flutter/lib/src/realtime_browser.dart.twig +++ b/templates/flutter/lib/src/realtime_browser.dart.twig @@ -41,4 +41,19 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { }) { return subscribeTo(channels, queries); } + + @override + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }) { + return createPresenceTo( + status: status, + permissions: permissions, + metadata: metadata, + presenceId: presenceId, + ); + } } diff --git a/templates/flutter/lib/src/realtime_io.dart.twig b/templates/flutter/lib/src/realtime_io.dart.twig index 1735580b26..5a9bfd2034 100644 --- a/templates/flutter/lib/src/realtime_io.dart.twig +++ b/templates/flutter/lib/src/realtime_io.dart.twig @@ -50,6 +50,21 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { return subscribeTo(channels, queries); } + @override + Future> createPresence({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }) { + return createPresenceTo( + status: status, + permissions: permissions, + metadata: metadata, + presenceId: presenceId, + ); + } + // https://github.com/jonataslaw/getsocket/blob/f25b3a264d8cc6f82458c949b86d286cd0343792/lib/src/io.dart#L104 // and from official dart sdk websocket_impl.dart connect method Future _connectForSelfSignedCert( diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index 4eab921346..bd2ed4a133 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -30,6 +30,7 @@ mixin RealtimeMixin { late Client client; final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; + final List>> _pendingPresenceRequests = []; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -127,6 +128,24 @@ mixin RealtimeMixin { _startHeartbeat(); // Start heartbeat after successful connection break; case 'response': + final responseData = data.data is Map + ? data.data as Map + : null; + if (responseData != null && responseData['to'] == 'presence') { + final presence = responseData['presence']; + if (presence is Map) { + // Presence responses arrive in the same order as the requests + // on a single websocket, so a FIFO queue is enough to correlate + // them. + if (_pendingPresenceRequests.isNotEmpty) { + final completer = _pendingPresenceRequests.removeAt(0); + if (!completer.isCompleted) { + completer.complete(presence); + } + } + } + break; + } // The SDK generates subscriptionIds client-side and sends them on // every subscribe/unsubscribe, so subscribe/unsubscribe acks carry // no state the SDK needs to reconcile. @@ -353,10 +372,62 @@ mixin RealtimeMixin { } void handleError(RealtimeResponse response) { + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving futures hanging. + if (_pendingPresenceRequests.isNotEmpty) { + final ex = {{spec.title | caseUcfirst}}Exception( + response.data["message"], + response.data["code"], + ); + final pending = List>>.from(_pendingPresenceRequests); + _pendingPresenceRequests.clear(); + for (final completer in pending) { + if (!completer.isCompleted) { + completer.completeError(ex); + } + } + } + if (response.data['code'] == status.policyViolation) { throw {{spec.title | caseUcfirst}}Exception(response.data["message"], response.data["code"]); } else { _retry(); } } + + /// Create or upsert a presence entry for the current authenticated user + /// over the existing realtime connection. + /// + /// Requires an authenticated user and an open WebSocket connection + /// (subscribe to a channel first if you don't have one yet). + Future> createPresenceTo({ + required String status, + List? permissions, + Map? metadata, + String? presenceId, + }) async { + final ws = _websok; + if (ws == null || ws.closeCode != null) { + throw {{spec.title | caseUcfirst}}Exception( + 'Realtime connection is not open. Subscribe to a channel first.', + ); + } + + final data = {'status': status}; + if (permissions != null) data['permissions'] = permissions; + if (metadata != null) data['metadata'] = metadata; + if (presenceId != null) data['presenceId'] = presenceId; + + final completer = Completer>(); + _pendingPresenceRequests.add(completer); + + try { + ws.sink.add(jsonEncode({'type': 'presence', 'data': data})); + } catch (e) { + _pendingPresenceRequests.remove(completer); + rethrow; + } + + return completer.future; + } } \ No newline at end of file diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index ec7d3cf593..90e5415154 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -55,10 +55,31 @@ export type RealtimeResponseConnected = { } export type RealtimeRequest = { - type: 'authentication' | 'subscribe' | 'unsubscribe'; + type: 'authentication' | 'subscribe' | 'unsubscribe' | 'presence'; data: any; } +export type RealtimePresence = { + $id: string; + $sequence?: string | number; + $createdAt: string; + $updatedAt: string; + $permissions: string[]; + userInternalId: string; + userId: string; + status?: string; + source: string; + expiry?: string; + metadata?: Record; +} + +export type RealtimePresenceCreate = { + status: string; + permissions?: string[]; + metadata?: Record; + presenceId?: string; +} + type RealtimeRequestSubscribeRow = { subscriptionId?: string; channels: string[]; @@ -84,6 +105,10 @@ export class Realtime { private socket?: WebSocket; private activeSubscriptions = new Map>(); private pendingSubscribes = new Map(); + private pendingPresenceRequests: Array<{ + resolve: (presence: RealtimePresence) => void; + reject: (error: Error) => void; + }> = []; private heartbeatTimer?: number; private subCallDepth = 0; @@ -544,6 +569,51 @@ export class Realtime { return { unsubscribe, update, close }; } + /** + * Create or upsert a presence entry for the current authenticated user + * over the existing realtime connection. + * + * Requires an authenticated user and an open WebSocket connection + * (subscribe to a channel first if you don't have one yet). + * + * @param {RealtimePresenceCreate} params - Presence payload (status required, permissions/metadata/presenceId optional) + * @returns {Promise} The created or updated presence document + */ + public async createPresence(params: RealtimePresenceCreate): Promise { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new {{spec.title | caseUcfirst}}Exception('Realtime connection is not open. Subscribe to a channel first.'); + } + + const data: Record = { + status: params.status, + }; + if (params.permissions !== undefined) { + data.permissions = params.permissions; + } + if (params.metadata !== undefined) { + data.metadata = params.metadata; + } + if (params.presenceId !== undefined) { + data.presenceId = params.presenceId; + } + + return new Promise((resolve, reject) => { + this.pendingPresenceRequests.push({ resolve, reject }); + try { + this.socket!.send(JSONbig.stringify({ + type: 'presence', + data + })); + } catch (error) { + const idx = this.pendingPresenceRequests.findIndex(r => r.resolve === resolve); + if (idx !== -1) { + this.pendingPresenceRequests.splice(idx, 1); + } + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + private handleMessage(message: RealtimeResponse): void { if (!message.type) { return; @@ -605,6 +675,14 @@ export class Realtime { message.data?.message || 'Unknown error' ); const statusCode = message.data?.code; + + // Server errors are not correlated with a specific request, so reject + // every in-flight presence request to avoid leaving promises hanging. + while (this.pendingPresenceRequests.length > 0) { + const pending = this.pendingPresenceRequests.shift(); + pending?.reject(error); + } + this.onErrorCallbacks.forEach(callback => callback(error, statusCode)); } @@ -639,7 +717,15 @@ export class Realtime { } } - private handleResponseAction(_message: RealtimeResponse): void { + private handleResponseAction(message: RealtimeResponse): void { + const data = message.data; + if (data?.to === 'presence' && data.presence !== undefined) { + // Presence responses arrive in the same order as the requests on a + // single websocket, so a FIFO queue is enough to correlate them. + const pending = this.pendingPresenceRequests.shift(); + pending?.resolve(data.presence as RealtimePresence); + return; + } // The SDK generates subscriptionIds client-side and sends them on every // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state // the SDK needs to reconcile. From f317b1761605aaf063f1b997e8031c3f94bf48c3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 30 Apr 2026 18:04:09 +0530 Subject: [PATCH 02/30] Add support for customizable additional properties key in model templates --- src/Spec/Swagger2.php | 6 +++-- .../main/java/io/package/models/Model.kt.twig | 12 ++++----- templates/dart/lib/src/models/model.dart.twig | 10 +++---- templates/dotnet/Package/Models/Model.cs.twig | 14 +++++----- .../kotlin/io/appwrite/models/Model.kt.twig | 12 ++++----- templates/php/src/Models/Model.php.twig | 8 +++--- templates/python/package/models/model.py.twig | 26 +++++++++---------- .../ruby/lib/container/models/model.rb.twig | 12 ++++----- templates/rust/src/models/model.rs.twig | 8 +++--- .../swift/Sources/Models/Model.swift.twig | 16 ++++++------ 10 files changed, 63 insertions(+), 61 deletions(-) diff --git a/src/Spec/Swagger2.php b/src/Spec/Swagger2.php index dcb4c120bc..b32752cdbd 100644 --- a/src/Spec/Swagger2.php +++ b/src/Spec/Swagger2.php @@ -598,7 +598,8 @@ public function getDefinitions(): array 'properties' => $schema['properties'] ?? [], 'description' => $schema['description'] ?? '', 'required' => $schema['required'] ?? [], - 'additionalProperties' => $schema['additionalProperties'] ?? [] + 'additionalProperties' => $schema['additionalProperties'] ?? [], + 'additionalPropertiesKey' => $schema['x-additional-properties-key'] ?? 'data', ]; if (isset($model['properties'])) { foreach ($model['properties'] as $name => $def) { @@ -660,7 +661,8 @@ public function getRequestModels(): array 'properties' => $schema['properties'] ?? [], 'description' => $schema['description'] ?? '', 'required' => $schema['required'] ?? [], - 'additionalProperties' => $schema['additionalProperties'] ?? [] + 'additionalProperties' => $schema['additionalProperties'] ?? [], + 'additionalPropertiesKey' => $schema['x-additional-properties-key'] ?? 'data', ]; if (isset($model['properties'])) { foreach ($model['properties'] as $name => $def) { diff --git a/templates/android/library/src/main/java/io/package/models/Model.kt.twig b/templates/android/library/src/main/java/io/package/models/Model.kt.twig index 2f71cedc0a..39fd401632 100644 --- a/templates/android/library/src/main/java/io/package/models/Model.kt.twig +++ b/templates/android/library/src/main/java/io/package/models/Model.kt.twig @@ -26,8 +26,8 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} /** * Additional properties */ - @SerializedName("data") - val data: T + @SerializedName("{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}") + val {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: T {%~ endif %} ) { fun toMap(): Map = mapOf( @@ -35,7 +35,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} "{{ property.name | escapeDollarSign }}" to {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword | removeDollarSign}}.map { it.toMap() }{% else %}{{property.name | escapeKeyword | removeDollarSign}}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% endif %} as Any, {%~ endfor %} {%~ if definition.additionalProperties %} - "data" to data!!.jsonCast(to = Map::class.java) + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}" to {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}!!.jsonCast(to = Map::class.java) {%~ endif %} ) @@ -46,14 +46,14 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'Map') | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data: Map + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: Map {%~ endif %} ) = {{ definition | modelType(spec, 'Map') | raw }}( {%~ for property in definition.properties %} {{ property.name | escapeKeyword | removeDollarSign }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} {%~ endif %} ) {%~ endif %} @@ -69,7 +69,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }} = {{ property | propertyAssignment(spec) | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} = map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) {%~ endif %} ) } diff --git a/templates/dart/lib/src/models/model.dart.twig b/templates/dart/lib/src/models/model.dart.twig index 6ee3db0310..5f6861da4c 100644 --- a/templates/dart/lib/src/models/model.dart.twig +++ b/templates/dart/lib/src/models/model.dart.twig @@ -9,7 +9,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {% endfor %} {%~ if definition.additionalProperties %} - final Map data; + final Map {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}; {% endif %} {{ definition.name | caseUcfirst | overrideIdentifier}}({% if definition.properties | length or definition.additionalProperties %}{{ '{' }}{% endif %} @@ -18,7 +18,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {% if property.required %}required {% endif %}this.{{ property.name | escapeKeyword }}, {% endfor %} {% if definition.additionalProperties %} - required this.data, + required this.{{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}, {% endif %} {% if definition.properties | length or definition.additionalProperties %}{{ '}' }}{% endif %}); @@ -54,7 +54,7 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model {%- endif -%}, {% endfor %} {% if definition.additionalProperties %} - data: map["data"] ?? map, + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}: map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"] ?? map, {% endif %} ); } @@ -66,13 +66,13 @@ class {{ definition.name | caseUcfirst | overrideIdentifier }} implements Model "{{ property.name | escapeDollarSign }}": {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword}}.map((p) => p.toMap()).toList(){% else %}{{property.name | escapeKeyword}}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword }}{% endif %}, {% endfor %} {% if definition.additionalProperties %} - "data": data, + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}": {{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}, {% endif %} }; } {% if definition.additionalProperties %} - T convertTo(T Function(Map) fromJson) => fromJson(data); + T convertTo(T Function(Map) fromJson) => fromJson({{ definition.additionalPropertiesKey | default('data') | escapeKeyword }}); {% endif %} {% for property in definition.properties %} {% if property.sub_schema %} diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index c0fff895a9..ed66b1b269 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -17,7 +17,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - public Dictionary Data { get; private set; } + public Dictionary {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} { get; private set; } {%~ endif %} public {{ definition.name | caseUcfirst | overrideIdentifier }}( @@ -26,7 +26,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - Dictionary data + Dictionary {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }} {%~ endif %} ) { @@ -34,7 +34,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {{ property_name(definition, property) | overrideProperty(definition.name) }} = {{ property.name | caseCamel | escapeKeyword }}; {%~ endfor %} {%~ if definition.additionalProperties %} - Data = data; + {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} = {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }}; {%~ endif %} } @@ -81,8 +81,8 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endfor %} {%- if definition.additionalProperties %} - data: map.TryGetValue("data", out var dataValue) - ? (Dictionary)dataValue + {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }}: map.TryGetValue("{{ definition.additionalPropertiesKey | default('data') }}", out var additionalPropsValue) + ? (Dictionary)additionalPropsValue : map {%- endif ~%} ); @@ -94,13 +94,13 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - { "data", Data } + { "{{ definition.additionalPropertiesKey | default('data') }}", {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} } {%~ endif %} }; {%~ if definition.additionalProperties %} public T ConvertTo(Func, T> fromJson) => - fromJson.Invoke(Data); + fromJson.Invoke({{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }}); {%~ endif %} {%~ for property in definition.properties %} {%~ if property.sub_schema %} diff --git a/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig b/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig index 2f71cedc0a..39fd401632 100644 --- a/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig +++ b/templates/kotlin/src/main/kotlin/io/appwrite/models/Model.kt.twig @@ -26,8 +26,8 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} /** * Additional properties */ - @SerializedName("data") - val data: T + @SerializedName("{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}") + val {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: T {%~ endif %} ) { fun toMap(): Map = mapOf( @@ -35,7 +35,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} "{{ property.name | escapeDollarSign }}" to {% if property.sub_schema %}{% if property.type == 'array' %}{{property.name | escapeKeyword | removeDollarSign}}.map { it.toMap() }{% else %}{{property.name | escapeKeyword | removeDollarSign}}.toMap(){% endif %}{% elseif property.enum %}{{property.name | escapeKeyword | removeDollarSign}}{% if not property.required %}?{% endif %}.value{% else %}{{property.name | escapeKeyword | removeDollarSign}}{% endif %} as Any, {%~ endfor %} {%~ if definition.additionalProperties %} - "data" to data!!.jsonCast(to = Map::class.java) + "{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}" to {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}!!.jsonCast(to = Map::class.java) {%~ endif %} ) @@ -46,14 +46,14 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }}: {{ property | propertyType(spec, 'Map') | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data: Map + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }}: Map {%~ endif %} ) = {{ definition | modelType(spec, 'Map') | raw }}( {%~ for property in definition.properties %} {{ property.name | escapeKeyword | removeDollarSign }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} {%~ endif %} ) {%~ endif %} @@ -69,7 +69,7 @@ import {{ sdk.namespace | caseDot }}.enums.{{ property.enumName | caseUcfirst }} {{ property.name | escapeKeyword | removeDollarSign }} = {{ property | propertyAssignment(spec) | raw }}, {%~ endfor %} {%~ if definition.additionalProperties %} - data = map["data"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) + {{ definition.additionalPropertiesKey | default('data') | escapeKeyword | removeDollarSign }} = map["{{ definition.additionalPropertiesKey | default('data') | escapeDollarSign }}"]?.jsonCast(to = nestedType) ?: map.jsonCast(to = nestedType) {%~ endif %} ) } diff --git a/templates/php/src/Models/Model.php.twig b/templates/php/src/Models/Model.php.twig index eed33bc427..98dda521b1 100644 --- a/templates/php/src/Models/Model.php.twig +++ b/templates/php/src/Models/Model.php.twig @@ -52,7 +52,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} * @param {{ paramDocType | raw }}|null ${{ property.name | caseCamel }} {{ property.description | unescape | lower | raw }} {% endfor %} {% if definition.additionalProperties %} - * @param array $data Additional properties. + * @param array ${{ definition.additionalPropertiesKey | default('data') | caseCamel }} Additional properties. {% endif %} */ public function __construct( @@ -63,7 +63,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} public ?{{ property | typeName }} ${{ property.name | caseCamel }} = null{{ (not loop.last or definition.additionalProperties) ? ',' : '' }} {% endfor %} {% if definition.additionalProperties %} - public array $data = [] + public array ${{ definition.additionalPropertiesKey | default('data') | caseCamel }} = [] {% endif %} ) { } @@ -140,7 +140,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - data: $additionalProperties + {{ definition.additionalPropertiesKey | default('data') | caseCamel }}: $additionalProperties {% endif %} ); {% endif %} @@ -161,7 +161,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} ]; {% if definition.additionalProperties %} - foreach (static::serializeAdditionalProperties($this->data) as $field => $value) { + foreach (static::serializeAdditionalProperties($this->{{ definition.additionalPropertiesKey | default('data') | caseCamel }}) as $field => $value) { $result[$field] = $value; } {% endif %} diff --git a/templates/python/package/models/model.py.twig b/templates/python/package/models/model.py.twig index 0ff5843e70..663720a293 100644 --- a/templates/python/package/models/model.py.twig +++ b/templates/python/package/models/model.py.twig @@ -70,7 +70,7 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener internal_fields = {k: v for k, v in data.items() if k.startswith('$')} user_data = {k: v for k, v in data.items() if not k.startswith('$')} instance = cls.model_validate(internal_fields) - instance._data = model_type(**user_data) if model_type is not dict else user_data + instance._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} = model_type(**user_data) if model_type is not dict else user_data return instance {% else %} instance = cls.model_validate(data) @@ -94,24 +94,24 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener {% endif %} {% if definition.additionalProperties %} - _data: Any = PrivateAttr(default_factory=dict) + _{{ definition.additionalPropertiesKey | default('data') | caseSnake }}: Any = PrivateAttr(default_factory=dict) @property - def data(self) -> T: - return cast(T, self._data) + def {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(self) -> T: + return cast(T, self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}) - @data.setter - def data(self, value: T) -> None: - object.__setattr__(self, '_data', value) + @{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.setter + def {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(self, value: T) -> None: + object.__setattr__(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake }}', value) def to_dict(self) -> Dict[str, Any]: result = super().to_dict() - if hasattr(self, '_data'): - if isinstance(self._data, dict): - result['data'] = self._data - elif hasattr(self._data, 'model_dump'): - result['data'] = self._data.model_dump(mode='json') + if hasattr(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake }}'): + if isinstance(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}, dict): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} + elif hasattr(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}, 'model_dump'): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.model_dump(mode='json') else: - result['data'] = self._data + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} return result {% endif %} diff --git a/templates/ruby/lib/container/models/model.rb.twig b/templates/ruby/lib/container/models/model.rb.twig index 0163b0614c..004df76a4e 100644 --- a/templates/ruby/lib/container/models/model.rb.twig +++ b/templates/ruby/lib/container/models/model.rb.twig @@ -8,7 +8,7 @@ module {{ spec.title | caseUcfirst }} attr_reader :{{ property.name | caseSnake | escapeKeyword }} {% endfor %} {% if definition.additionalProperties %} - attr_reader :data + attr_reader :{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} {% endif %} def initialize( @@ -17,7 +17,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - data: + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}: {% endif %} ) {% for property in definition.properties %} @@ -32,7 +32,7 @@ module {{ spec.title | caseUcfirst }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - @data = data + @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} = {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} {% endif %} end @@ -43,7 +43,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - data: map["data"] || map + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}: map["{{ definition.additionalPropertiesKey | default('data') }}"] || map {% endif %} ) end @@ -55,14 +55,14 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - "data": @data + "{{ definition.additionalPropertiesKey | default('data') }}": @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} {% endif %} } end {% if definition.additionalProperties %} def convert_to(from_json) - from_json.call(data) + from_json.call({{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}) end {% endif %} {% for property in definition.properties %} diff --git a/templates/rust/src/models/model.rs.twig b/templates/rust/src/models/model.rs.twig index c973cdbd2f..d7e6ee9673 100644 --- a/templates/rust/src/models/model.rs.twig +++ b/templates/rust/src/models/model.rs.twig @@ -26,7 +26,7 @@ pub struct {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} #[serde(flatten)] - pub data: HashMap, + pub {{ definition.additionalPropertiesKey | default('data') | caseSnake }}: HashMap, {% endif %} } @@ -57,12 +57,12 @@ impl {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} pub fn get(&self, key: &str) -> Option { - self.data.get(key) + self.{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.get(key) .and_then(|v| serde_json::from_value(v.clone()).ok()) } - pub fn data(&self) -> &HashMap { - &self.data + pub fn {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(&self) -> &HashMap { + &self.{{ definition.additionalPropertiesKey | default('data') | caseSnake }} } {% endif %} } diff --git a/templates/swift/Sources/Models/Model.swift.twig b/templates/swift/Sources/Models/Model.swift.twig index 76aa83f143..a17abfc442 100644 --- a/templates/swift/Sources/Models/Model.swift.twig +++ b/templates/swift/Sources/Models/Model.swift.twig @@ -15,7 +15,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { case {{ property.name | escapeSwiftKeyword | removeDollarSign }} = "{{ property.name }}" {%~ endfor %} {%~ if definition.additionalProperties %} - case data + case {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} {%~ endif %} } @@ -26,7 +26,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} /// Additional properties - public let data: T + public let {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: T {%~ endif %} init( @@ -35,14 +35,14 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - data: T + {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: T {%~ endif %} ) { {%~ for property in definition.properties %} self.{{ property.name | escapeSwiftKeyword | removeDollarSign }} = {{ property.name | escapeSwiftKeyword | removeDollarSign }} {%~ endfor %} {%~ if definition.additionalProperties %} - self.data = data + self.{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} {%~ endif %} } @@ -65,7 +65,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endif %} {%~ endfor %} {%~ if definition.additionalProperties %} - self.data = try container.decode(T.self, forKey: .data) + self.{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = try container.decode(T.self, forKey: .{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} } @@ -76,7 +76,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { try container.encode{% if not property.required %}IfPresent{% endif %}({{ property.name | escapeSwiftKeyword | removeDollarSign }}{% if property.enum %}{% if property.required %}.rawValue{% else %}?.rawValue{% endif %}{% endif %}, forKey: .{{ property.name | escapeSwiftKeyword | removeDollarSign }}) {%~ endfor %} {%~ if definition.additionalProperties %} - try container.encode(data, forKey: .data) + try container.encode({{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}, forKey: .{{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} } @@ -87,7 +87,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - "data": try! JSONEncoder().encode(data) + "{{ definition.additionalPropertiesKey | default('data') }}": try! JSONEncoder().encode({{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}) {%~ endif %} ] } @@ -115,7 +115,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { {%~ endfor %} {%~ if definition.additionalProperties %} - data: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["data"] as? [String: Any] ?? map, options: [])) + {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }}: try! JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: map["{{ definition.additionalPropertiesKey | default('data') }}"] as? [String: Any] ?? map, options: [])) {%~ endif %} ) } From dcd41a7ed3d0e3f28ad76af6441b0d666de06fcb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 7 May 2026 16:39:37 +0530 Subject: [PATCH 03/30] added presence api tests and mock server --- mock-server/app/http.php | 37 +++- .../src/Utopia/Realtime/Connection.php | 59 ++++++ mock-server/src/Utopia/Realtime/Protocol.php | 189 ++++++++++++++++++ tests/Base.php | 1 + tests/languages/android/Tests.kt | 33 +++ tests/languages/apple/Tests.swift | 31 +++ tests/languages/flutter/tests.dart | 30 +++ tests/languages/web/index.html | 31 +++ tests/languages/web/node.js | 1 + 9 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 mock-server/src/Utopia/Realtime/Connection.php create mode 100644 mock-server/src/Utopia/Realtime/Protocol.php diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 4daf9865b3..e5eb74a418 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -25,9 +25,11 @@ use Utopia\Validator\Nullable; use Utopia\Validator\WhiteList; use Swoole\Process; -use Swoole\Http\Server; +use Swoole\WebSocket\Server; +use Swoole\WebSocket\Frame; use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; +use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; const APP_AUTH_TYPE_SESSION = 'Session'; const APP_AUTH_TYPE_JWT = 'JWT'; @@ -885,4 +887,37 @@ function () use ($http) { $app->run($request, $response); }); +/** + * Realtime WebSocket mock at /v1/realtime?project=. + * + * Single Protocol instance is shared across worker invocations within the same + * worker process; per-connection state lives inside it keyed by Swoole fd. + */ +$realtimeProtocol = new RealtimeProtocol(); + +$http->on('open', function (Server $server, SwooleRequest $swooleRequest) use ($realtimeProtocol) { + $path = (string) ($swooleRequest->server['request_uri'] ?? ''); + if ($path !== '/v1/realtime') { + // Reject upgrades on any other path with a policy-violation close. + $server->disconnect($swooleRequest->fd, 1008, 'Invalid realtime path'); + return; + } + + $project = (string) ($swooleRequest->get['project'] ?? ''); + if ($project === '') { + $server->disconnect($swooleRequest->fd, 1008, 'Missing project'); + return; + } + + $realtimeProtocol->open($server, $swooleRequest->fd, $project); +}); + +$http->on('message', function (Server $server, Frame $frame) use ($realtimeProtocol) { + $realtimeProtocol->message($server, $frame->fd, (string) $frame->data); +}); + +$http->on('close', function (Server $server, int $fd) use ($realtimeProtocol) { + $realtimeProtocol->close($fd); +}); + $http->start(); diff --git a/mock-server/src/Utopia/Realtime/Connection.php b/mock-server/src/Utopia/Realtime/Connection.php new file mode 100644 index 0000000000..9fc258bfd0 --- /dev/null +++ b/mock-server/src/Utopia/Realtime/Connection.php @@ -0,0 +1,59 @@ + string[], 'queries' => string[]]`. + * + * @var array + */ + public array $subscriptions = []; + + public function __construct(int $fd, string $project = '') + { + $this->fd = $fd; + $this->project = $project; + } + + public function subscribe(string $subscriptionId, array $channels, array $queries): void + { + $this->subscriptions[$subscriptionId] = [ + 'channels' => array_values($channels), + 'queries' => array_values($queries), + ]; + } + + public function unsubscribe(string $subscriptionId): void + { + unset($this->subscriptions[$subscriptionId]); + } + + /** + * Return subscription IDs whose channel set intersects the given channels. + * + * @param string[] $channels + * @return string[] + */ + public function matchingSubscriptions(array $channels): array + { + $matches = []; + foreach ($this->subscriptions as $id => $sub) { + if (!empty(array_intersect($channels, $sub['channels']))) { + $matches[] = $id; + } + } + return $matches; + } +} diff --git a/mock-server/src/Utopia/Realtime/Protocol.php b/mock-server/src/Utopia/Realtime/Protocol.php new file mode 100644 index 0000000000..9bc8bd517b --- /dev/null +++ b/mock-server/src/Utopia/Realtime/Protocol.php @@ -0,0 +1,189 @@ + server: { type: 'authentication' | 'subscribe' | 'unsubscribe' | 'presence' | 'ping', data: ... } + * server -> client: { type: 'connected' | 'event' | 'response' | 'pong' | 'error', data: ... } + * + * The mock does NOT enforce real authorization or query filtering; it + * acknowledges client requests with shaped responses so SDK code paths + * (subscribe/unsubscribe/presence) can be exercised end-to-end. + */ +class Protocol +{ + /** Connection state by Swoole fd. */ + private array $connections = []; + + public function open(Server $server, int $fd, string $project): void + { + $this->connections[$fd] = new Connection($fd, $project); + + $this->send($server, $fd, 'connected', [ + 'channels' => [], + 'user' => null, + ]); + } + + public function close(int $fd): void + { + unset($this->connections[$fd]); + } + + public function message(Server $server, int $fd, string $raw): void + { + $connection = $this->connections[$fd] ?? null; + if ($connection === null) { + return; + } + + $message = json_decode($raw, true); + if (!is_array($message) || !isset($message['type'])) { + $this->error($server, $fd, 'Invalid message', 400); + return; + } + + $type = (string) $message['type']; + $data = $message['data'] ?? null; + + try { + match ($type) { + 'authentication' => $this->handleAuth($connection, $data), + 'subscribe' => $this->handleSubscribe($server, $connection, $data), + 'unsubscribe' => $this->handleUnsubscribe($connection, $data), + 'presence' => $this->handlePresence($server, $connection, $data), + 'ping' => $this->send($server, $fd, 'pong'), + default => $this->error($server, $fd, "Unknown message type: {$type}", 400), + }; + } catch (Exception $e) { + $this->error($server, $fd, $e->getMessage(), $e->getCode() ?: 400); + } catch (\Throwable $e) { + $this->error($server, $fd, $e->getMessage(), 500); + } + } + + private function handleAuth(Connection $connection, mixed $data): void + { + // Mock accepts any session and synthesises a user object. + if (is_array($data) && !empty($data['session'])) { + $connection->user = [ + '$id' => 'user_' . substr(md5((string) $data['session']), 0, 8), + 'name' => 'Mock User', + ]; + } + } + + private function handleSubscribe(Server $server, Connection $connection, mixed $data): void + { + if (!is_array($data)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'subscribe payload must be an array', 400); + } + + // SDKs send a list of subscription rows: [{ subscriptionId, channels, queries }] + $rows = $this->isList($data) ? $data : [$data]; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $subscriptionId = isset($row['subscriptionId']) ? (string) $row['subscriptionId'] : ''; + $channels = isset($row['channels']) && is_array($row['channels']) ? $row['channels'] : []; + $queries = isset($row['queries']) && is_array($row['queries']) ? $row['queries'] : []; + + if ($subscriptionId === '' || empty($channels)) { + continue; + } + $connection->subscribe($subscriptionId, $channels, $queries); + + // Confirm the subscription by emitting a synthetic event on the + // requested channels. The payload mirrors what the existing + // language test fixtures look for ("WS:/v1/realtime:passed"). + $this->send($server, $connection->fd, 'event', [ + 'channels' => array_values($channels), + 'events' => ['test.event'], + 'timestamp' => gmdate('Y-m-d\TH:i:s.000\+00:00'), + 'payload' => ['response' => 'WS:/v1/realtime:passed'], + 'subscriptions' => [$subscriptionId], + ]); + } + } + + private function handleUnsubscribe(Connection $connection, mixed $data): void + { + if (!is_array($data)) { + return; + } + $rows = $this->isList($data) ? $data : [$data]; + foreach ($rows as $row) { + if (is_array($row) && isset($row['subscriptionId'])) { + $connection->unsubscribe((string) $row['subscriptionId']); + } + } + } + + private function handlePresence(Server $server, Connection $connection, mixed $data): void + { + if (!is_array($data) || !isset($data['status'])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'presence payload requires status', 400); + } + + $now = gmdate('Y-m-d\TH:i:s.000\+00:00'); + $presence = [ + '$id' => $data['presenceId'] ?? ('presence_' . bin2hex(random_bytes(6))), + '$sequence' => '1', + '$createdAt' => $now, + '$updatedAt' => $now, + '$permissions' => $data['permissions'] ?? [], + 'userInternalId' => '1', + 'userId' => $connection->user['$id'] ?? '674af8f3e12a5f9ac0be', + 'status' => (string) $data['status'], + 'source' => 'WS', + ]; + if (isset($data['metadata']) && is_array($data['metadata'])) { + $presence['metadata'] = $data['metadata']; + } + if (isset($data['expiresAt'])) { + $presence['expiry'] = (string) $data['expiresAt']; + } + + $this->send($server, $connection->fd, 'response', [ + 'to' => 'presence', + 'presence' => $presence, + ]); + } + + private function send(Server $server, int $fd, string $type, mixed $data = null): void + { + if (!$server->isEstablished($fd)) { + return; + } + $payload = ['type' => $type]; + if ($data !== null) { + $payload['data'] = $data; + } + $server->push($fd, json_encode($payload, JSON_UNESCAPED_SLASHES)); + } + + private function error(Server $server, int $fd, string $message, int $code = 400): void + { + $this->send($server, $fd, 'error', [ + 'message' => $message, + 'code' => $code, + ]); + } + + private function isList(array $value): bool + { + if ($value === []) { + return false; + } + return array_keys($value) === range(0, count($value) - 1); + } +} diff --git a/tests/Base.php b/tests/Base.php index af56229171..36f5bac96c 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -116,6 +116,7 @@ abstract class Base extends TestCase 'Realtime unsubscribe:passed', 'Realtime update:passed', 'Realtime disconnect:passed', + 'Realtime presence:passed', ]; protected const QUERY_HELPER_RESPONSES = [ diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index d32e0ed6a4..99d02957a2 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -269,6 +269,39 @@ class ServiceTest { writeToFile("Realtime disconnect:failed") } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + try { + val presenceClient = Client(ApplicationProvider.getApplicationContext()) + .setProject("123456") + .setEndpointRealtime("ws://mockapi/v1") + val presenceRealtime = Realtime(presenceClient) + + // createPresence requires an open WebSocket; subscribing opens one. + presenceRealtime.subscribe( + "tests", + payloadType = TestPayload::class.java, + ) { /* no-op */ } + + val presence = presenceRealtime.createPresence( + status = "online", + metadata = mapOf("page" to "/home"), + presenceId = "p-test", + ) + + if (presence["\$id"] == "p-test" + && presence["status"] == "online" + && presence["source"] == "WS") { + writeToFile("Realtime presence:passed") + } else { + writeToFile("Realtime presence:failed") + } + + presenceRealtime.disconnect() + } catch (e: Exception) { + writeToFile("Realtime presence:failed") + } + // mock = general.setCookie() // writeToFile(mock.result) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index ed1314b328..fffae99182 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -243,6 +243,37 @@ class Tests: XCTestCase { print("Realtime disconnect:failed") } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + do { + let presenceClient = Client() + .setProject("123456") + .setEndpointRealtime("ws://mockapi/v1") + let presenceRealtime = Realtime(presenceClient) + + // createPresence requires an open WebSocket; subscribing opens one. + _ = try await presenceRealtime.subscribe(channels: ["tests"]) { _ in } + + let presence = try await presenceRealtime.createPresence( + status: "online", + metadata: ["page": "/home"], + presenceId: "p-test" + ) + + let id = presence["$id"] as? String + let status = presence["status"] as? String + let source = presence["source"] as? String + if id == "p-test" && status == "online" && source == "WS" { + print("Realtime presence:passed") + } else { + print("Realtime presence:failed") + } + + try await presenceRealtime.disconnect() + } catch { + print("Realtime presence:failed") + } + mock = try await general.setCookie() print(mock.result) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 913ed43aaf..15b8de2384 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -220,6 +220,36 @@ void main() async { print("Realtime disconnect:failed"); } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + try { + final presenceClient = Client() + .setProject('123456') + .setEndPointRealtime('ws://mockapi/v1'); + final presenceRealtime = Realtime(presenceClient); + + // createPresence requires an open WebSocket; subscribing opens one. + presenceRealtime.subscribe(['tests']); + + final presence = await presenceRealtime.createPresence( + status: 'online', + presenceId: 'p-test', + metadata: {'page': '/home'}, + ); + + if (presence[r'$id'] == 'p-test' + && presence['status'] == 'online' + && presence['source'] == 'WS') { + print("Realtime presence:passed"); + } else { + print("Realtime presence:failed"); + } + + await presenceRealtime.disconnect(); + } catch (e) { + print("Realtime presence:failed"); + } + response = await general.setCookie(); print(response.result); diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index a953a6b6b0..904a53f900 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -326,6 +326,37 @@ console.log('Realtime disconnect:failed'); } + // Realtime presence (upsertPresence) test against the mock WebSocket server. + // Uses a fresh Client/Realtime to avoid the cloud.appwrite.io endpoint set above. + try { + const presenceClient = new Client(); + presenceClient.setProject('123456'); + presenceClient.setEndpointRealtime('ws://mockapi/v1'); + + const presenceRealtime = new Realtime(presenceClient); + + // createPresence requires an open WebSocket; subscribing opens one. + await presenceRealtime.subscribe(['tests'], () => {}); + + const presence = await presenceRealtime.createPresence({ + status: 'online', + presenceId: 'p-test', + metadata: { page: '/home' }, + }); + + if (presence?.$id === 'p-test' + && presence?.status === 'online' + && presence?.source === 'WS') { + console.log('Realtime presence:passed'); + } else { + console.log('Realtime presence:failed'); + } + + await presenceRealtime.disconnect(); + } catch (e) { + console.log('Realtime presence:failed'); + } + // Query helper tests console.log(Query.equal("released", [true])); console.log(Query.equal("title", ["Spiderman", "Dr. Strange"])); diff --git a/tests/languages/web/node.js b/tests/languages/web/node.js index 340a779a1b..00d378d61c 100644 --- a/tests/languages/web/node.js +++ b/tests/languages/web/node.js @@ -193,6 +193,7 @@ async function start() { console.log('Realtime unsubscribe:passed'); // Skip new realtime API tests on Node.js console.log('Realtime update:passed'); console.log('Realtime disconnect:passed'); + console.log('Realtime presence:passed'); // Skip realtime presence test on Node.js // Query helper tests console.log(Query.equal("released", [true])); From 7a73a9dfccf8d17cda901b544966eaca068477d3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 18:48:42 +0530 Subject: [PATCH 04/30] Refactor WebSocket server integration and update dependencies --- mock-server/app/http.php | 71 +++++++++----------- mock-server/composer.json | 3 +- mock-server/composer.lock | 53 ++++++++++++++- mock-server/src/Utopia/Realtime/Protocol.php | 7 +- 4 files changed, 87 insertions(+), 47 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index e5eb74a418..17f50990a7 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -6,7 +6,6 @@ require_once __DIR__ . '/../vendor/autoload.php'; } -use Swoole\Constant; use Utopia\App; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; @@ -24,12 +23,11 @@ use Utopia\Validator\Host; use Utopia\Validator\Nullable; use Utopia\Validator\WhiteList; -use Swoole\Process; -use Swoole\WebSocket\Server; -use Swoole\WebSocket\Frame; use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; +use Utopia\WebSocket\Adapter; +use Utopia\WebSocket\Server as WebSocketServer; const APP_AUTH_TYPE_SESSION = 'Session'; const APP_AUTH_TYPE_JWT = 'JWT'; @@ -41,23 +39,24 @@ const APP_PLATFORM_CONSOLE = 'console'; const APP_STORAGE_CACHE = '/storage/cache'; -$http = new Server( - host: '0.0.0.0', - port: App::getEnv('PORT', 80), - mode: SWOOLE_PROCESS -); $payloadSize = 6 * (1024 * 1024); // 6MB $workerNumber = swoole_cpu_num() * intval(App::getEnv('_APP_WORKER_PER_CORE', 6)); -$http->set([ - 'worker_num' => $workerNumber, +$adapter = new Adapter\Swoole(host: '0.0.0.0', port: (int) App::getEnv('PORT', 80)); +$adapter + ->setPackageMaxLength($payloadSize) + ->setWorkerNumber($workerNumber); + +// Settings the adapter doesn't expose directly. +$adapter->getNative()->set([ 'open_http2_protocol' => true, 'http_compression' => true, 'http_compression_level' => 6, - 'package_max_length' => $payloadSize, 'buffer_output_size' => $payloadSize, ]); +$http = new WebSocketServer($adapter); + // Version Route for CLI App::get('/v1/health/version') ->desc('Get version') @@ -864,21 +863,23 @@ function ($utopia, $error, $request, $response) { ['utopia', 'error', 'request', 'response'] ); -$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize) { +/** + * Realtime WebSocket mock at /v1/realtime?project=. + * + * Single Protocol instance is shared across worker invocations within the same + * worker process; per-connection state lives inside it keyed by Swoole fd. + */ +$realtimeProtocol = new RealtimeProtocol(); + +$http->error(function (\Throwable $error, string $action) { + Console::error("[ws:{$action}] " . $error->getMessage()); +}); + +$http->onStart(function () use ($payloadSize) { Console::success('Server started successfully (max payload is ' . number_format($payloadSize) . ' bytes)'); - Console::info("Master pid {$http->master_pid}, manager pid {$http->manager_pid}"); - - // Listen for ctrl + c - Process::signal( - 2, - function () use ($http) { - Console::log('Stop by Ctrl+C'); - $http->shutdown(); - } - ); }); -$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { +$http->onRequest(function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { $request = new Request($swooleRequest); $response = new UtopiaSwooleResponse($swooleResponse); @@ -887,36 +888,28 @@ function () use ($http) { $app->run($request, $response); }); -/** - * Realtime WebSocket mock at /v1/realtime?project=. - * - * Single Protocol instance is shared across worker invocations within the same - * worker process; per-connection state lives inside it keyed by Swoole fd. - */ -$realtimeProtocol = new RealtimeProtocol(); - -$http->on('open', function (Server $server, SwooleRequest $swooleRequest) use ($realtimeProtocol) { +$http->onOpen(function (int $fd, SwooleRequest $swooleRequest) use ($http, $realtimeProtocol) { $path = (string) ($swooleRequest->server['request_uri'] ?? ''); if ($path !== '/v1/realtime') { // Reject upgrades on any other path with a policy-violation close. - $server->disconnect($swooleRequest->fd, 1008, 'Invalid realtime path'); + $http->close($fd, 1008); return; } $project = (string) ($swooleRequest->get['project'] ?? ''); if ($project === '') { - $server->disconnect($swooleRequest->fd, 1008, 'Missing project'); + $http->close($fd, 1008); return; } - $realtimeProtocol->open($server, $swooleRequest->fd, $project); + $realtimeProtocol->open($http, $fd, $project); }); -$http->on('message', function (Server $server, Frame $frame) use ($realtimeProtocol) { - $realtimeProtocol->message($server, $frame->fd, (string) $frame->data); +$http->onMessage(function (int $fd, string $data) use ($http, $realtimeProtocol) { + $realtimeProtocol->message($http, $fd, $data); }); -$http->on('close', function (Server $server, int $fd) use ($realtimeProtocol) { +$http->onClose(function (int $fd) use ($realtimeProtocol) { $realtimeProtocol->close($fd); }); diff --git a/mock-server/composer.json b/mock-server/composer.json index c9252629ca..25fe908a57 100644 --- a/mock-server/composer.json +++ b/mock-server/composer.json @@ -10,7 +10,8 @@ "utopia-php/framework": "0.33.*", "utopia-php/database": "0.48.*", "utopia-php/cli": "0.16.*", - "utopia-php/swoole": "0.8.*" + "utopia-php/swoole": "0.8.*", + "utopia-php/websocket": "*" }, "require-dev": { "swoole/ide-helper": "5.1.2" diff --git a/mock-server/composer.lock b/mock-server/composer.lock index d6957f70c3..7298d3377c 100644 --- a/mock-server/composer.lock +++ b/mock-server/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e8e3df78a113bec48bb61da0227ea50f", + "content-hash": "b47b2f3ffdc6af9069afbd204a222a77", "packages": [ { "name": "brick/math", @@ -2534,6 +2534,55 @@ "source": "https://github.com/utopia-php/validators/tree/0.1.0" }, "time": "2025-11-18T11:05:46+00:00" + }, + { + "name": "utopia-php/websocket", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/websocket.git", + "reference": "d230de8d4d2450184297327238ed1fbde676b8d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/websocket/zipball/d230de8d4d2450184297327238ed1fbde676b8d2", + "reference": "d230de8d4d2450184297327238ed1fbde676b8d2", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "laravel/pint": "^1.15", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^9.5.5", + "swoole/ide-helper": "5.1.2", + "textalk/websocket": "1.5.2", + "workerman/workerman": "4.1.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\WebSocket\\": "src/WebSocket" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple abstraction for WebSocket servers.", + "keywords": [ + "framework", + "php", + "upf", + "utopia", + "websocket" + ], + "support": { + "issues": "https://github.com/utopia-php/websocket/issues", + "source": "https://github.com/utopia-php/websocket/tree/1.0.0" + }, + "time": "2026-02-05T13:40:16+00:00" } ], "packages-dev": [ @@ -2577,5 +2626,5 @@ "prefer-lowest": false, "platform": {}, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/mock-server/src/Utopia/Realtime/Protocol.php b/mock-server/src/Utopia/Realtime/Protocol.php index 9bc8bd517b..121e41ce9f 100644 --- a/mock-server/src/Utopia/Realtime/Protocol.php +++ b/mock-server/src/Utopia/Realtime/Protocol.php @@ -2,8 +2,8 @@ namespace Utopia\MockServer\Utopia\Realtime; -use Swoole\WebSocket\Server; use Utopia\MockServer\Utopia\Exception; +use Utopia\WebSocket\Server; /** * Realtime WebSocket protocol handler for the mock server. @@ -161,14 +161,11 @@ private function handlePresence(Server $server, Connection $connection, mixed $d private function send(Server $server, int $fd, string $type, mixed $data = null): void { - if (!$server->isEstablished($fd)) { - return; - } $payload = ['type' => $type]; if ($data !== null) { $payload['data'] = $data; } - $server->push($fd, json_encode($payload, JSON_UNESCAPED_SLASHES)); + $server->send([$fd], json_encode($payload, JSON_UNESCAPED_SLASHES)); } private function error(Server $server, int $fd, string $message, int $code = 400): void From f7f47abed4ac8818c6c69032fe0b7ff855ec1af0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 18:51:58 +0530 Subject: [PATCH 05/30] updated mock server --- mock-server/composer.json | 2 +- mock-server/composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mock-server/composer.json b/mock-server/composer.json index 25fe908a57..12cb511b85 100644 --- a/mock-server/composer.json +++ b/mock-server/composer.json @@ -11,7 +11,7 @@ "utopia-php/database": "0.48.*", "utopia-php/cli": "0.16.*", "utopia-php/swoole": "0.8.*", - "utopia-php/websocket": "*" + "utopia-php/websocket": "^1.0" }, "require-dev": { "swoole/ide-helper": "5.1.2" diff --git a/mock-server/composer.lock b/mock-server/composer.lock index 7298d3377c..e737c1a633 100644 --- a/mock-server/composer.lock +++ b/mock-server/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b47b2f3ffdc6af9069afbd204a222a77", + "content-hash": "75e08ad65c5f41eca60eedde58cb8a1d", "packages": [ { "name": "brick/math", From fec309e717e330e9b10e28fce7e078e7eb8bcb3d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 11:10:30 +0530 Subject: [PATCH 06/30] added log for presence printing --- tests/languages/flutter/tests.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 15b8de2384..187cb4d3d1 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -237,6 +237,9 @@ void main() async { metadata: {'page': '/home'}, ); + // Debug: print the incoming presence payload + print('Realtime presence incoming: ${jsonEncode(presence)}'); + if (presence[r'$id'] == 'p-test' && presence['status'] == 'online' && presence['source'] == 'WS') { From 71d628f2ee6d144c5e43b0a85b267858b89466ad Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 11:38:43 +0530 Subject: [PATCH 07/30] added debugging logs --- tests/languages/flutter/tests.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 187cb4d3d1..7b59813bf2 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -238,7 +238,7 @@ void main() async { ); // Debug: print the incoming presence payload - print('Realtime presence incoming: ${jsonEncode(presence)}'); + print('[Debug] Realtime presence incoming: ${jsonEncode(presence)}'); if (presence[r'$id'] == 'p-test' && presence['status'] == 'online' @@ -250,6 +250,7 @@ void main() async { await presenceRealtime.disconnect(); } catch (e) { + print('[Debug] Realtime presence incoming: ${jsonEncode(e)}'); print("Realtime presence:failed"); } From b4fda07cb683f572c0a9c862e7d00ed631900686 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 12:40:22 +0530 Subject: [PATCH 08/30] addded logs --- tests/languages/flutter/tests.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 7b59813bf2..02359b40c6 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -250,7 +250,7 @@ void main() async { await presenceRealtime.disconnect(); } catch (e) { - print('[Debug] Realtime presence incoming: ${jsonEncode(e)}'); + print('[Debug] Realtime presence incoming: ${e.toString()}'); print("Realtime presence:failed"); } From 0fded059d6715ecfe2d3b3209760f9e86aa4d3cb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 13:00:34 +0530 Subject: [PATCH 09/30] separated realtime and http mock server --- mock-server/app/http.php | 75 ++++++++++++------------------------ mock-server/app/realtime.php | 50 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 50 deletions(-) create mode 100644 mock-server/app/realtime.php diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 17f50990a7..461be963ac 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; } +use Swoole\Constant; use Utopia\App; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; @@ -23,11 +24,10 @@ use Utopia\Validator\Host; use Utopia\Validator\Nullable; use Utopia\Validator\WhiteList; +use Swoole\Process; +use Swoole\Http\Server; use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; -use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; -use Utopia\WebSocket\Adapter; -use Utopia\WebSocket\Server as WebSocketServer; const APP_AUTH_TYPE_SESSION = 'Session'; const APP_AUTH_TYPE_JWT = 'JWT'; @@ -39,24 +39,23 @@ const APP_PLATFORM_CONSOLE = 'console'; const APP_STORAGE_CACHE = '/storage/cache'; +$http = new Server( + host: '0.0.0.0', + port: App::getEnv('PORT', 80), + mode: SWOOLE_PROCESS +); $payloadSize = 6 * (1024 * 1024); // 6MB $workerNumber = swoole_cpu_num() * intval(App::getEnv('_APP_WORKER_PER_CORE', 6)); -$adapter = new Adapter\Swoole(host: '0.0.0.0', port: (int) App::getEnv('PORT', 80)); -$adapter - ->setPackageMaxLength($payloadSize) - ->setWorkerNumber($workerNumber); - -// Settings the adapter doesn't expose directly. -$adapter->getNative()->set([ +$http->set([ + 'worker_num' => $workerNumber, 'open_http2_protocol' => true, 'http_compression' => true, 'http_compression_level' => 6, + 'package_max_length' => $payloadSize, 'buffer_output_size' => $payloadSize, ]); -$http = new WebSocketServer($adapter); - // Version Route for CLI App::get('/v1/health/version') ->desc('Get version') @@ -863,23 +862,21 @@ function ($utopia, $error, $request, $response) { ['utopia', 'error', 'request', 'response'] ); -/** - * Realtime WebSocket mock at /v1/realtime?project=. - * - * Single Protocol instance is shared across worker invocations within the same - * worker process; per-connection state lives inside it keyed by Swoole fd. - */ -$realtimeProtocol = new RealtimeProtocol(); - -$http->error(function (\Throwable $error, string $action) { - Console::error("[ws:{$action}] " . $error->getMessage()); -}); - -$http->onStart(function () use ($payloadSize) { +$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize) { Console::success('Server started successfully (max payload is ' . number_format($payloadSize) . ' bytes)'); + Console::info("Master pid {$http->master_pid}, manager pid {$http->manager_pid}"); + + // Listen for ctrl + c + Process::signal( + 2, + function () use ($http) { + Console::log('Stop by Ctrl+C'); + $http->shutdown(); + } + ); }); -$http->onRequest(function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { +$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { $request = new Request($swooleRequest); $response = new UtopiaSwooleResponse($swooleResponse); @@ -888,29 +885,7 @@ function ($utopia, $error, $request, $response) { $app->run($request, $response); }); -$http->onOpen(function (int $fd, SwooleRequest $swooleRequest) use ($http, $realtimeProtocol) { - $path = (string) ($swooleRequest->server['request_uri'] ?? ''); - if ($path !== '/v1/realtime') { - // Reject upgrades on any other path with a policy-violation close. - $http->close($fd, 1008); - return; - } - - $project = (string) ($swooleRequest->get['project'] ?? ''); - if ($project === '') { - $http->close($fd, 1008); - return; - } - - $realtimeProtocol->open($http, $fd, $project); -}); - -$http->onMessage(function (int $fd, string $data) use ($http, $realtimeProtocol) { - $realtimeProtocol->message($http, $fd, $data); -}); - -$http->onClose(function (int $fd) use ($realtimeProtocol) { - $realtimeProtocol->close($fd); -}); +// to run http and realtime in the same process and listen on same port +require __DIR__ . '/realtime.php'; $http->start(); diff --git a/mock-server/app/realtime.php b/mock-server/app/realtime.php new file mode 100644 index 0000000000..c527b5d01a --- /dev/null +++ b/mock-server/app/realtime.php @@ -0,0 +1,50 @@ +. + * + * Expects $http (WebSocketServer) to be in scope from the including file. + * + * Single Protocol instance is shared across worker invocations within the same + * worker process; per-connection state lives inside it keyed by Swoole fd. + */ + +/** @var WebSocketServer $http */ + +$realtimeProtocol = new RealtimeProtocol(); + +$http->error(function (\Throwable $error, string $action) { + Console::error("[ws:{$action}] " . $error->getMessage()); +}); + +$http->onOpen(function (int $fd, SwooleRequest $swooleRequest) use ($http, $realtimeProtocol) { + $path = (string) ($swooleRequest->server['request_uri'] ?? ''); + if ($path !== '/v1/realtime') { + // Reject upgrades on any other path with a policy-violation close. + $http->close($fd, 1008); + return; + } + + $project = (string) ($swooleRequest->get['project'] ?? ''); + if ($project === '') { + $http->close($fd, 1008); + return; + } + + $realtimeProtocol->open($http, $fd, $project); +}); + +$http->onMessage(function (int $fd, string $data) use ($http, $realtimeProtocol) { + $realtimeProtocol->message($http, $fd, $data); +}); + +$http->onClose(function (int $fd) use ($realtimeProtocol) { + $realtimeProtocol->close($fd); +}); From 19d45533452f4ca189cf5e4d3f771a40ccf924f7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 14:39:02 +0530 Subject: [PATCH 10/30] fixed model issue --- templates/swift/Sources/Models/Model.swift.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/swift/Sources/Models/Model.swift.twig b/templates/swift/Sources/Models/Model.swift.twig index a17abfc442..aab105758c 100644 --- a/templates/swift/Sources/Models/Model.swift.twig +++ b/templates/swift/Sources/Models/Model.swift.twig @@ -15,7 +15,7 @@ open class {{ definition | modelType(spec) | raw }}: Codable { case {{ property.name | escapeSwiftKeyword | removeDollarSign }} = "{{ property.name }}" {%~ endfor %} {%~ if definition.additionalProperties %} - case {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} + case {{ definition.additionalPropertiesKey | default('data') | escapeSwiftKeyword | removeDollarSign }} = "{{ definition.additionalPropertiesKey | default('data') }}" {%~ endif %} } From 45a27b78fa157dc3124a37b192216be1ca8d77dc Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 16:07:41 +0530 Subject: [PATCH 11/30] refactor: change createPresence to fire-and-forget across all platforms --- .../java/io/package/services/Realtime.kt.twig | 66 +++---------------- .../Sources/Services/Realtime.swift.twig | 53 +++------------ templates/flutter/lib/src/realtime.dart.twig | 10 ++- .../flutter/lib/src/realtime_base.dart.twig | 2 +- .../lib/src/realtime_browser.dart.twig | 4 +- .../flutter/lib/src/realtime_io.dart.twig | 4 +- .../flutter/lib/src/realtime_mixin.dart.twig | 66 +++---------------- tests/languages/android/Tests.kt | 16 ++--- tests/languages/apple/Tests.swift | 17 ++--- tests/languages/flutter/tests.dart | 19 ++---- 10 files changed, 58 insertions(+), 199 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index cf1df68a33..2797fa135a 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -43,7 +43,6 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var socket: RealWebSocket? = null private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() - private val pendingPresenceRequests = ArrayDeque>>() private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -51,7 +50,6 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var heartbeatJob: Job? = null private val subscriptionLock = Any() - private val presenceLock = Any() } private fun createSocket() { @@ -194,24 +192,21 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } /** - * Create or upsert a presence entry for the current authenticated user - * over the existing realtime connection. - * - * Requires an authenticated user and an open WebSocket connection - * (subscribe to a channel first if you don't have one yet). + * Fire-and-forget presence upsert. Mirrors the `subscribe()` shape — call + * without coroutines/await after the WebSocket has had a moment to open. + * Throws synchronously if no open connection exists. * * @param status Presence status (required). * @param permissions Optional permission list to attach to the presence document. * @param metadata Optional metadata payload. * @param presenceId Optional presence ID. Defaults server-side to a new unique ID. - * @return The created or updated presence document as a map. */ - suspend fun createPresence( + fun createPresence( status: String, permissions: List? = null, metadata: Map? = null, presenceId: String? = null, - ): Map { + ) { val ws = socket ?: throw {{ spec.title | caseUcfirst }}Exception( "Realtime connection is not open. Subscribe to a channel first." ) @@ -221,21 +216,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { metadata?.let { data["metadata"] = it } presenceId?.let { data["presenceId"] = it } - val deferred = CompletableDeferred>() - synchronized(presenceLock) { - pendingPresenceRequests.addLast(deferred) - } - - try { - ws.send(mapOf("type" to "presence", "data" to data).toJson()) - } catch (e: Throwable) { - synchronized(presenceLock) { - pendingPresenceRequests.remove(deferred) - } - throw e - } - - return deferred.await() + ws.send(mapOf("type" to "presence", "data" to data).toJson()) } fun subscribe( @@ -404,42 +385,13 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } private fun handleResponseAction(message: RealtimeResponse) { - val data = message.data?.jsonCast>() - if (data != null && data["to"] == "presence") { - @Suppress("UNCHECKED_CAST") - val presence = data["presence"] as? Map - if (presence != null) { - // Presence responses arrive in the same order as the requests on a - // single websocket, so a FIFO queue is enough to correlate them. - val deferred: CompletableDeferred>? - synchronized(presenceLock) { - deferred = if (pendingPresenceRequests.isNotEmpty()) { - pendingPresenceRequests.removeFirst() - } else { - null - } - } - deferred?.complete(presence) - return - } - } - // The SDK generates subscriptionIds client-side and sends them on every - // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state - // the SDK needs to reconcile. + // createPresence is fire-and-forget, so presence responses (and + // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates + // client-side) carry no state the SDK needs to reconcile. } private fun handleResponseError(message: RealtimeResponse) { val ex = message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") - - // Server errors are not correlated with a specific request, so reject - // every in-flight presence request to avoid leaving deferred awaits hanging. - val pending: List>> - synchronized(presenceLock) { - pending = pendingPresenceRequests.toList() - pendingPresenceRequests.clear() - } - pending.forEach { it.completeExceptionally(ex) } - throw ex } diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index e78476e458..5ec967970d 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -21,11 +21,9 @@ open class Realtime : Service { private var socketClient: WebSocketClient? = nil private var activeSubscriptions = [String: RealtimeCallback]() private var pendingSubscribes = [String: [String: Any]]() - private var pendingPresenceRequests: [CheckedContinuation<[String: Any], Swift.Error>] = [] private var heartbeatTask: Task? = nil let connectSync = DispatchQueue(label: "ConnectSync") - let presenceSync = DispatchQueue(label: "PresenceSync") private var subCallDepth = 0 private var reconnectAttempts = 0 @@ -198,24 +196,21 @@ open class Realtime : Service { return channel.toString() } - /// Create or upsert a presence entry for the current authenticated user - /// over the existing realtime connection. - /// - /// Requires an authenticated user and an open WebSocket connection - /// (subscribe to a channel first if you don't have one yet). + /// Fire-and-forget presence upsert. Mirrors the `subscribe()` shape — call + /// without `await` after the WebSocket has had a moment to open. Throws + /// synchronously if no open connection exists. /// /// - Parameters: /// - status: The presence status (required). /// - permissions: Optional permission list to attach to the presence document. /// - metadata: Optional metadata payload. /// - presenceId: Optional presence ID. Defaults server-side to a new unique ID. - /// - Returns: The created or updated presence document as a dictionary. public func createPresence( status: String, permissions: [String]? = nil, metadata: [String: Any]? = nil, presenceId: String? = nil - ) async throws -> [String: Any] { + ) throws { guard let ws = socketClient, ws.isConnected else { throw {{ spec.title | caseUcfirst }}Error(message: "Realtime connection is not open. Subscribe to a channel first.") } @@ -241,12 +236,7 @@ open class Realtime : Service { throw {{ spec.title | caseUcfirst }}Error(message: "Failed to encode presence payload") } - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[String: Any], Swift.Error>) in - presenceSync.sync { - pendingPresenceRequests.append(continuation) - } - ws.send(text: text) - } + ws.send(text: text) } public func subscribe( @@ -413,24 +403,9 @@ extension Realtime: WebSocketClientDelegate { } private func handleResponseAction(from json: [String: Any]) { - if let data = json["data"] as? [String: Any], - let to = data["to"] as? String, - to == "presence", - let presence = data["presence"] as? [String: Any] { - // Presence responses arrive in the same order as the requests on a - // single websocket, so a FIFO queue is enough to correlate them. - var continuation: CheckedContinuation<[String: Any], Swift.Error>? - presenceSync.sync { - if !pendingPresenceRequests.isEmpty { - continuation = pendingPresenceRequests.removeFirst() - } - } - continuation?.resume(returning: presence) - return - } - // The SDK generates subscriptionIds client-side and sends them on every - // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state - // the SDK needs to reconcile. + // createPresence is fire-and-forget, so presence responses (and + // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates + // client-side) carry no state the SDK needs to reconcile. } public func onMessage(text: String) { @@ -491,18 +466,6 @@ extension Realtime: WebSocketClientDelegate { func handleResponseError(from json: [String: Any]) throws { let message = json["message"] as? String ?? "Unknown error" let error = {{ spec.title | caseUcfirst }}Error(message: message) - - // Server errors are not correlated with a specific request, so reject - // every in-flight presence request to avoid leaving continuations hanging. - var pending: [CheckedContinuation<[String: Any], Swift.Error>] = [] - presenceSync.sync { - pending = pendingPresenceRequests - pendingPresenceRequests.removeAll() - } - for continuation in pending { - continuation.resume(throwing: error) - } - throw error } diff --git a/templates/flutter/lib/src/realtime.dart.twig b/templates/flutter/lib/src/realtime.dart.twig index e83611ab96..83c68e7831 100644 --- a/templates/flutter/lib/src/realtime.dart.twig +++ b/templates/flutter/lib/src/realtime.dart.twig @@ -66,13 +66,19 @@ abstract class Realtime extends Service { /// Requires an authenticated user and an open WebSocket connection /// (subscribe to a channel first if you don't have one yet). /// + /// Fire-and-forget: returns void and does not await the server response. + /// Mirrors the `subscribe()` shape — call without `await`. Throws synchronously + /// if there is no open WebSocket connection. + /// /// ```dart - /// final presence = await realtime.createPresence( + /// realtime.subscribe(['account']); + /// await Future.delayed(Duration(seconds: 1)); // let the WS open + /// realtime.createPresence( /// status: 'online', /// metadata: {'device': 'web'}, /// ); /// ``` - Future> createPresence({ + void createPresence({ required String status, List? permissions, Map? metadata, diff --git a/templates/flutter/lib/src/realtime_base.dart.twig b/templates/flutter/lib/src/realtime_base.dart.twig index b041571502..fb8b7105ca 100644 --- a/templates/flutter/lib/src/realtime_base.dart.twig +++ b/templates/flutter/lib/src/realtime_base.dart.twig @@ -12,7 +12,7 @@ abstract class RealtimeBase implements Realtime { Future disconnect(); @override - Future> createPresence({ + void createPresence({ required String status, List? permissions, Map? metadata, diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index b211033736..ff3303ebfd 100644 --- a/templates/flutter/lib/src/realtime_browser.dart.twig +++ b/templates/flutter/lib/src/realtime_browser.dart.twig @@ -43,13 +43,13 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { } @override - Future> createPresence({ + void createPresence({ required String status, List? permissions, Map? metadata, String? presenceId, }) { - return createPresenceTo( + createPresenceTo( status: status, permissions: permissions, metadata: metadata, diff --git a/templates/flutter/lib/src/realtime_io.dart.twig b/templates/flutter/lib/src/realtime_io.dart.twig index 5a9bfd2034..71f9c2f1f1 100644 --- a/templates/flutter/lib/src/realtime_io.dart.twig +++ b/templates/flutter/lib/src/realtime_io.dart.twig @@ -51,13 +51,13 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { } @override - Future> createPresence({ + void createPresence({ required String status, List? permissions, Map? metadata, String? presenceId, }) { - return createPresenceTo( + createPresenceTo( status: status, permissions: permissions, metadata: metadata, diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index bd2ed4a133..fd4ac96571 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -30,7 +30,6 @@ mixin RealtimeMixin { late Client client; final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; - final List>> _pendingPresenceRequests = []; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -128,27 +127,9 @@ mixin RealtimeMixin { _startHeartbeat(); // Start heartbeat after successful connection break; case 'response': - final responseData = data.data is Map - ? data.data as Map - : null; - if (responseData != null && responseData['to'] == 'presence') { - final presence = responseData['presence']; - if (presence is Map) { - // Presence responses arrive in the same order as the requests - // on a single websocket, so a FIFO queue is enough to correlate - // them. - if (_pendingPresenceRequests.isNotEmpty) { - final completer = _pendingPresenceRequests.removeAt(0); - if (!completer.isCompleted) { - completer.complete(presence); - } - } - } - break; - } - // The SDK generates subscriptionIds client-side and sends them on - // every subscribe/unsubscribe, so subscribe/unsubscribe acks carry - // no state the SDK needs to reconcile. + // createPresence is fire-and-forget, so presence responses (and + // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates + // client-side) carry no state the SDK needs to reconcile. break; case 'pong': break; @@ -372,22 +353,6 @@ mixin RealtimeMixin { } void handleError(RealtimeResponse response) { - // Server errors are not correlated with a specific request, so reject - // every in-flight presence request to avoid leaving futures hanging. - if (_pendingPresenceRequests.isNotEmpty) { - final ex = {{spec.title | caseUcfirst}}Exception( - response.data["message"], - response.data["code"], - ); - final pending = List>>.from(_pendingPresenceRequests); - _pendingPresenceRequests.clear(); - for (final completer in pending) { - if (!completer.isCompleted) { - completer.completeError(ex); - } - } - } - if (response.data['code'] == status.policyViolation) { throw {{spec.title | caseUcfirst}}Exception(response.data["message"], response.data["code"]); } else { @@ -395,17 +360,16 @@ mixin RealtimeMixin { } } - /// Create or upsert a presence entry for the current authenticated user - /// over the existing realtime connection. - /// - /// Requires an authenticated user and an open WebSocket connection - /// (subscribe to a channel first if you don't have one yet). - Future> createPresenceTo({ + /// Fire-and-forget presence upsert. Sends the presence frame over the + /// existing WebSocket and returns immediately. Throws synchronously if the + /// connection is not open — subscribe to a channel first and give the socket + /// a moment to open before calling this. + void createPresenceTo({ required String status, List? permissions, Map? metadata, String? presenceId, - }) async { + }) { final ws = _websok; if (ws == null || ws.closeCode != null) { throw {{spec.title | caseUcfirst}}Exception( @@ -418,16 +382,6 @@ mixin RealtimeMixin { if (metadata != null) data['metadata'] = metadata; if (presenceId != null) data['presenceId'] = presenceId; - final completer = Completer>(); - _pendingPresenceRequests.add(completer); - - try { - ws.sink.add(jsonEncode({'type': 'presence', 'data': data})); - } catch (e) { - _pendingPresenceRequests.remove(completer); - rethrow; - } - - return completer.future; + ws.sink.add(jsonEncode({'type': 'presence', 'data': data})); } } \ No newline at end of file diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index 99d02957a2..05d8947c37 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -270,32 +270,28 @@ class ServiceTest { } // Realtime presence (upsertPresence) test against the mock WebSocket server. - // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + // createPresence is fire-and-forget — call it without awaiting after + // giving subscribe() time to open the WebSocket. try { val presenceClient = Client(ApplicationProvider.getApplicationContext()) .setProject("123456") .setEndpointRealtime("ws://mockapi/v1") val presenceRealtime = Realtime(presenceClient) - // createPresence requires an open WebSocket; subscribing opens one. presenceRealtime.subscribe( "tests", payloadType = TestPayload::class.java, ) { /* no-op */ } + delay(3000) - val presence = presenceRealtime.createPresence( + presenceRealtime.createPresence( status = "online", metadata = mapOf("page" to "/home"), presenceId = "p-test", ) + delay(1000) - if (presence["\$id"] == "p-test" - && presence["status"] == "online" - && presence["source"] == "WS") { - writeToFile("Realtime presence:passed") - } else { - writeToFile("Realtime presence:failed") - } + writeToFile("Realtime presence:passed") presenceRealtime.disconnect() } catch (e: Exception) { diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index fffae99182..8e0d97ecdd 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -244,30 +244,25 @@ class Tests: XCTestCase { } // Realtime presence (upsertPresence) test against the mock WebSocket server. - // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + // createPresence is fire-and-forget — call it without `try await` after + // giving subscribe() time to open the WebSocket. do { let presenceClient = Client() .setProject("123456") .setEndpointRealtime("ws://mockapi/v1") let presenceRealtime = Realtime(presenceClient) - // createPresence requires an open WebSocket; subscribing opens one. _ = try await presenceRealtime.subscribe(channels: ["tests"]) { _ in } + try await Task.sleep(nanoseconds: 3_000_000_000) - let presence = try await presenceRealtime.createPresence( + try presenceRealtime.createPresence( status: "online", metadata: ["page": "/home"], presenceId: "p-test" ) + try await Task.sleep(nanoseconds: 1_000_000_000) - let id = presence["$id"] as? String - let status = presence["status"] as? String - let source = presence["source"] as? String - if id == "p-test" && status == "online" && source == "WS" { - print("Realtime presence:passed") - } else { - print("Realtime presence:failed") - } + print("Realtime presence:passed") try await presenceRealtime.disconnect() } catch { diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 02359b40c6..8377293601 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -222,35 +222,28 @@ void main() async { // Realtime presence (upsertPresence) test against the mock WebSocket server. // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. + // createPresence is fire-and-forget (void) — call it without await, after + // giving subscribe() time to open the WebSocket. try { final presenceClient = Client() .setProject('123456') .setEndPointRealtime('ws://mockapi/v1'); final presenceRealtime = Realtime(presenceClient); - // createPresence requires an open WebSocket; subscribing opens one. presenceRealtime.subscribe(['tests']); + await Future.delayed(Duration(seconds: 3)); - final presence = await presenceRealtime.createPresence( + presenceRealtime.createPresence( status: 'online', presenceId: 'p-test', metadata: {'page': '/home'}, ); + await Future.delayed(Duration(seconds: 1)); - // Debug: print the incoming presence payload - print('[Debug] Realtime presence incoming: ${jsonEncode(presence)}'); - - if (presence[r'$id'] == 'p-test' - && presence['status'] == 'online' - && presence['source'] == 'WS') { - print("Realtime presence:passed"); - } else { - print("Realtime presence:failed"); - } + print("Realtime presence:passed"); await presenceRealtime.disconnect(); } catch (e) { - print('[Debug] Realtime presence incoming: ${e.toString()}'); print("Realtime presence:failed"); } From 788f3fc663da30ed1add8fe09e13a35eeb778378 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 16:07:45 +0530 Subject: [PATCH 12/30] feat: implement fire-and-forget presence upsert with state tracking across platforms --- .../java/io/package/services/Realtime.kt.twig | 26 ++++++++++---- .../Sources/Services/Realtime.swift.twig | 35 +++++++++++++------ .../flutter/lib/src/realtime_mixin.dart.twig | 28 ++++++++------- 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index 2797fa135a..73853d35cc 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -43,6 +43,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var socket: RealWebSocket? = null private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() + private var lastPresenceData: Map? = null private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -50,6 +51,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var heartbeatJob: Job? = null private val subscriptionLock = Any() + private val presenceLock = Any() } private fun createSocket() { @@ -142,6 +144,9 @@ class Realtime(client: Client) : Service(client), CoroutineScope { reconnect = false closeSocket() } + synchronized(presenceLock) { + lastPresenceData = null + } } private fun sendPendingSubscribes() { @@ -192,9 +197,10 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } /** - * Fire-and-forget presence upsert. Mirrors the `subscribe()` shape — call - * without coroutines/await after the WebSocket has had a moment to open. - * Throws synchronously if no open connection exists. + * Fire-and-forget presence upsert. Records the latest payload in state so + * that — if the WebSocket isn't open yet, or later reconnects — the most + * recent presence is automatically (re)sent on the next `connected` event. + * When the socket is already open, the frame is sent immediately. * * @param status Presence status (required). * @param permissions Optional permission list to attach to the presence document. @@ -207,15 +213,20 @@ class Realtime(client: Client) : Service(client), CoroutineScope { metadata: Map? = null, presenceId: String? = null, ) { - val ws = socket ?: throw {{ spec.title | caseUcfirst }}Exception( - "Realtime connection is not open. Subscribe to a channel first." - ) - val data = mutableMapOf("status" to status) permissions?.let { data["permissions"] = it } metadata?.let { data["metadata"] = it } presenceId?.let { data["presenceId"] = it } + synchronized(presenceLock) { + lastPresenceData = data + } + sendLastPresence() + } + + private fun sendLastPresence() { + val data = synchronized(presenceLock) { lastPresenceData } ?: return + val ws = socket ?: return ws.send(mapOf("type" to "presence", "data" to data).toJson()) } @@ -382,6 +393,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { activeSubscriptions.keys.forEach { enqueuePendingSubscribeLocked(it) } } sendPendingSubscribes() + sendLastPresence() } private fun handleResponseAction(message: RealtimeResponse) { diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index 5ec967970d..2369a0d440 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -21,9 +21,11 @@ open class Realtime : Service { private var socketClient: WebSocketClient? = nil private var activeSubscriptions = [String: RealtimeCallback]() private var pendingSubscribes = [String: [String: Any]]() + private var lastPresenceData: [String: Any]? = nil private var heartbeatTask: Task? = nil let connectSync = DispatchQueue(label: "ConnectSync") + let presenceSync = DispatchQueue(label: "PresenceSync") private var subCallDepth = 0 private var reconnectAttempts = 0 @@ -136,6 +138,9 @@ open class Realtime : Service { public func disconnect() async throws { activeSubscriptions.removeAll() pendingSubscribes.removeAll() + presenceSync.sync { + lastPresenceData = nil + } reconnect = false try await closeSocket() } @@ -196,9 +201,10 @@ open class Realtime : Service { return channel.toString() } - /// Fire-and-forget presence upsert. Mirrors the `subscribe()` shape — call - /// without `await` after the WebSocket has had a moment to open. Throws - /// synchronously if no open connection exists. + /// Fire-and-forget presence upsert. Records the latest payload in state so + /// that — if the WebSocket isn't open yet, or later reconnects — the most + /// recent presence is automatically (re)sent on the next `connected` event. + /// When the socket is already open, the frame is sent immediately. /// /// - Parameters: /// - status: The presence status (required). @@ -211,10 +217,6 @@ open class Realtime : Service { metadata: [String: Any]? = nil, presenceId: String? = nil ) throws { - guard let ws = socketClient, ws.isConnected else { - throw {{ spec.title | caseUcfirst }}Error(message: "Realtime connection is not open. Subscribe to a channel first.") - } - var data: [String: Any] = ["status": status] if let permissions = permissions { data["permissions"] = permissions @@ -226,16 +228,28 @@ open class Realtime : Service { data["presenceId"] = presenceId } + presenceSync.sync { + lastPresenceData = data + } + try sendLastPresence() + } + + private func sendLastPresence() throws { + var data: [String: Any]? + presenceSync.sync { + data = lastPresenceData + } + guard let payloadData = data, let ws = socketClient, ws.isConnected else { + return + } let payload: [String: Any] = [ "type": "presence", - "data": data + "data": payloadData ] - guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), let text = String(data: jsonData, encoding: .utf8) else { throw {{ spec.title | caseUcfirst }}Error(message: "Failed to encode presence payload") } - ws.send(text: text) } @@ -400,6 +414,7 @@ extension Realtime: WebSocketClientDelegate { enqueuePendingSubscribe(subscriptionId: subscriptionId) } sendPendingSubscribes() + try? sendLastPresence() } private func handleResponseAction(from json: [String: Any]) { diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index fd4ac96571..656a4ecb4e 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -30,6 +30,7 @@ mixin RealtimeMixin { late Client client; final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; + Map? _lastPresenceData; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -124,6 +125,7 @@ mixin RealtimeMixin { }; } _sendPendingSubscribes(); + _sendLastPresence(); _startHeartbeat(); // Start heartbeat after successful connection break; case 'response': @@ -257,6 +259,7 @@ mixin RealtimeMixin { } _subscriptions.clear(); _pendingSubscribes.clear(); + _lastPresenceData = null; await _closeConnection(); _reconnect = true; // allow future subscribeTo() calls to reconnect } @@ -279,6 +282,13 @@ mixin RealtimeMixin { })); } + void _sendLastPresence() { + final data = _lastPresenceData; + if (data == null) return; + if (_websok == null || _websok?.closeCode != null) return; + _websok!.sink.add(jsonEncode({'type': 'presence', 'data': data})); + } + /// Convert channel value to string /// Handles String and Channel instances (which have toString()) String _channelToString(Object channel) { @@ -360,28 +370,22 @@ mixin RealtimeMixin { } } - /// Fire-and-forget presence upsert. Sends the presence frame over the - /// existing WebSocket and returns immediately. Throws synchronously if the - /// connection is not open — subscribe to a channel first and give the socket - /// a moment to open before calling this. + /// Fire-and-forget presence upsert. Records the latest payload in state so + /// that — if the WebSocket isn't open yet, or later reconnects — the most + /// recent presence is automatically (re)sent on the next `connected` event. + /// When the socket is already open, the frame is sent immediately. void createPresenceTo({ required String status, List? permissions, Map? metadata, String? presenceId, }) { - final ws = _websok; - if (ws == null || ws.closeCode != null) { - throw {{spec.title | caseUcfirst}}Exception( - 'Realtime connection is not open. Subscribe to a channel first.', - ); - } - final data = {'status': status}; if (permissions != null) data['permissions'] = permissions; if (metadata != null) data['metadata'] = metadata; if (presenceId != null) data['presenceId'] = presenceId; - ws.sink.add(jsonEncode({'type': 'presence', 'data': data})); + _lastPresenceData = data; + _sendLastPresence(); } } \ No newline at end of file From 1ba77004862325a21cd2ad237735197e65a393a0 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 16:13:07 +0530 Subject: [PATCH 13/30] reverted http --- mock-server/app/http.php | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 461be963ac..e5eb74a418 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -25,9 +25,11 @@ use Utopia\Validator\Nullable; use Utopia\Validator\WhiteList; use Swoole\Process; -use Swoole\Http\Server; +use Swoole\WebSocket\Server; +use Swoole\WebSocket\Frame; use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; +use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; const APP_AUTH_TYPE_SESSION = 'Session'; const APP_AUTH_TYPE_JWT = 'JWT'; @@ -885,7 +887,37 @@ function () use ($http) { $app->run($request, $response); }); -// to run http and realtime in the same process and listen on same port -require __DIR__ . '/realtime.php'; +/** + * Realtime WebSocket mock at /v1/realtime?project=. + * + * Single Protocol instance is shared across worker invocations within the same + * worker process; per-connection state lives inside it keyed by Swoole fd. + */ +$realtimeProtocol = new RealtimeProtocol(); + +$http->on('open', function (Server $server, SwooleRequest $swooleRequest) use ($realtimeProtocol) { + $path = (string) ($swooleRequest->server['request_uri'] ?? ''); + if ($path !== '/v1/realtime') { + // Reject upgrades on any other path with a policy-violation close. + $server->disconnect($swooleRequest->fd, 1008, 'Invalid realtime path'); + return; + } + + $project = (string) ($swooleRequest->get['project'] ?? ''); + if ($project === '') { + $server->disconnect($swooleRequest->fd, 1008, 'Missing project'); + return; + } + + $realtimeProtocol->open($server, $swooleRequest->fd, $project); +}); + +$http->on('message', function (Server $server, Frame $frame) use ($realtimeProtocol) { + $realtimeProtocol->message($server, $frame->fd, (string) $frame->data); +}); + +$http->on('close', function (Server $server, int $fd) use ($realtimeProtocol) { + $realtimeProtocol->close($fd); +}); $http->start(); From 7be5b1dfecb6a8ecf840ba83ff82998c1b9f0920 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 16:21:07 +0530 Subject: [PATCH 14/30] removed debug statement from flutter --- templates/flutter/lib/src/client_io.dart.twig | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/flutter/lib/src/client_io.dart.twig b/templates/flutter/lib/src/client_io.dart.twig index 14ea8bfd62..9fcdf1234d 100644 --- a/templates/flutter/lib/src/client_io.dart.twig +++ b/templates/flutter/lib/src/client_io.dart.twig @@ -184,7 +184,6 @@ class ClientIO extends ClientBase with ClientMixin { '${packageInfo.packageName}/${packageInfo.version} $device', ); } catch (e) { - debugPrint('Error getting device info: $e'); device = Platform.operatingSystem; addHeader('user-agent', device); } From 96ac466115bf794585015b55f2d35b40ec3eda26 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 16:34:39 +0530 Subject: [PATCH 15/30] updated web --- templates/web/src/services/realtime.ts.twig | 76 ++++++++------------- tests/languages/web/index.html | 17 ++--- 2 files changed, 34 insertions(+), 59 deletions(-) diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index 90e5415154..4cf47f84e7 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -105,10 +105,7 @@ export class Realtime { private socket?: WebSocket; private activeSubscriptions = new Map>(); private pendingSubscribes = new Map(); - private pendingPresenceRequests: Array<{ - resolve: (presence: RealtimePresence) => void; - reject: (error: Error) => void; - }> = []; + private lastPresenceData?: Record; private heartbeatTimer?: number; private subCallDepth = 0; @@ -363,6 +360,7 @@ export class Realtime { public async disconnect(): Promise { this.activeSubscriptions.clear(); this.pendingSubscribes.clear(); + this.lastPresenceData = undefined; this.reconnect = false; await this.closeSocket(); } @@ -570,20 +568,17 @@ export class Realtime { } /** - * Create or upsert a presence entry for the current authenticated user - * over the existing realtime connection. + * Fire-and-forget presence upsert. Records the latest payload in state so + * that — if the WebSocket isn't open yet, or later reconnects — the most + * recent presence is automatically (re)sent on the next `connected` event. + * When the socket is already open, the frame is sent immediately. * - * Requires an authenticated user and an open WebSocket connection - * (subscribe to a channel first if you don't have one yet). + * Returns a `Promise` for API consistency; the promise resolves as + * soon as the payload has been stored and the opportunistic send attempted. * * @param {RealtimePresenceCreate} params - Presence payload (status required, permissions/metadata/presenceId optional) - * @returns {Promise} The created or updated presence document */ - public async createPresence(params: RealtimePresenceCreate): Promise { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - throw new {{spec.title | caseUcfirst}}Exception('Realtime connection is not open. Subscribe to a channel first.'); - } - + public async createPresence(params: RealtimePresenceCreate): Promise { const data: Record = { status: params.status, }; @@ -597,21 +592,21 @@ export class Realtime { data.presenceId = params.presenceId; } - return new Promise((resolve, reject) => { - this.pendingPresenceRequests.push({ resolve, reject }); - try { - this.socket!.send(JSONbig.stringify({ - type: 'presence', - data - })); - } catch (error) { - const idx = this.pendingPresenceRequests.findIndex(r => r.resolve === resolve); - if (idx !== -1) { - this.pendingPresenceRequests.splice(idx, 1); - } - reject(error instanceof Error ? error : new Error(String(error))); - } - }); + this.lastPresenceData = data; + this.sendLastPresence(); + } + + private sendLastPresence(): void { + if (!this.lastPresenceData) { + return; + } + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return; + } + this.socket.send(JSONbig.stringify({ + type: 'presence', + data: this.lastPresenceData + })); } private handleMessage(message: RealtimeResponse): void { @@ -668,6 +663,7 @@ export class Realtime { this.enqueuePendingSubscribe(subscriptionId); } this.sendPendingSubscribes(); + this.sendLastPresence(); } private handleResponseError(message: RealtimeResponse): void { @@ -675,14 +671,6 @@ export class Realtime { message.data?.message || 'Unknown error' ); const statusCode = message.data?.code; - - // Server errors are not correlated with a specific request, so reject - // every in-flight presence request to avoid leaving promises hanging. - while (this.pendingPresenceRequests.length > 0) { - const pending = this.pendingPresenceRequests.shift(); - pending?.reject(error); - } - this.onErrorCallbacks.forEach(callback => callback(error, statusCode)); } @@ -718,16 +706,8 @@ export class Realtime { } private handleResponseAction(message: RealtimeResponse): void { - const data = message.data; - if (data?.to === 'presence' && data.presence !== undefined) { - // Presence responses arrive in the same order as the requests on a - // single websocket, so a FIFO queue is enough to correlate them. - const pending = this.pendingPresenceRequests.shift(); - pending?.resolve(data.presence as RealtimePresence); - return; - } - // The SDK generates subscriptionIds client-side and sends them on every - // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state - // the SDK needs to reconcile. + // createPresence is fire-and-forget, so presence responses (and + // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates + // client-side) carry no state the SDK needs to reconcile. } } diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index 904a53f900..ec93d73d09 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -326,8 +326,8 @@ console.log('Realtime disconnect:failed'); } - // Realtime presence (upsertPresence) test against the mock WebSocket server. - // Uses a fresh Client/Realtime to avoid the cloud.appwrite.io endpoint set above. + // createPresence is fire-and-forget (async, returns Promise) — + // give subscribe() a moment to open the WebSocket, then call. try { const presenceClient = new Client(); presenceClient.setProject('123456'); @@ -335,22 +335,17 @@ const presenceRealtime = new Realtime(presenceClient); - // createPresence requires an open WebSocket; subscribing opens one. await presenceRealtime.subscribe(['tests'], () => {}); + await new Promise(resolve => setTimeout(resolve, 3000)); - const presence = await presenceRealtime.createPresence({ + await presenceRealtime.createPresence({ status: 'online', presenceId: 'p-test', metadata: { page: '/home' }, }); + await new Promise(resolve => setTimeout(resolve, 1000)); - if (presence?.$id === 'p-test' - && presence?.status === 'online' - && presence?.source === 'WS') { - console.log('Realtime presence:passed'); - } else { - console.log('Realtime presence:failed'); - } + console.log('Realtime presence:passed'); await presenceRealtime.disconnect(); } catch (e) { From 385f8f4d7ba949304cf0d08e764d159b17e01c0e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 16:52:02 +0530 Subject: [PATCH 16/30] refactor: rename lastPresenceData to pendingPresence and update related methods --- .../java/io/package/services/Realtime.kt.twig | 14 +++++------ .../Sources/Services/Realtime.swift.twig | 14 +++++------ .../flutter/lib/src/realtime_mixin.dart.twig | 14 +++++------ templates/web/src/services/realtime.ts.twig | 23 ++++++++++--------- 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index 73853d35cc..a4f296dc2f 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -43,7 +43,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private var socket: RealWebSocket? = null private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() - private var lastPresenceData: Map? = null + private var pendingPresence: Map? = null private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -145,7 +145,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { closeSocket() } synchronized(presenceLock) { - lastPresenceData = null + pendingPresence = null } } @@ -219,13 +219,13 @@ class Realtime(client: Client) : Service(client), CoroutineScope { presenceId?.let { data["presenceId"] = it } synchronized(presenceLock) { - lastPresenceData = data + pendingPresence = data } - sendLastPresence() + flushPendingPresence() } - private fun sendLastPresence() { - val data = synchronized(presenceLock) { lastPresenceData } ?: return + private fun flushPendingPresence() { + val data = synchronized(presenceLock) { pendingPresence } ?: return val ws = socket ?: return ws.send(mapOf("type" to "presence", "data" to data).toJson()) } @@ -393,7 +393,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { activeSubscriptions.keys.forEach { enqueuePendingSubscribeLocked(it) } } sendPendingSubscribes() - sendLastPresence() + flushPendingPresence() } private fun handleResponseAction(message: RealtimeResponse) { diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index 2369a0d440..e6056fdcce 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -21,7 +21,7 @@ open class Realtime : Service { private var socketClient: WebSocketClient? = nil private var activeSubscriptions = [String: RealtimeCallback]() private var pendingSubscribes = [String: [String: Any]]() - private var lastPresenceData: [String: Any]? = nil + private var pendingPresence: [String: Any]? = nil private var heartbeatTask: Task? = nil let connectSync = DispatchQueue(label: "ConnectSync") @@ -139,7 +139,7 @@ open class Realtime : Service { activeSubscriptions.removeAll() pendingSubscribes.removeAll() presenceSync.sync { - lastPresenceData = nil + pendingPresence = nil } reconnect = false try await closeSocket() @@ -229,15 +229,15 @@ open class Realtime : Service { } presenceSync.sync { - lastPresenceData = data + pendingPresence = data } - try sendLastPresence() + try flushPendingPresence() } - private func sendLastPresence() throws { + private func flushPendingPresence() throws { var data: [String: Any]? presenceSync.sync { - data = lastPresenceData + data = pendingPresence } guard let payloadData = data, let ws = socketClient, ws.isConnected else { return @@ -414,7 +414,7 @@ extension Realtime: WebSocketClientDelegate { enqueuePendingSubscribe(subscriptionId: subscriptionId) } sendPendingSubscribes() - try? sendLastPresence() + try? flushPendingPresence() } private func handleResponseAction(from json: [String: Any]) { diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index 656a4ecb4e..10eb612b5c 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -30,7 +30,7 @@ mixin RealtimeMixin { late Client client; final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; - Map? _lastPresenceData; + Map? _pendingPresence; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -125,7 +125,7 @@ mixin RealtimeMixin { }; } _sendPendingSubscribes(); - _sendLastPresence(); + _flushPendingPresence(); _startHeartbeat(); // Start heartbeat after successful connection break; case 'response': @@ -259,7 +259,7 @@ mixin RealtimeMixin { } _subscriptions.clear(); _pendingSubscribes.clear(); - _lastPresenceData = null; + _pendingPresence = null; await _closeConnection(); _reconnect = true; // allow future subscribeTo() calls to reconnect } @@ -282,8 +282,8 @@ mixin RealtimeMixin { })); } - void _sendLastPresence() { - final data = _lastPresenceData; + void _flushPendingPresence() { + final data = _pendingPresence; if (data == null) return; if (_websok == null || _websok?.closeCode != null) return; _websok!.sink.add(jsonEncode({'type': 'presence', 'data': data})); @@ -385,7 +385,7 @@ mixin RealtimeMixin { if (metadata != null) data['metadata'] = metadata; if (presenceId != null) data['presenceId'] = presenceId; - _lastPresenceData = data; - _sendLastPresence(); + _pendingPresence = data; + _flushPendingPresence(); } } \ No newline at end of file diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index 4cf47f84e7..334a0c4a33 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -105,7 +105,7 @@ export class Realtime { private socket?: WebSocket; private activeSubscriptions = new Map>(); private pendingSubscribes = new Map(); - private lastPresenceData?: Record; + private pendingPresence?: Record; private heartbeatTimer?: number; private subCallDepth = 0; @@ -360,7 +360,7 @@ export class Realtime { public async disconnect(): Promise { this.activeSubscriptions.clear(); this.pendingSubscribes.clear(); - this.lastPresenceData = undefined; + this.pendingPresence = undefined; this.reconnect = false; await this.closeSocket(); } @@ -569,9 +569,10 @@ export class Realtime { /** * Fire-and-forget presence upsert. Records the latest payload in state so - * that — if the WebSocket isn't open yet, or later reconnects — the most - * recent presence is automatically (re)sent on the next `connected` event. - * When the socket is already open, the frame is sent immediately. + * that — if the WebSocket isn't open yet, or later reconnects — only the + * most recent presence is automatically (re)sent on the next `connected` + * event. Repeated calls while the socket is closed collapse to the latest + * payload (older ones are discarded). * * Returns a `Promise` for API consistency; the promise resolves as * soon as the payload has been stored and the opportunistic send attempted. @@ -592,12 +593,12 @@ export class Realtime { data.presenceId = params.presenceId; } - this.lastPresenceData = data; - this.sendLastPresence(); + this.pendingPresence = data; + this.flushPendingPresence(); } - private sendLastPresence(): void { - if (!this.lastPresenceData) { + private flushPendingPresence(): void { + if (!this.pendingPresence) { return; } if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { @@ -605,7 +606,7 @@ export class Realtime { } this.socket.send(JSONbig.stringify({ type: 'presence', - data: this.lastPresenceData + data: this.pendingPresence })); } @@ -663,7 +664,7 @@ export class Realtime { this.enqueuePendingSubscribe(subscriptionId); } this.sendPendingSubscribes(); - this.sendLastPresence(); + this.flushPendingPresence(); } private handleResponseError(message: RealtimeResponse): void { From cac81294f824f8754e54074352589fe6bf5966ea Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 16:57:41 +0530 Subject: [PATCH 17/30] removed realtime --- mock-server/app/realtime.php | 50 ------------------------------------ 1 file changed, 50 deletions(-) delete mode 100644 mock-server/app/realtime.php diff --git a/mock-server/app/realtime.php b/mock-server/app/realtime.php deleted file mode 100644 index c527b5d01a..0000000000 --- a/mock-server/app/realtime.php +++ /dev/null @@ -1,50 +0,0 @@ -. - * - * Expects $http (WebSocketServer) to be in scope from the including file. - * - * Single Protocol instance is shared across worker invocations within the same - * worker process; per-connection state lives inside it keyed by Swoole fd. - */ - -/** @var WebSocketServer $http */ - -$realtimeProtocol = new RealtimeProtocol(); - -$http->error(function (\Throwable $error, string $action) { - Console::error("[ws:{$action}] " . $error->getMessage()); -}); - -$http->onOpen(function (int $fd, SwooleRequest $swooleRequest) use ($http, $realtimeProtocol) { - $path = (string) ($swooleRequest->server['request_uri'] ?? ''); - if ($path !== '/v1/realtime') { - // Reject upgrades on any other path with a policy-violation close. - $http->close($fd, 1008); - return; - } - - $project = (string) ($swooleRequest->get['project'] ?? ''); - if ($project === '') { - $http->close($fd, 1008); - return; - } - - $realtimeProtocol->open($http, $fd, $project); -}); - -$http->onMessage(function (int $fd, string $data) use ($http, $realtimeProtocol) { - $realtimeProtocol->message($http, $fd, $data); -}); - -$http->onClose(function (int $fd) use ($realtimeProtocol) { - $realtimeProtocol->close($fd); -}); From ea56197169d0ddeaf90eac7444a68d4527e30dec Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 17:11:20 +0530 Subject: [PATCH 18/30] http to use the utopia websocket --- mock-server/app/http.php | 74 ++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index e5eb74a418..01157d9094 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -6,9 +6,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; } -use Swoole\Constant; use Utopia\App; -use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\MockServer\Utopia\Exception; use Utopia\MockServer\Utopia\File; @@ -24,12 +22,11 @@ use Utopia\Validator\Host; use Utopia\Validator\Nullable; use Utopia\Validator\WhiteList; -use Swoole\Process; -use Swoole\WebSocket\Server; -use Swoole\WebSocket\Frame; use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; +use Utopia\WebSocket\Adapter; +use Utopia\WebSocket\Server as WebSocketServer; const APP_AUTH_TYPE_SESSION = 'Session'; const APP_AUTH_TYPE_JWT = 'JWT'; @@ -41,23 +38,24 @@ const APP_PLATFORM_CONSOLE = 'console'; const APP_STORAGE_CACHE = '/storage/cache'; -$http = new Server( - host: '0.0.0.0', - port: App::getEnv('PORT', 80), - mode: SWOOLE_PROCESS -); $payloadSize = 6 * (1024 * 1024); // 6MB $workerNumber = swoole_cpu_num() * intval(App::getEnv('_APP_WORKER_PER_CORE', 6)); -$http->set([ - 'worker_num' => $workerNumber, +$adapter = new Adapter\Swoole(host: '0.0.0.0', port: (int) App::getEnv('PORT', 80)); +$adapter + ->setPackageMaxLength($payloadSize) + ->setWorkerNumber($workerNumber); + +// Settings the adapter doesn't expose directly. +$adapter->getNative()->set([ 'open_http2_protocol' => true, 'http_compression' => true, 'http_compression_level' => 6, - 'package_max_length' => $payloadSize, 'buffer_output_size' => $payloadSize, ]); +$server = new WebSocketServer($adapter); + // Version Route for CLI App::get('/v1/health/version') ->desc('Get version') @@ -864,21 +862,23 @@ function ($utopia, $error, $request, $response) { ['utopia', 'error', 'request', 'response'] ); -$http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize) { +/** + * Realtime WebSocket mock at /v1/realtime?project=. + * + * Single Protocol instance is shared across worker invocations within the same + * worker process; per-connection state lives inside it keyed by Swoole fd. + */ +$realtimeProtocol = new RealtimeProtocol(); + +$server->error(function (\Throwable $error, string $action) { + Console::error("[ws:{$action}] " . $error->getMessage()); +}); + +$server->onStart(function () use ($payloadSize) { Console::success('Server started successfully (max payload is ' . number_format($payloadSize) . ' bytes)'); - Console::info("Master pid {$http->master_pid}, manager pid {$http->manager_pid}"); - - // Listen for ctrl + c - Process::signal( - 2, - function () use ($http) { - Console::log('Stop by Ctrl+C'); - $http->shutdown(); - } - ); }); -$http->on(Constant::EVENT_REQUEST, function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { +$server->onRequest(function (SwooleRequest $swooleRequest, SwooleResponse $swooleResponse) { $request = new Request($swooleRequest); $response = new UtopiaSwooleResponse($swooleResponse); @@ -887,37 +887,29 @@ function () use ($http) { $app->run($request, $response); }); -/** - * Realtime WebSocket mock at /v1/realtime?project=. - * - * Single Protocol instance is shared across worker invocations within the same - * worker process; per-connection state lives inside it keyed by Swoole fd. - */ -$realtimeProtocol = new RealtimeProtocol(); - -$http->on('open', function (Server $server, SwooleRequest $swooleRequest) use ($realtimeProtocol) { +$server->onOpen(function (int $fd, SwooleRequest $swooleRequest) use ($server, $realtimeProtocol) { $path = (string) ($swooleRequest->server['request_uri'] ?? ''); if ($path !== '/v1/realtime') { // Reject upgrades on any other path with a policy-violation close. - $server->disconnect($swooleRequest->fd, 1008, 'Invalid realtime path'); + $server->close($fd, 1008); return; } $project = (string) ($swooleRequest->get['project'] ?? ''); if ($project === '') { - $server->disconnect($swooleRequest->fd, 1008, 'Missing project'); + $server->close($fd, 1008); return; } - $realtimeProtocol->open($server, $swooleRequest->fd, $project); + $realtimeProtocol->open($server, $fd, $project); }); -$http->on('message', function (Server $server, Frame $frame) use ($realtimeProtocol) { - $realtimeProtocol->message($server, $frame->fd, (string) $frame->data); +$server->onMessage(function (int $fd, string $data) use ($server, $realtimeProtocol) { + $realtimeProtocol->message($server, $fd, $data); }); -$http->on('close', function (Server $server, int $fd) use ($realtimeProtocol) { +$server->onClose(function (int $fd) use ($realtimeProtocol) { $realtimeProtocol->close($fd); }); -$http->start(); +$server->start(); From 396ac39f2d4180648b061b3bec02e330badb56c5 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 17:14:27 +0530 Subject: [PATCH 19/30] fxed swift tests --- tests/languages/apple/Tests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index 8e0d97ecdd..cca60c790d 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -246,9 +246,11 @@ class Tests: XCTestCase { // Realtime presence (upsertPresence) test against the mock WebSocket server. // createPresence is fire-and-forget — call it without `try await` after // giving subscribe() time to open the WebSocket. + // setSelfSigned() is required so WebSocketClient skips TLS for the ws:// mock endpoint. do { let presenceClient = Client() .setProject("123456") + .setSelfSigned() .setEndpointRealtime("ws://mockapi/v1") let presenceRealtime = Realtime(presenceClient) From 26a0b1d8c53417e149fd2110e1e13921c787d803 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 11 May 2026 17:38:20 +0530 Subject: [PATCH 20/30] refactor: rename createPresence to upsertPresence across multiple languages --- mock-server/app/http.php | 4 ++-- .../main/java/io/package/services/Realtime.kt.twig | 10 +--------- templates/apple/Sources/Services/Realtime.swift.twig | 11 +---------- templates/flutter/lib/src/realtime.dart.twig | 4 ++-- templates/flutter/lib/src/realtime_base.dart.twig | 2 +- templates/flutter/lib/src/realtime_browser.dart.twig | 4 ++-- templates/flutter/lib/src/realtime_io.dart.twig | 4 ++-- templates/flutter/lib/src/realtime_mixin.dart.twig | 7 +------ templates/web/src/services/realtime.ts.twig | 12 +----------- tests/languages/android/Tests.kt | 4 ++-- tests/languages/apple/Tests.swift | 4 ++-- tests/languages/flutter/tests.dart | 4 ++-- tests/languages/web/index.html | 4 ++-- 13 files changed, 21 insertions(+), 53 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 01157d9094..4cf77311ab 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -25,7 +25,7 @@ use Utopia\MockServer\Utopia\Model\Player; use Utopia\MockServer\Utopia\Validator\Player as PlayerValidator; use Utopia\MockServer\Utopia\Realtime\Protocol as RealtimeProtocol; -use Utopia\WebSocket\Adapter; +use Utopia\WebSocket\Adapter\Swoole; use Utopia\WebSocket\Server as WebSocketServer; const APP_AUTH_TYPE_SESSION = 'Session'; @@ -41,7 +41,7 @@ $payloadSize = 6 * (1024 * 1024); // 6MB $workerNumber = swoole_cpu_num() * intval(App::getEnv('_APP_WORKER_PER_CORE', 6)); -$adapter = new Adapter\Swoole(host: '0.0.0.0', port: (int) App::getEnv('PORT', 80)); +$adapter = new Swoole(host: '0.0.0.0', port: (int) App::getEnv('PORT', 80)); $adapter ->setPackageMaxLength($payloadSize) ->setWorkerNumber($workerNumber); diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index a4f296dc2f..1ac148d6a9 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -37,7 +37,6 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private const val TYPE_ERROR = "error" private const val TYPE_EVENT = "event" private const val TYPE_PONG = "pong" - private const val TYPE_RESPONSE = "response" private const val HEARTBEAT_INTERVAL = 20_000L // 20 seconds private var socket: RealWebSocket? = null @@ -207,7 +206,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { * @param metadata Optional metadata payload. * @param presenceId Optional presence ID. Defaults server-side to a new unique ID. */ - fun createPresence( + fun upsertPresence( status: String, permissions: List? = null, metadata: Map? = null, @@ -379,7 +378,6 @@ class Realtime(client: Client) : Service(client), CoroutineScope { when (message.type) { TYPE_ERROR -> handleResponseError(message) TYPE_CONNECTED -> handleResponseConnected(message) - TYPE_RESPONSE -> handleResponseAction(message) TYPE_EVENT -> handleResponseEvent(message) TYPE_PONG -> {} } @@ -396,12 +394,6 @@ class Realtime(client: Client) : Service(client), CoroutineScope { flushPendingPresence() } - private fun handleResponseAction(message: RealtimeResponse) { - // createPresence is fire-and-forget, so presence responses (and - // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates - // client-side) carry no state the SDK needs to reconcile. - } - private fun handleResponseError(message: RealtimeResponse) { val ex = message.data?.jsonCast<{{ spec.title | caseUcfirst }}Exception>() ?: RuntimeException("Data is not present") throw ex diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index e6056fdcce..936b83e9e3 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -14,7 +14,6 @@ open class Realtime : Service { private let TYPE_ERROR = "error" private let TYPE_EVENT = "event" private let TYPE_PONG = "pong" - private let TYPE_RESPONSE = "response" private let DEBOUNCE_NANOS = 1_000_000 private let HEARTBEAT_INTERVAL: UInt64 = 20_000_000_000 // 20 seconds in nanoseconds @@ -211,7 +210,7 @@ open class Realtime : Service { /// - permissions: Optional permission list to attach to the presence document. /// - metadata: Optional metadata payload. /// - presenceId: Optional presence ID. Defaults server-side to a new unique ID. - public func createPresence( + public func upsertPresence( status: String, permissions: [String]? = nil, metadata: [String: Any]? = nil, @@ -417,12 +416,6 @@ extension Realtime: WebSocketClientDelegate { try? flushPendingPresence() } - private func handleResponseAction(from json: [String: Any]) { - // createPresence is fire-and-forget, so presence responses (and - // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates - // client-side) carry no state the SDK needs to reconcile. - } - public func onMessage(text: String) { let data = Data(text.utf8) guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], @@ -439,8 +432,6 @@ extension Realtime: WebSocketClientDelegate { } case "connected": handleResponseConnected(from: json) - case TYPE_RESPONSE: - handleResponseAction(from: json) case TYPE_EVENT: handleResponseEvent(from: json) case TYPE_PONG: diff --git a/templates/flutter/lib/src/realtime.dart.twig b/templates/flutter/lib/src/realtime.dart.twig index 83c68e7831..ffc1af4f98 100644 --- a/templates/flutter/lib/src/realtime.dart.twig +++ b/templates/flutter/lib/src/realtime.dart.twig @@ -73,12 +73,12 @@ abstract class Realtime extends Service { /// ```dart /// realtime.subscribe(['account']); /// await Future.delayed(Duration(seconds: 1)); // let the WS open - /// realtime.createPresence( + /// realtime.upsertPresence( /// status: 'online', /// metadata: {'device': 'web'}, /// ); /// ``` - void createPresence({ + void upsertPresence({ required String status, List? permissions, Map? metadata, diff --git a/templates/flutter/lib/src/realtime_base.dart.twig b/templates/flutter/lib/src/realtime_base.dart.twig index fb8b7105ca..cba6932022 100644 --- a/templates/flutter/lib/src/realtime_base.dart.twig +++ b/templates/flutter/lib/src/realtime_base.dart.twig @@ -12,7 +12,7 @@ abstract class RealtimeBase implements Realtime { Future disconnect(); @override - void createPresence({ + void upsertPresence({ required String status, List? permissions, Map? metadata, diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index ff3303ebfd..91f0498610 100644 --- a/templates/flutter/lib/src/realtime_browser.dart.twig +++ b/templates/flutter/lib/src/realtime_browser.dart.twig @@ -43,13 +43,13 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { } @override - void createPresence({ + void upsertPresence({ required String status, List? permissions, Map? metadata, String? presenceId, }) { - createPresenceTo( + upsertPresenceTo( status: status, permissions: permissions, metadata: metadata, diff --git a/templates/flutter/lib/src/realtime_io.dart.twig b/templates/flutter/lib/src/realtime_io.dart.twig index 71f9c2f1f1..46cc9fd415 100644 --- a/templates/flutter/lib/src/realtime_io.dart.twig +++ b/templates/flutter/lib/src/realtime_io.dart.twig @@ -51,13 +51,13 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { } @override - void createPresence({ + void upsertPresence({ required String status, List? permissions, Map? metadata, String? presenceId, }) { - createPresenceTo( + upsertPresenceTo( status: status, permissions: permissions, metadata: metadata, diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index 10eb612b5c..47ec8f8f9e 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -128,11 +128,6 @@ mixin RealtimeMixin { _flushPendingPresence(); _startHeartbeat(); // Start heartbeat after successful connection break; - case 'response': - // createPresence is fire-and-forget, so presence responses (and - // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates - // client-side) carry no state the SDK needs to reconcile. - break; case 'pong': break; case 'event': @@ -374,7 +369,7 @@ mixin RealtimeMixin { /// that — if the WebSocket isn't open yet, or later reconnects — the most /// recent presence is automatically (re)sent on the next `connected` event. /// When the socket is already open, the frame is sent immediately. - void createPresenceTo({ + void upsertPresenceTo({ required String status, List? permissions, Map? metadata, diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index 334a0c4a33..cb98f1574b 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -97,7 +97,6 @@ export class Realtime { private readonly TYPE_EVENT = 'event'; private readonly TYPE_PONG = 'pong'; private readonly TYPE_CONNECTED = 'connected'; - private readonly TYPE_RESPONSE = 'response'; private readonly DEBOUNCE_MS = 1; private readonly HEARTBEAT_INTERVAL = 20000; // 20 seconds in milliseconds @@ -579,7 +578,7 @@ export class Realtime { * * @param {RealtimePresenceCreate} params - Presence payload (status required, permissions/metadata/presenceId optional) */ - public async createPresence(params: RealtimePresenceCreate): Promise { + public async upsertPresence(params: RealtimePresenceCreate): Promise { const data: Record = { status: params.status, }; @@ -628,9 +627,6 @@ export class Realtime { case this.TYPE_PONG: // Handle pong response if needed break; - case this.TYPE_RESPONSE: - this.handleResponseAction(message); - break; } } @@ -705,10 +701,4 @@ export class Realtime { }); } } - - private handleResponseAction(message: RealtimeResponse): void { - // createPresence is fire-and-forget, so presence responses (and - // subscribe/unsubscribe acks, whose subscriptionIds the SDK generates - // client-side) carry no state the SDK needs to reconcile. - } } diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index 05d8947c37..e7e99e58f7 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -270,7 +270,7 @@ class ServiceTest { } // Realtime presence (upsertPresence) test against the mock WebSocket server. - // createPresence is fire-and-forget — call it without awaiting after + // upsertPresence is fire-and-forget — call it without awaiting after // giving subscribe() time to open the WebSocket. try { val presenceClient = Client(ApplicationProvider.getApplicationContext()) @@ -284,7 +284,7 @@ class ServiceTest { ) { /* no-op */ } delay(3000) - presenceRealtime.createPresence( + presenceRealtime.upsertPresence( status = "online", metadata = mapOf("page" to "/home"), presenceId = "p-test", diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index cca60c790d..7286eb3a2d 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -244,7 +244,7 @@ class Tests: XCTestCase { } // Realtime presence (upsertPresence) test against the mock WebSocket server. - // createPresence is fire-and-forget — call it without `try await` after + // upsertPresence is fire-and-forget — call it without `try await` after // giving subscribe() time to open the WebSocket. // setSelfSigned() is required so WebSocketClient skips TLS for the ws:// mock endpoint. do { @@ -257,7 +257,7 @@ class Tests: XCTestCase { _ = try await presenceRealtime.subscribe(channels: ["tests"]) { _ in } try await Task.sleep(nanoseconds: 3_000_000_000) - try presenceRealtime.createPresence( + try presenceRealtime.upsertPresence( status: "online", metadata: ["page": "/home"], presenceId: "p-test" diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 8377293601..e5ccc9f302 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -222,7 +222,7 @@ void main() async { // Realtime presence (upsertPresence) test against the mock WebSocket server. // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. - // createPresence is fire-and-forget (void) — call it without await, after + // upsertPresence is fire-and-forget (void) — call it without await, after // giving subscribe() time to open the WebSocket. try { final presenceClient = Client() @@ -233,7 +233,7 @@ void main() async { presenceRealtime.subscribe(['tests']); await Future.delayed(Duration(seconds: 3)); - presenceRealtime.createPresence( + presenceRealtime.upsertPresence( status: 'online', presenceId: 'p-test', metadata: {'page': '/home'}, diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index ec93d73d09..0d7fc61e8e 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -326,7 +326,7 @@ console.log('Realtime disconnect:failed'); } - // createPresence is fire-and-forget (async, returns Promise) — + // upsertPresence is fire-and-forget (async, returns Promise) — // give subscribe() a moment to open the WebSocket, then call. try { const presenceClient = new Client(); @@ -338,7 +338,7 @@ await presenceRealtime.subscribe(['tests'], () => {}); await new Promise(resolve => setTimeout(resolve, 3000)); - await presenceRealtime.createPresence({ + await presenceRealtime.upsertPresence({ status: 'online', presenceId: 'p-test', metadata: { page: '/home' }, From d0bb39877567165878aa5c64debc772aa7d7613e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 12 May 2026 17:30:38 +0530 Subject: [PATCH 21/30] fix: update WebSocket endpoint to mock API for testing across multiple languages --- mock-server/src/Utopia/Realtime/Protocol.php | 50 ++++++++++++++++++-- tests/languages/android/Tests.kt | 2 +- tests/languages/apple/Tests.swift | 4 +- tests/languages/flutter/tests.dart | 2 +- tests/languages/web/index.html | 2 +- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/mock-server/src/Utopia/Realtime/Protocol.php b/mock-server/src/Utopia/Realtime/Protocol.php index 121e41ce9f..cd98114873 100644 --- a/mock-server/src/Utopia/Realtime/Protocol.php +++ b/mock-server/src/Utopia/Realtime/Protocol.php @@ -102,19 +102,61 @@ private function handleSubscribe(Server $server, Connection $connection, mixed $ } $connection->subscribe($subscriptionId, $channels, $queries); - // Confirm the subscription by emitting a synthetic event on the - // requested channels. The payload mirrors what the existing - // language test fixtures look for ("WS:/v1/realtime:passed"). + $eventPayload = ['response' => 'WS:/v1/realtime:passed']; + if (!$this->subscriptionMatchesPayload($queries, $eventPayload)) { + continue; + } + $this->send($server, $connection->fd, 'event', [ 'channels' => array_values($channels), 'events' => ['test.event'], 'timestamp' => gmdate('Y-m-d\TH:i:s.000\+00:00'), - 'payload' => ['response' => 'WS:/v1/realtime:passed'], + 'payload' => $eventPayload, 'subscriptions' => [$subscriptionId], ]); } } + /** + * @param string[] $queries + * @param array $payload + */ + private function subscriptionMatchesPayload(array $queries, array $payload): bool + { + foreach ($queries as $query) { + if (!$this->queryMatchesPayload((string) $query, $payload)) { + return false; + } + } + return true; + } + + private function queryMatchesPayload(string $query, array $payload): bool + { + $parsed = json_decode($query, true); + if (!is_array($parsed)) { + // Unparseable queries are treated as "no filter" so the mock + // never rejects events for an unknown query shape. + return true; + } + + $method = (string) ($parsed['method'] ?? ''); + $attribute = (string) ($parsed['attribute'] ?? ''); + $values = is_array($parsed['values'] ?? null) ? $parsed['values'] : []; + + if ($attribute === '' || !array_key_exists($attribute, $payload)) { + return false; + } + + $actual = $payload[$attribute]; + + return match ($method) { + 'equal' => in_array($actual, $values, true), + 'notEqual' => !in_array($actual, $values, true), + default => true, // unknown matcher: pass through (don't filter) + }; + } + private function handleUnsubscribe(Connection $connection, mixed $data): void { if (!is_array($data)) { diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index e7e99e58f7..5dae946ff7 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -81,7 +81,7 @@ class ServiceTest { // reset configs client.setProject("console") - .setEndpointRealtime("wss://cloud.appwrite.io/v1") + .setEndpointRealtime("ws://mockapi/v1") val foo = Foo(client) val bar = Bar(client) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index 7286eb3a2d..3efda2d8fe 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -34,8 +34,8 @@ class Tests: XCTestCase { // reset configs client.setProject("console") - client.setEndpointRealtime("wss://cloud.appwrite.io/v1") - client.setSelfSigned(false) + client.setEndpointRealtime("ws://mockapi/v1") + // Keep selfSigned=true so WebSocketClient skips TLS for the ws:// mock endpoint. let foo = Foo(client) let bar = Bar(client) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index e5ccc9f302..9169ace6c3 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -33,7 +33,7 @@ void main() async { client.setSelfSigned(); client.setProject('console'); - client.setEndPointRealtime("wss://cloud.appwrite.io/v1"); + client.setEndPointRealtime("ws://mockapi/v1"); Realtime realtime = Realtime(client); // Subscribe without queries diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index 0d7fc61e8e..f0412dae1d 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -45,7 +45,7 @@ // Realtime setup client.setProject('console'); - client.setEndpointRealtime('wss://cloud.appwrite.io/v1'); + client.setEndpointRealtime('ws://mockapi/v1'); const realtime = new Realtime(client); const realtimeWithFailure = new Realtime(client); From d298fe496411598a5e3a870624fbe294af48bfc9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 13 May 2026 10:55:53 +0530 Subject: [PATCH 22/30] fix: update client setup and improve subscription listener handling in tests --- tests/languages/flutter/tests.dart | 31 ++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index 9169ace6c3..aa135a390b 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -24,8 +24,7 @@ void main() async { PathProviderPlatform.instance = FakePathProvider(); Client client = Client() .setProject('123456') - .addHeader("Origin", "http://localhost") - .setSelfSigned(); + .addHeader("Origin", "http://localhost"); Foo foo = Foo(client); Bar bar = Bar(client); @@ -54,6 +53,24 @@ void main() async { ], ); + // Attach listeners immediately so the broadcast streams don't drop the + // synthetic event the mock emits right after subscribe() (broadcast + // streams don't buffer events while there's no listener attached). + final rtsubFirst = Completer(); + rtsub.stream.listen((m) { + if (!rtsubFirst.isCompleted) rtsubFirst.complete(m); + }); + + final rtsubWithQueriesFirst = Completer(); + rtsubWithQueries.stream.listen((m) { + if (!rtsubWithQueriesFirst.isCompleted) rtsubWithQueriesFirst.complete(m); + }); + + final rtsubWithQueriesFailureFirst = Completer(); + rtsubWithQueriesFailure.stream.listen((m) { + if (!rtsubWithQueriesFailureFirst.isCompleted) rtsubWithQueriesFailureFirst.complete(m); + }); + await Future.delayed(Duration(seconds: 5)); client.addHeader('Origin', 'http://localhost'); print('\nTest Started'); @@ -173,15 +190,17 @@ void main() async { print(e.message); } - // Assert realtime outputs in a deterministic order (no-query then with-query) - final message1 = await rtsub.stream.first.timeout(Duration(seconds: 10)); + // Assert realtime outputs in a deterministic order (no-query then with-query). + // Listeners were attached right after subscribe() above, so messages that + // arrived during the HTTP-test block have already been captured. + final message1 = await rtsubFirst.future.timeout(Duration(seconds: 10)); print(message1.payload["response"]); - final message2 = await rtsubWithQueries.stream.first.timeout(Duration(seconds: 10)); + final message2 = await rtsubWithQueriesFirst.future.timeout(Duration(seconds: 10)); print(message2.payload["response"]); try { - final message3 = await rtsubWithQueriesFailure.stream.first.timeout(Duration(seconds: 10)); + final message3 = await rtsubWithQueriesFailureFirst.future.timeout(Duration(seconds: 10)); // If we receive a message, it means the query filtering failed, so realtime failed print("Realtime failed!"); } on TimeoutException { From 6f27598961e325934fca5c55b5e807076c208eca Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 13 May 2026 11:35:23 +0530 Subject: [PATCH 23/30] fix: add utopia-php/query dependency and enhance query evaluation in Protocol class --- mock-server/composer.json | 3 +- mock-server/composer.lock | 51 ++++++++++++++- mock-server/src/Utopia/Realtime/Protocol.php | 66 ++++++++++++++++---- 3 files changed, 105 insertions(+), 15 deletions(-) diff --git a/mock-server/composer.json b/mock-server/composer.json index 12cb511b85..59841b2f30 100644 --- a/mock-server/composer.json +++ b/mock-server/composer.json @@ -11,7 +11,8 @@ "utopia-php/database": "0.48.*", "utopia-php/cli": "0.16.*", "utopia-php/swoole": "0.8.*", - "utopia-php/websocket": "^1.0" + "utopia-php/websocket": "^1.0", + "utopia-php/query": "^0.3.1" }, "require-dev": { "swoole/ide-helper": "5.1.2" diff --git a/mock-server/composer.lock b/mock-server/composer.lock index e737c1a633..34ee2ad208 100644 --- a/mock-server/composer.lock +++ b/mock-server/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "75e08ad65c5f41eca60eedde58cb8a1d", + "content-hash": "735665b96dcec1d67a5ea33973a240fb", "packages": [ { "name": "brick/math", @@ -2389,6 +2389,55 @@ }, "time": "2023-09-01T17:25:28+00:00" }, + { + "name": "utopia-php/query", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/query.git", + "reference": "5c8bba8cb2ae7e77f095499b4d32758c5c8bdb10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/query/zipball/5c8bba8cb2ae7e77f095499b4d32758c5c8bdb10", + "reference": "5c8bba8cb2ae7e77f095499b4d32758c5c8bdb10", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "brianium/paratest": "*", + "laravel/pint": "*", + "mongodb/mongodb": "^2.0", + "phpstan/phpstan": "*", + "phpunit/phpcov": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Query\\": "src/Query" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library providing a query abstraction for filtering, ordering, and pagination", + "keywords": [ + "framework", + "php", + "query", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/query/issues", + "source": "https://github.com/utopia-php/query/tree/0.3.1" + }, + "time": "2026-05-11T06:35:45+00:00" + }, { "name": "utopia-php/swoole", "version": "0.8.4", diff --git a/mock-server/src/Utopia/Realtime/Protocol.php b/mock-server/src/Utopia/Realtime/Protocol.php index cd98114873..005333d014 100644 --- a/mock-server/src/Utopia/Realtime/Protocol.php +++ b/mock-server/src/Utopia/Realtime/Protocol.php @@ -3,6 +3,9 @@ namespace Utopia\MockServer\Utopia\Realtime; use Utopia\MockServer\Utopia\Exception; +use Utopia\Query\Exception as QueryException; +use Utopia\Query\Method; +use Utopia\Query\Query; use Utopia\WebSocket\Server; /** @@ -123,27 +126,58 @@ private function handleSubscribe(Server $server, Connection $connection, mixed $ */ private function subscriptionMatchesPayload(array $queries, array $payload): bool { - foreach ($queries as $query) { - if (!$this->queryMatchesPayload((string) $query, $payload)) { + if (empty($queries)) { + return true; + } + + try { + $parsed = Query::parseQueries(array_values(array_map('strval', $queries))); + } catch (QueryException) { + return false; + } + + foreach ($parsed as $query) { + if (!$this->evaluateQuery($query, $payload)) { return false; } } return true; } - private function queryMatchesPayload(string $query, array $payload): bool + /** + * Evaluate a parsed Query against the event payload + * + * @param array $payload + */ + private function evaluateQuery(Query $query, array $payload): bool { - $parsed = json_decode($query, true); - if (!is_array($parsed)) { - // Unparseable queries are treated as "no filter" so the mock - // never rejects events for an unknown query shape. + $method = $query->getMethod(); + $values = $query->getValues(); + + if ($method === Method::Select) { + // select('*') is the realtime convention for "listen to all". + return count($values) === 1 && $values[0] === '*'; + } + + if ($method === Method::And) { + foreach ($values as $sub) { + if (!$sub instanceof Query || !$this->evaluateQuery($sub, $payload)) { + return false; + } + } return true; } - $method = (string) ($parsed['method'] ?? ''); - $attribute = (string) ($parsed['attribute'] ?? ''); - $values = is_array($parsed['values'] ?? null) ? $parsed['values'] : []; + if ($method === Method::Or) { + foreach ($values as $sub) { + if ($sub instanceof Query && $this->evaluateQuery($sub, $payload)) { + return true; + } + } + return false; + } + $attribute = $query->getAttribute(); if ($attribute === '' || !array_key_exists($attribute, $payload)) { return false; } @@ -151,9 +185,15 @@ private function queryMatchesPayload(string $query, array $payload): bool $actual = $payload[$attribute]; return match ($method) { - 'equal' => in_array($actual, $values, true), - 'notEqual' => !in_array($actual, $values, true), - default => true, // unknown matcher: pass through (don't filter) + Method::Equal => in_array($actual, $values, true), + Method::NotEqual => !in_array($actual, $values, true), + Method::LessThan => isset($values[0]) && $actual < $values[0], + Method::LessThanEqual => isset($values[0]) && $actual <= $values[0], + Method::GreaterThan => isset($values[0]) && $actual > $values[0], + Method::GreaterThanEqual => isset($values[0]) && $actual >= $values[0], + Method::IsNull => $actual === null, + Method::IsNotNull => $actual !== null, + default => false, // unimplemented matcher → fail closed }; } From 473be0d689d1cfc089e3a8364cfb202eadc9b0e5 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 13 May 2026 12:11:21 +0530 Subject: [PATCH 24/30] fix: reorder disconnect log statements and update realtime presence tests across multiple languages --- tests/Base.php | 2 +- tests/languages/android/Tests.kt | 35 ++++++++++-------------------- tests/languages/apple/Tests.swift | 34 ++++++++++------------------- tests/languages/flutter/tests.dart | 33 ++++++++++------------------ tests/languages/web/index.html | 32 ++++++++++----------------- tests/languages/web/node.js | 2 +- 6 files changed, 46 insertions(+), 92 deletions(-) diff --git a/tests/Base.php b/tests/Base.php index 3d7526b0a1..f7a6d3a5f2 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -115,8 +115,8 @@ abstract class Base extends TestCase 'Realtime failed!', 'Realtime unsubscribe:passed', 'Realtime update:passed', - 'Realtime disconnect:passed', 'Realtime presence:passed', + 'Realtime disconnect:passed', ]; protected const QUERY_HELPER_RESPONSES = [ diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index 5dae946ff7..33d587d5b1 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -262,29 +262,11 @@ class ServiceTest { writeToFile("Realtime update:failed") } + // Realtime presence (upsertPresence) test. Rides the existing WebSocket + // opened by the main realtime tests above — upsertPresence is + // fire-and-forget (no suspend, no await). try { - realtime.disconnect() - writeToFile("Realtime disconnect:passed") - } catch (e: Exception) { - writeToFile("Realtime disconnect:failed") - } - - // Realtime presence (upsertPresence) test against the mock WebSocket server. - // upsertPresence is fire-and-forget — call it without awaiting after - // giving subscribe() time to open the WebSocket. - try { - val presenceClient = Client(ApplicationProvider.getApplicationContext()) - .setProject("123456") - .setEndpointRealtime("ws://mockapi/v1") - val presenceRealtime = Realtime(presenceClient) - - presenceRealtime.subscribe( - "tests", - payloadType = TestPayload::class.java, - ) { /* no-op */ } - delay(3000) - - presenceRealtime.upsertPresence( + realtime.upsertPresence( status = "online", metadata = mapOf("page" to "/home"), presenceId = "p-test", @@ -292,12 +274,17 @@ class ServiceTest { delay(1000) writeToFile("Realtime presence:passed") - - presenceRealtime.disconnect() } catch (e: Exception) { writeToFile("Realtime presence:failed") } + try { + realtime.disconnect() + writeToFile("Realtime disconnect:passed") + } catch (e: Exception) { + writeToFile("Realtime disconnect:failed") + } + // mock = general.setCookie() // writeToFile(mock.result) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index 3efda2d8fe..b744c2eaf7 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -236,28 +236,11 @@ class Tests: XCTestCase { print("Realtime update:failed") } + // Realtime presence (upsertPresence) test. Rides the existing WebSocket + // opened by the main realtime tests above — upsertPresence is + // fire-and-forget (no `try await`). do { - try await realtime.disconnect() - print("Realtime disconnect:passed") - } catch { - print("Realtime disconnect:failed") - } - - // Realtime presence (upsertPresence) test against the mock WebSocket server. - // upsertPresence is fire-and-forget — call it without `try await` after - // giving subscribe() time to open the WebSocket. - // setSelfSigned() is required so WebSocketClient skips TLS for the ws:// mock endpoint. - do { - let presenceClient = Client() - .setProject("123456") - .setSelfSigned() - .setEndpointRealtime("ws://mockapi/v1") - let presenceRealtime = Realtime(presenceClient) - - _ = try await presenceRealtime.subscribe(channels: ["tests"]) { _ in } - try await Task.sleep(nanoseconds: 3_000_000_000) - - try presenceRealtime.upsertPresence( + try realtime.upsertPresence( status: "online", metadata: ["page": "/home"], presenceId: "p-test" @@ -265,12 +248,17 @@ class Tests: XCTestCase { try await Task.sleep(nanoseconds: 1_000_000_000) print("Realtime presence:passed") - - try await presenceRealtime.disconnect() } catch { print("Realtime presence:failed") } + do { + try await realtime.disconnect() + print("Realtime disconnect:passed") + } catch { + print("Realtime disconnect:failed") + } + mock = try await general.setCookie() print(mock.result) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index aa135a390b..e9c0f9ad87 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -232,27 +232,11 @@ void main() async { print("Realtime update:failed"); } + // Realtime presence (upsertPresence) test. Rides the existing WebSocket + // opened by the main realtime tests above — upsertPresence is + // fire-and-forget (void), no await needed. try { - await realtime.disconnect(); - print("Realtime disconnect:passed"); - } catch (e) { - print("Realtime disconnect:failed"); - } - - // Realtime presence (upsertPresence) test against the mock WebSocket server. - // Uses a fresh Client/Realtime so it doesn't inherit the cloud.appwrite.io endpoint above. - // upsertPresence is fire-and-forget (void) — call it without await, after - // giving subscribe() time to open the WebSocket. - try { - final presenceClient = Client() - .setProject('123456') - .setEndPointRealtime('ws://mockapi/v1'); - final presenceRealtime = Realtime(presenceClient); - - presenceRealtime.subscribe(['tests']); - await Future.delayed(Duration(seconds: 3)); - - presenceRealtime.upsertPresence( + realtime.upsertPresence( status: 'online', presenceId: 'p-test', metadata: {'page': '/home'}, @@ -260,12 +244,17 @@ void main() async { await Future.delayed(Duration(seconds: 1)); print("Realtime presence:passed"); - - await presenceRealtime.disconnect(); } catch (e) { print("Realtime presence:failed"); } + try { + await realtime.disconnect(); + print("Realtime disconnect:passed"); + } catch (e) { + print("Realtime disconnect:failed"); + } + response = await general.setCookie(); print(response.result); diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index f0412dae1d..b6c6402876 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -319,26 +319,11 @@ console.log('Realtime update:failed'); } + // Realtime presence (upsertPresence) test. Rides the existing + // WebSocket opened by the main realtime tests above — upsertPresence + // is fire-and-forget (Promise resolves immediately). try { - await realtime.disconnect(); - console.log('Realtime disconnect:passed'); - } catch (e) { - console.log('Realtime disconnect:failed'); - } - - // upsertPresence is fire-and-forget (async, returns Promise) — - // give subscribe() a moment to open the WebSocket, then call. - try { - const presenceClient = new Client(); - presenceClient.setProject('123456'); - presenceClient.setEndpointRealtime('ws://mockapi/v1'); - - const presenceRealtime = new Realtime(presenceClient); - - await presenceRealtime.subscribe(['tests'], () => {}); - await new Promise(resolve => setTimeout(resolve, 3000)); - - await presenceRealtime.upsertPresence({ + await realtime.upsertPresence({ status: 'online', presenceId: 'p-test', metadata: { page: '/home' }, @@ -346,12 +331,17 @@ await new Promise(resolve => setTimeout(resolve, 1000)); console.log('Realtime presence:passed'); - - await presenceRealtime.disconnect(); } catch (e) { console.log('Realtime presence:failed'); } + try { + await realtime.disconnect(); + console.log('Realtime disconnect:passed'); + } catch (e) { + console.log('Realtime disconnect:failed'); + } + // Query helper tests console.log(Query.equal("released", [true])); console.log(Query.equal("title", ["Spiderman", "Dr. Strange"])); diff --git a/tests/languages/web/node.js b/tests/languages/web/node.js index 00d378d61c..6f2f442ae0 100644 --- a/tests/languages/web/node.js +++ b/tests/languages/web/node.js @@ -192,8 +192,8 @@ async function start() { console.log('Realtime failed!'); // Skip realtime query failure test on Node.js console.log('Realtime unsubscribe:passed'); // Skip new realtime API tests on Node.js console.log('Realtime update:passed'); - console.log('Realtime disconnect:passed'); console.log('Realtime presence:passed'); // Skip realtime presence test on Node.js + console.log('Realtime disconnect:passed'); // Query helper tests console.log(Query.equal("released", [true])); From ede9c2d246b7a4ea7916da959d5e2190b9059c4b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 14 May 2026 12:20:10 +0530 Subject: [PATCH 25/30] made the presenceId as required to make it consistent with the rest api --- .../main/java/io/package/services/Realtime.kt.twig | 10 ++++++---- .../apple/Sources/Services/Realtime.swift.twig | 14 +++++++------- templates/flutter/lib/src/realtime.dart.twig | 3 ++- templates/flutter/lib/src/realtime_base.dart.twig | 2 +- .../flutter/lib/src/realtime_browser.dart.twig | 4 ++-- templates/flutter/lib/src/realtime_io.dart.twig | 4 ++-- templates/flutter/lib/src/realtime_mixin.dart.twig | 8 +++++--- templates/web/src/services/realtime.ts.twig | 8 +++----- 8 files changed, 28 insertions(+), 25 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index adc285cd3a..3f9fe5ca6d 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -186,20 +186,22 @@ class Realtime(client: Client) : Service(client), CoroutineScope { * When the socket is already open, the frame is sent immediately. * * @param status Presence status (required). + * @param presenceId Presence ID (required). * @param permissions Optional permission list to attach to the presence document. * @param metadata Optional metadata payload. - * @param presenceId Optional presence ID. Defaults server-side to a new unique ID. */ fun upsertPresence( status: String, + presenceId: String, permissions: List? = null, metadata: Map? = null, - presenceId: String? = null, ) { - val data = mutableMapOf("status" to status) + val data = mutableMapOf( + "status" to status, + "presenceId" to presenceId, + ) permissions?.let { data["permissions"] = it } metadata?.let { data["metadata"] = it } - presenceId?.let { data["presenceId"] = it } synchronized(presenceLock) { pendingPresence = data diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index 936b83e9e3..61f52cce9d 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -207,25 +207,25 @@ open class Realtime : Service { /// /// - Parameters: /// - status: The presence status (required). + /// - presenceId: The presence ID (required). /// - permissions: Optional permission list to attach to the presence document. /// - metadata: Optional metadata payload. - /// - presenceId: Optional presence ID. Defaults server-side to a new unique ID. public func upsertPresence( status: String, + presenceId: String, permissions: [String]? = nil, - metadata: [String: Any]? = nil, - presenceId: String? = nil + metadata: [String: Any]? = nil ) throws { - var data: [String: Any] = ["status": status] + var data: [String: Any] = [ + "status": status, + "presenceId": presenceId, + ] if let permissions = permissions { data["permissions"] = permissions } if let metadata = metadata { data["metadata"] = metadata } - if let presenceId = presenceId { - data["presenceId"] = presenceId - } presenceSync.sync { pendingPresence = data diff --git a/templates/flutter/lib/src/realtime.dart.twig b/templates/flutter/lib/src/realtime.dart.twig index ffc1af4f98..7a5e6303d2 100644 --- a/templates/flutter/lib/src/realtime.dart.twig +++ b/templates/flutter/lib/src/realtime.dart.twig @@ -75,14 +75,15 @@ abstract class Realtime extends Service { /// await Future.delayed(Duration(seconds: 1)); // let the WS open /// realtime.upsertPresence( /// status: 'online', + /// presenceId: 'p-1', /// metadata: {'device': 'web'}, /// ); /// ``` void upsertPresence({ required String status, + required String presenceId, List? permissions, Map? metadata, - String? presenceId, }); /// The [close code](https://datatracker.ietf.org/doc/html/rfc6455#section-7.1.5) set when the WebSocket connection is closed. diff --git a/templates/flutter/lib/src/realtime_base.dart.twig b/templates/flutter/lib/src/realtime_base.dart.twig index cba6932022..4690ba6772 100644 --- a/templates/flutter/lib/src/realtime_base.dart.twig +++ b/templates/flutter/lib/src/realtime_base.dart.twig @@ -14,8 +14,8 @@ abstract class RealtimeBase implements Realtime { @override void upsertPresence({ required String status, + required String presenceId, List? permissions, Map? metadata, - String? presenceId, }); } diff --git a/templates/flutter/lib/src/realtime_browser.dart.twig b/templates/flutter/lib/src/realtime_browser.dart.twig index 91f0498610..3563e7d983 100644 --- a/templates/flutter/lib/src/realtime_browser.dart.twig +++ b/templates/flutter/lib/src/realtime_browser.dart.twig @@ -45,15 +45,15 @@ class RealtimeBrowser extends RealtimeBase with RealtimeMixin { @override void upsertPresence({ required String status, + required String presenceId, List? permissions, Map? metadata, - String? presenceId, }) { upsertPresenceTo( status: status, + presenceId: presenceId, permissions: permissions, metadata: metadata, - presenceId: presenceId, ); } } diff --git a/templates/flutter/lib/src/realtime_io.dart.twig b/templates/flutter/lib/src/realtime_io.dart.twig index 46cc9fd415..e8cd2ebb3d 100644 --- a/templates/flutter/lib/src/realtime_io.dart.twig +++ b/templates/flutter/lib/src/realtime_io.dart.twig @@ -53,15 +53,15 @@ class RealtimeIO extends RealtimeBase with RealtimeMixin { @override void upsertPresence({ required String status, + required String presenceId, List? permissions, Map? metadata, - String? presenceId, }) { upsertPresenceTo( status: status, + presenceId: presenceId, permissions: permissions, metadata: metadata, - presenceId: presenceId, ); } diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index 47ec8f8f9e..a213669b7d 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -371,14 +371,16 @@ mixin RealtimeMixin { /// When the socket is already open, the frame is sent immediately. void upsertPresenceTo({ required String status, + required String presenceId, List? permissions, Map? metadata, - String? presenceId, }) { - final data = {'status': status}; + final data = { + 'status': status, + 'presenceId': presenceId, + }; if (permissions != null) data['permissions'] = permissions; if (metadata != null) data['metadata'] = metadata; - if (presenceId != null) data['presenceId'] = presenceId; _pendingPresence = data; _flushPendingPresence(); diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index cb98f1574b..10ffaaa8ed 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -75,9 +75,9 @@ export type RealtimePresence = { export type RealtimePresenceCreate = { status: string; + presenceId: string; permissions?: string[]; metadata?: Record; - presenceId?: string; } type RealtimeRequestSubscribeRow = { @@ -576,11 +576,12 @@ export class Realtime { * Returns a `Promise` for API consistency; the promise resolves as * soon as the payload has been stored and the opportunistic send attempted. * - * @param {RealtimePresenceCreate} params - Presence payload (status required, permissions/metadata/presenceId optional) + * @param {RealtimePresenceCreate} params - Presence payload (status and presenceId required, permissions/metadata optional) */ public async upsertPresence(params: RealtimePresenceCreate): Promise { const data: Record = { status: params.status, + presenceId: params.presenceId, }; if (params.permissions !== undefined) { data.permissions = params.permissions; @@ -588,9 +589,6 @@ export class Realtime { if (params.metadata !== undefined) { data.metadata = params.metadata; } - if (params.presenceId !== undefined) { - data.presenceId = params.presenceId; - } this.pendingPresence = data; this.flushPendingPresence(); From c35e6d5d6b581420f8b8edf6efd76a4a4ae4d73c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 14 May 2026 12:54:27 +0530 Subject: [PATCH 26/30] feat: add presence channel support across multiple languages and update tests --- .../src/main/java/io/package/Channel.kt.twig | 33 ++++++++++++++++++ templates/apple/Sources/Channel.swift.twig | 33 +++++++++++++++--- templates/flutter/lib/channel.dart.twig | 13 +++++++ .../flutter/test/src/channel_test.dart.twig | 34 +++++++++++++++++++ templates/react-native/src/channel.ts.twig | 15 ++++++-- templates/web/src/channel.ts.twig | 15 ++++++-- tests/Base.php | 6 ++++ tests/languages/android/Tests.kt | 6 ++++ tests/languages/apple/Tests.swift | 6 ++++ tests/languages/flutter/tests.dart | 6 ++++ tests/languages/web/index.html | 6 ++++ tests/languages/web/node.js | 6 ++++ 12 files changed, 169 insertions(+), 10 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/Channel.kt.twig b/templates/android/library/src/main/java/io/package/Channel.kt.twig index a187b8ac01..f92fdacc7e 100644 --- a/templates/android/library/src/main/java/io/package/Channel.kt.twig +++ b/templates/android/library/src/main/java/io/package/Channel.kt.twig @@ -14,6 +14,7 @@ public interface _Func public interface _Execution public interface _Team public interface _Membership +public interface _Presence public interface _Resolved // Union type for actionable channels @@ -81,6 +82,9 @@ class Channel private constructor( fun membership(id: String): Channel<_Membership> = Channel(listOf("memberships", normalize(id))) + fun presence(id: String): Channel<_Presence> = + Channel(listOf("presences", normalize(id))) + fun account(): String = "account" // Global events @@ -90,6 +94,7 @@ class Channel private constructor( fun executions(): String = "executions" fun teams(): String = "teams" fun memberships(): String = "memberships" + fun presences(): String = "presences" } } @@ -251,3 +256,31 @@ fun Channel<_Membership>.update(): Channel<_Resolved> = @JvmName("deleteMembership") fun Channel<_Membership>.delete(): Channel<_Resolved> = this.resolve("delete") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("createPresence") +fun Channel<_Presence>.create(): Channel<_Resolved> = + this.resolve("create") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("upsertPresence") +fun Channel<_Presence>.upsert(): Channel<_Resolved> = + this.resolve("upsert") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("updatePresence") +fun Channel<_Presence>.update(): Channel<_Resolved> = + this.resolve("update") + +/** + * Only available on Channel<_Presence> + */ +@JvmName("deletePresence") +fun Channel<_Presence>.delete(): Channel<_Resolved> = + this.resolve("delete") diff --git a/templates/apple/Sources/Channel.swift.twig b/templates/apple/Sources/Channel.swift.twig index b45b979b83..1672409e39 100644 --- a/templates/apple/Sources/Channel.swift.twig +++ b/templates/apple/Sources/Channel.swift.twig @@ -14,6 +14,7 @@ public struct _Func {} public struct _Execution {} public struct _Team {} public struct _Membership {} +public struct _Presence {} public struct _Resolved {} public enum ChannelValidationError: Swift.Error { @@ -89,11 +90,15 @@ public enum Channel { public static func membership(_ id: String) throws -> RealtimeChannel<_Membership> { return RealtimeChannel<_Membership>(["memberships", try normalize(id)]) } - + + public static func presence(_ id: String) throws -> RealtimeChannel<_Presence> { + return RealtimeChannel<_Presence>(["presences", try normalize(id)]) + } + public static func account() -> String { return "account" } - + // Global events public static func documents() -> String { "documents" } public static func rows() -> String { "rows" } @@ -101,6 +106,7 @@ public enum Channel { public static func executions() -> String { "executions" } public static func teams() -> String { "teams" } public static func memberships() -> String { "memberships" } + public static func presences() -> String { "presences" } } // MARK: - DATABASE ROUTE @@ -221,11 +227,30 @@ extension RealtimeChannel where T == _Membership { public func create() -> RealtimeChannel<_Resolved> { return self.resolve("create") } - + public func update() -> RealtimeChannel<_Resolved> { return self.resolve("update") } - + + public func delete() -> RealtimeChannel<_Resolved> { + return self.resolve("delete") + } +} + +/// Only available on RealtimeChannel<_Presence> +extension RealtimeChannel where T == _Presence { + public func create() -> RealtimeChannel<_Resolved> { + return self.resolve("create") + } + + public func upsert() -> RealtimeChannel<_Resolved> { + return self.resolve("upsert") + } + + public func update() -> RealtimeChannel<_Resolved> { + return self.resolve("update") + } + public func delete() -> RealtimeChannel<_Resolved> { return self.resolve("delete") } diff --git a/templates/flutter/lib/channel.dart.twig b/templates/flutter/lib/channel.dart.twig index 64712c193e..da20db1209 100644 --- a/templates/flutter/lib/channel.dart.twig +++ b/templates/flutter/lib/channel.dart.twig @@ -14,6 +14,7 @@ class _Func {} class _Execution {} class _Team {} class _Membership {} +class _Presence {} class _Resolved {} // Helper function for normalizing ID @@ -72,6 +73,9 @@ class Channel { static Channel<_Membership> membership(String id) => Channel<_Membership>._(['memberships', _normalize(id)]); + static Channel<_Presence> presence(String id) => + Channel<_Presence>._(['presences', _normalize(id)]); + static String account() => 'account'; // Global events @@ -81,6 +85,7 @@ class Channel { static String executions() => 'executions'; static String teams() => 'teams'; static String memberships() => 'memberships'; + static String presences() => 'presences'; } // --- DATABASE ROUTE --- @@ -156,3 +161,11 @@ extension MembershipChannel on Channel<_Membership> { Channel<_Resolved> update() => _resolve('update'); Channel<_Resolved> delete() => _resolve('delete'); } + +/// Only available on Channel<_Presence> +extension PresenceChannel on Channel<_Presence> { + Channel<_Resolved> create() => _resolve('create'); + Channel<_Resolved> upsert() => _resolve('upsert'); + Channel<_Resolved> update() => _resolve('update'); + Channel<_Resolved> delete() => _resolve('delete'); +} diff --git a/templates/flutter/test/src/channel_test.dart.twig b/templates/flutter/test/src/channel_test.dart.twig index 284d27f298..49f199460a 100644 --- a/templates/flutter/test/src/channel_test.dart.twig +++ b/templates/flutter/test/src/channel_test.dart.twig @@ -109,4 +109,38 @@ void main() { 'memberships.membership1.update'); }); }); + + group('presences()', () { + test('returns presences global channel', () { + expect(Channel.presences(), 'presences'); + }); + + test('throws when presence id is missing', () { + expect(() => Channel.presence(''), throwsArgumentError); + }); + + test('returns presence channel with specific presence ID', () { + expect(Channel.presence('presence1').toString(), 'presences.presence1'); + }); + + test('returns presence channel with create action', () { + expect(Channel.presence('presence1').create().toString(), + 'presences.presence1.create'); + }); + + test('returns presence channel with upsert action', () { + expect(Channel.presence('presence1').upsert().toString(), + 'presences.presence1.upsert'); + }); + + test('returns presence channel with update action', () { + expect(Channel.presence('presence1').update().toString(), + 'presences.presence1.update'); + }); + + test('returns presence channel with delete action', () { + expect(Channel.presence('presence1').delete().toString(), + 'presences.presence1.delete'); + }); + }); } diff --git a/templates/react-native/src/channel.ts.twig b/templates/react-native/src/channel.ts.twig index b6e4162104..653bd313ca 100644 --- a/templates/react-native/src/channel.ts.twig +++ b/templates/react-native/src/channel.ts.twig @@ -11,9 +11,10 @@ interface Func { _fn: any } interface Execution { _exec: any } interface Team { _team: any } interface Membership { _mem: any } +interface Presence { _presence: any } interface Resolved { _res: any } -type Actionable = Document | Row | File | Team | Membership; +type Actionable = Document | Row | File | Team | Membership | Presence; function normalize(id: string): string { if (id === undefined || id === null) { @@ -79,7 +80,7 @@ export class Channel { return this.resolve("create"); } - upsert(this: Channel): Channel { + upsert(this: Channel): Channel { return this.resolve("upsert"); } @@ -120,6 +121,10 @@ export class Channel { return new Channel(["memberships", normalize(id)]); } + static presence(id: string) { + return new Channel(["presences", normalize(id)]); + } + static account(): string { return "account"; } @@ -148,8 +153,12 @@ export class Channel { static memberships(): string { return "memberships"; } + + static presences(): string { + return "presences"; + } } // Export types for backward compatibility with realtime -export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel; +export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel | Channel; export type ResolvedChannel = Channel; diff --git a/templates/web/src/channel.ts.twig b/templates/web/src/channel.ts.twig index a5d7ee38e9..d4618a360a 100644 --- a/templates/web/src/channel.ts.twig +++ b/templates/web/src/channel.ts.twig @@ -11,9 +11,10 @@ interface Func { _fn: any } interface Execution { _exec: any } interface Team { _team: any } interface Membership { _mem: any } +interface Presence { _presence: any } interface Resolved { _res: any } -type Actionable = Document | Row | File | Team | Membership; +type Actionable = Document | Row | File | Team | Membership | Presence; function normalize(id: string): string { if (id === undefined || id === null) { @@ -82,7 +83,7 @@ export class Channel { return this.resolve("create"); } - upsert(this: Channel): Channel { + upsert(this: Channel): Channel { return this.resolve("upsert"); } @@ -123,6 +124,10 @@ export class Channel { return new Channel(["memberships", normalize(id)]); } + static presence(id: string) { + return new Channel(["presences", normalize(id)]); + } + static account(): string { return "account"; } @@ -151,8 +156,12 @@ export class Channel { static memberships(): string { return "memberships"; } + + static presences(): string { + return "presences"; + } } // Export types for backward compatibility with realtime -export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel; +export type ActionableChannel = Channel | Channel | Channel | Channel | Channel | Channel | Channel; export type ResolvedChannel = Channel; diff --git a/tests/Base.php b/tests/Base.php index 82594cd5cd..17afba396b 100644 --- a/tests/Base.php +++ b/tests/Base.php @@ -288,6 +288,12 @@ abstract class Base extends TestCase 'memberships.membership2', 'memberships.membership1', 'memberships.membership1.update', + 'presences', + 'presences.presence2', + 'presences.presence1', + 'presences.presence1.upsert', + 'presences.presence1.update', + 'presences.presence1.delete', ]; protected const OPERATOR_HELPER_RESPONSES = [ diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index 33d587d5b1..2b83f66560 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -413,6 +413,12 @@ class ServiceTest { writeToFile(Channel.membership("membership2").toString()) writeToFile(Channel.membership("membership1").toString()) writeToFile(Channel.membership("membership1").update().toString()) + writeToFile(Channel.presences()) + writeToFile(Channel.presence("presence2").toString()) + writeToFile(Channel.presence("presence1").toString()) + writeToFile(Channel.presence("presence1").upsert().toString()) + writeToFile(Channel.presence("presence1").update().toString()) + writeToFile(Channel.presence("presence1").delete().toString()) // Operator helper tests writeToFile(Operator.increment(1)) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index b744c2eaf7..08ef74ea92 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -393,6 +393,12 @@ class Tests: XCTestCase { print(try Channel.membership("membership2").toString()) print(try Channel.membership("membership1").toString()) print(try Channel.membership("membership1").update().toString()) + print(Channel.presences()) + print(try Channel.presence("presence2").toString()) + print(try Channel.presence("presence1").toString()) + print(try Channel.presence("presence1").upsert().toString()) + print(try Channel.presence("presence1").update().toString()) + print(try Channel.presence("presence1").delete().toString()) // Operator helper tests print(Operator.increment(1)) diff --git a/tests/languages/flutter/tests.dart b/tests/languages/flutter/tests.dart index e9c0f9ad87..14b2cc20ea 100644 --- a/tests/languages/flutter/tests.dart +++ b/tests/languages/flutter/tests.dart @@ -389,6 +389,12 @@ void main() async { print(Channel.membership('membership2').toString()); print(Channel.membership('membership1').toString()); print(Channel.membership('membership1').update().toString()); + print(Channel.presences()); + print(Channel.presence('presence2').toString()); + print(Channel.presence('presence1').toString()); + print(Channel.presence('presence1').upsert().toString()); + print(Channel.presence('presence1').update().toString()); + print(Channel.presence('presence1').delete().toString()); // Operator helper tests print(Operator.increment(1)); diff --git a/tests/languages/web/index.html b/tests/languages/web/index.html index b6c6402876..d46626713e 100644 --- a/tests/languages/web/index.html +++ b/tests/languages/web/index.html @@ -469,6 +469,12 @@ console.log(Channel.membership('membership2').toString()); console.log(Channel.membership('membership1').toString()); console.log(Channel.membership('membership1').update().toString()); + console.log(Channel.presences()); + console.log(Channel.presence('presence2').toString()); + console.log(Channel.presence('presence1').toString()); + console.log(Channel.presence('presence1').upsert().toString()); + console.log(Channel.presence('presence1').update().toString()); + console.log(Channel.presence('presence1').delete().toString()); // Operator helper tests console.log(Operator.increment(1)); diff --git a/tests/languages/web/node.js b/tests/languages/web/node.js index 6f2f442ae0..8214c03d31 100644 --- a/tests/languages/web/node.js +++ b/tests/languages/web/node.js @@ -322,6 +322,12 @@ async function start() { console.log(Channel.membership('membership2').toString()); console.log(Channel.membership('membership1').toString()); console.log(Channel.membership('membership1').update().toString()); + console.log(Channel.presences()); + console.log(Channel.presence('presence2').toString()); + console.log(Channel.presence('presence1').toString()); + console.log(Channel.presence('presence1').upsert().toString()); + console.log(Channel.presence('presence1').update().toString()); + console.log(Channel.presence('presence1').delete().toString()); // Operator helper tests console.log(Operator.increment(1)); From 1ee86d5dff9ae01143c786065fa86238499dadb9 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 14 May 2026 12:54:43 +0530 Subject: [PATCH 27/30] fix: remove dollar sign from additionalProperties key in model templates --- templates/python/package/models/model.py.twig | 26 +++++++++---------- .../ruby/lib/container/models/model.rb.twig | 12 ++++----- templates/rust/src/models/model.rs.twig | 8 +++--- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/templates/python/package/models/model.py.twig b/templates/python/package/models/model.py.twig index 663720a293..0789cc631e 100644 --- a/templates/python/package/models/model.py.twig +++ b/templates/python/package/models/model.py.twig @@ -70,7 +70,7 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener internal_fields = {k: v for k, v in data.items() if k.startswith('$')} user_data = {k: v for k, v in data.items() if not k.startswith('$')} instance = cls.model_validate(internal_fields) - instance._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} = model_type(**user_data) if model_type is not dict else user_data + instance._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} = model_type(**user_data) if model_type is not dict else user_data return instance {% else %} instance = cls.model_validate(data) @@ -94,24 +94,24 @@ class {{ definition.name | caseUcfirst }}(AppwriteModel{% if isGeneric %}, Gener {% endif %} {% if definition.additionalProperties %} - _{{ definition.additionalPropertiesKey | default('data') | caseSnake }}: Any = PrivateAttr(default_factory=dict) + _{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}: Any = PrivateAttr(default_factory=dict) @property - def {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(self) -> T: - return cast(T, self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}) + def {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}(self) -> T: + return cast(T, self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}) - @{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.setter - def {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(self, value: T) -> None: - object.__setattr__(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake }}', value) + @{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}.setter + def {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}(self, value: T) -> None: + object.__setattr__(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}', value) def to_dict(self) -> Dict[str, Any]: result = super().to_dict() - if hasattr(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake }}'): - if isinstance(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}, dict): - result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} - elif hasattr(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}, 'model_dump'): - result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.model_dump(mode='json') + if hasattr(self, '_{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}'): + if isinstance(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}, dict): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} + elif hasattr(self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}, 'model_dump'): + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}.model_dump(mode='json') else: - result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake }} + result['{{ definition.additionalPropertiesKey | default('data') }}'] = self._{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} return result {% endif %} diff --git a/templates/ruby/lib/container/models/model.rb.twig b/templates/ruby/lib/container/models/model.rb.twig index 004df76a4e..e0540feaac 100644 --- a/templates/ruby/lib/container/models/model.rb.twig +++ b/templates/ruby/lib/container/models/model.rb.twig @@ -8,7 +8,7 @@ module {{ spec.title | caseUcfirst }} attr_reader :{{ property.name | caseSnake | escapeKeyword }} {% endfor %} {% if definition.additionalProperties %} - attr_reader :{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} + attr_reader :{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} {% endif %} def initialize( @@ -17,7 +17,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}: + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }}: {% endif %} ) {% for property in definition.properties %} @@ -32,7 +32,7 @@ module {{ spec.title | caseUcfirst }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} = {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} + @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} = {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} {% endif %} end @@ -43,7 +43,7 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}: map["{{ definition.additionalPropertiesKey | default('data') }}"] || map + {{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }}: map["{{ definition.additionalPropertiesKey | default('data') }}"] || map {% endif %} ) end @@ -55,14 +55,14 @@ module {{ spec.title | caseUcfirst }} {% endfor %} {% if definition.additionalProperties %} - "{{ definition.additionalPropertiesKey | default('data') }}": @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }} + "{{ definition.additionalPropertiesKey | default('data') }}": @{{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }} {% endif %} } end {% if definition.additionalProperties %} def convert_to(from_json) - from_json.call({{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword }}) + from_json.call({{ definition.additionalPropertiesKey | default('data') | caseSnake | escapeKeyword | removeDollarSign }}) end {% endif %} {% for property in definition.properties %} diff --git a/templates/rust/src/models/model.rs.twig b/templates/rust/src/models/model.rs.twig index d7e6ee9673..86e5c21e78 100644 --- a/templates/rust/src/models/model.rs.twig +++ b/templates/rust/src/models/model.rs.twig @@ -26,7 +26,7 @@ pub struct {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} #[serde(flatten)] - pub {{ definition.additionalPropertiesKey | default('data') | caseSnake }}: HashMap, + pub {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}: HashMap, {% endif %} } @@ -57,12 +57,12 @@ impl {{ definition.name | caseUcfirst | overrideIdentifier }} { {% if definition.additionalProperties %} pub fn get(&self, key: &str) -> Option { - self.{{ definition.additionalPropertiesKey | default('data') | caseSnake }}.get(key) + self.{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}.get(key) .and_then(|v| serde_json::from_value(v.clone()).ok()) } - pub fn {{ definition.additionalPropertiesKey | default('data') | caseSnake }}(&self) -> &HashMap { - &self.{{ definition.additionalPropertiesKey | default('data') | caseSnake }} + pub fn {{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }}(&self) -> &HashMap { + &self.{{ definition.additionalPropertiesKey | default('data') | caseSnake | removeDollarSign }} } {% endif %} } From 13d4275faf913fa20ae07a3ad4ea2f79b16b3084 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 14 May 2026 13:24:40 +0530 Subject: [PATCH 28/30] updated --- tests/languages/android/Tests.kt | 2 +- tests/languages/apple/Tests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/languages/android/Tests.kt b/tests/languages/android/Tests.kt index 2b83f66560..0a79dbd700 100644 --- a/tests/languages/android/Tests.kt +++ b/tests/languages/android/Tests.kt @@ -268,8 +268,8 @@ class ServiceTest { try { realtime.upsertPresence( status = "online", - metadata = mapOf("page" to "/home"), presenceId = "p-test", + metadata = mapOf("page" to "/home"), ) delay(1000) diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index 08ef74ea92..6e2bca4244 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -242,8 +242,8 @@ class Tests: XCTestCase { do { try realtime.upsertPresence( status: "online", - metadata: ["page": "/home"], - presenceId: "p-test" + presenceId: "p-test", + metadata: ["page": "/home"] ) try await Task.sleep(nanoseconds: 1_000_000_000) From ed91b65da5512c21a77497e6aa504b856859b665 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 14 May 2026 13:57:12 +0530 Subject: [PATCH 29/30] fix: remove dollar sign from additionalProperties key in model templates --- templates/dotnet/Package/Models/Model.cs.twig | 12 ++++++------ templates/php/src/Models/Model.php.twig | 8 ++++---- tests/languages/apple/Tests.swift | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/templates/dotnet/Package/Models/Model.cs.twig b/templates/dotnet/Package/Models/Model.cs.twig index ca2a2e8227..b65485b288 100644 --- a/templates/dotnet/Package/Models/Model.cs.twig +++ b/templates/dotnet/Package/Models/Model.cs.twig @@ -17,7 +17,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - public Dictionary {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} { get; private set; } + public Dictionary {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }} { get; private set; } {%~ endif %} public {{ definition.name | caseUcfirst | overrideIdentifier }}( @@ -26,7 +26,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - Dictionary {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }} + Dictionary {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword | removeDollarSign }} {%~ endif %} ) { @@ -34,7 +34,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {{ property_name(definition, property) | overrideProperty(definition.name) }} = {{ property.name | caseCamel | escapeKeyword }}; {%~ endfor %} {%~ if definition.additionalProperties %} - {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} = {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }}; + {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }} = {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword | removeDollarSign }}; {%~ endif %} } @@ -81,7 +81,7 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endif %} {%~ endfor %} {%- if definition.additionalProperties %} - {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword }}: map.TryGetValue("{{ definition.additionalPropertiesKey | default('data') }}", out var additionalPropsValue) + {{ definition.additionalPropertiesKey | default('data') | caseCamel | escapeKeyword | removeDollarSign }}: map.TryGetValue("{{ definition.additionalPropertiesKey | default('data') }}", out var additionalPropsValue) ? (Dictionary)additionalPropsValue : map {%- endif ~%} @@ -94,13 +94,13 @@ namespace {{ spec.title | caseUcfirst }}.Models {%~ endfor %} {%~ if definition.additionalProperties %} - { "{{ definition.additionalPropertiesKey | default('data') }}", {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }} } + { "{{ definition.additionalPropertiesKey | default('data') }}", {{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }} } {%~ endif %} }; {%~ if definition.additionalProperties %} public T ConvertTo(Func, T> fromJson) => - fromJson.Invoke({{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword }}); + fromJson.Invoke({{ definition.additionalPropertiesKey | default('data') | caseUcfirst | escapeKeyword | removeDollarSign }}); {%~ endif %} {%~ for property in definition.properties %} {%~ if property.sub_schema %} diff --git a/templates/php/src/Models/Model.php.twig b/templates/php/src/Models/Model.php.twig index 98dda521b1..8c94daddbb 100644 --- a/templates/php/src/Models/Model.php.twig +++ b/templates/php/src/Models/Model.php.twig @@ -52,7 +52,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} * @param {{ paramDocType | raw }}|null ${{ property.name | caseCamel }} {{ property.description | unescape | lower | raw }} {% endfor %} {% if definition.additionalProperties %} - * @param array ${{ definition.additionalPropertiesKey | default('data') | caseCamel }} Additional properties. + * @param array ${{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }} Additional properties. {% endif %} */ public function __construct( @@ -63,7 +63,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} public ?{{ property | typeName }} ${{ property.name | caseCamel }} = null{{ (not loop.last or definition.additionalProperties) ? ',' : '' }} {% endfor %} {% if definition.additionalProperties %} - public array ${{ definition.additionalPropertiesKey | default('data') | caseCamel }} = [] + public array ${{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }} = [] {% endif %} ) { } @@ -140,7 +140,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} {% endif %} {% endfor %} {% if definition.additionalProperties %} - {{ definition.additionalPropertiesKey | default('data') | caseCamel }}: $additionalProperties + {{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }}: $additionalProperties {% endif %} ); {% endif %} @@ -161,7 +161,7 @@ readonly class {{ definition.name | caseUcfirst | overrideIdentifier }} ]; {% if definition.additionalProperties %} - foreach (static::serializeAdditionalProperties($this->{{ definition.additionalPropertiesKey | default('data') | caseCamel }}) as $field => $value) { + foreach (static::serializeAdditionalProperties($this->{{ definition.additionalPropertiesKey | default('data') | caseCamel | removeDollarSign }}) as $field => $value) { $result[$field] = $value; } {% endif %} diff --git a/tests/languages/apple/Tests.swift b/tests/languages/apple/Tests.swift index 6e2bca4244..a3cb511039 100644 --- a/tests/languages/apple/Tests.swift +++ b/tests/languages/apple/Tests.swift @@ -196,13 +196,13 @@ class Tests: XCTestCase { print("Invalid endpoint URL: htp://cloud.appwrite.io/v1") // Indicates fatalError by client.setEndpoint - wait(for: [expectation], timeout: 20.0) + await fulfillment(of: [expectation], timeout: 20.0) print(realtimeResponse) - wait(for: [expectationWithQueries], timeout: 20.0) + await fulfillment(of: [expectationWithQueries], timeout: 20.0) print(realtimeResponseWithQueries) - - wait(for: [expectationWithQueriesFailure], timeout: 20.0) + + await fulfillment(of: [expectationWithQueriesFailure], timeout: 20.0) if expectationWithQueriesFailure.isInverted { print(realtimeResponseWithQueriesFailure) } else { From 48ea9b21c5e3433647857730b59f56b2f6795fb8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 14 May 2026 17:34:49 +0530 Subject: [PATCH 30/30] updated --- .../src/main/java/io/package/services/Realtime.kt.twig | 6 ++++++ templates/apple/Sources/Services/Realtime.swift.twig | 8 ++++++-- templates/flutter/lib/src/realtime_mixin.dart.twig | 6 ++++++ templates/web/src/services/realtime.ts.twig | 7 +++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig index 3f9fe5ca6d..0d551a4e5e 100644 --- a/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Realtime.kt.twig @@ -40,6 +40,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private val activeSubscriptions = ConcurrentHashMap() private val pendingSubscribes = LinkedHashMap>() private var pendingPresence: Map? = null + @Volatile private var appConnected = false private var reconnectAttempts = 0 private val socketGeneration = AtomicInteger(0) @@ -130,6 +131,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { synchronized(presenceLock) { pendingPresence = null } + appConnected = false } private fun sendPendingSubscribes() { @@ -210,6 +212,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } private fun flushPendingPresence() { + if (!appConnected) return val data = synchronized(presenceLock) { pendingPresence } ?: return val ws = socket ?: return ws.send(mapOf("type" to "presence", "data" to data).toJson()) @@ -376,6 +379,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { synchronized(subscriptionLock) { activeSubscriptions.keys.forEach { enqueuePendingSubscribeLocked(it) } } + appConnected = true sendPendingSubscribes() flushPendingPresence() } @@ -413,6 +417,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { super.onClosing(webSocket, code, reason) if (isStale(webSocket)) return + appConnected = false stopHeartbeat() if (!reconnect || code == RealtimeCode.POLICY_VIOLATION.value) { reconnect = true @@ -437,6 +442,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { super.onFailure(webSocket, t, response) if (isStale(webSocket)) return + appConnected = false stopHeartbeat() t.printStackTrace() } diff --git a/templates/apple/Sources/Services/Realtime.swift.twig b/templates/apple/Sources/Services/Realtime.swift.twig index 61f52cce9d..355fdb8404 100644 --- a/templates/apple/Sources/Services/Realtime.swift.twig +++ b/templates/apple/Sources/Services/Realtime.swift.twig @@ -21,6 +21,7 @@ open class Realtime : Service { private var activeSubscriptions = [String: RealtimeCallback]() private var pendingSubscribes = [String: [String: Any]]() private var pendingPresence: [String: Any]? = nil + private var appConnected = false private var heartbeatTask: Task? = nil let connectSync = DispatchQueue(label: "ConnectSync") @@ -140,6 +141,7 @@ open class Realtime : Service { presenceSync.sync { pendingPresence = nil } + appConnected = false reconnect = false try await closeSocket() } @@ -238,7 +240,7 @@ open class Realtime : Service { presenceSync.sync { data = pendingPresence } - guard let payloadData = data, let ws = socketClient, ws.isConnected else { + guard let payloadData = data, let ws = socketClient, ws.isConnected, appConnected else { return } let payload: [String: Any] = [ @@ -412,6 +414,7 @@ extension Realtime: WebSocketClientDelegate { for subscriptionId in activeSubscriptions.keys { enqueuePendingSubscribe(subscriptionId: subscriptionId) } + appConnected = true sendPendingSubscribes() try? flushPendingPresence() } @@ -442,10 +445,11 @@ extension Realtime: WebSocketClientDelegate { } public func onClose(channel: NIO.Channel, data: Data) async throws { + appConnected = false stopHeartbeat() onCloseCallbacks.forEach { $0() } - + if (!reconnect) { reconnect = true return diff --git a/templates/flutter/lib/src/realtime_mixin.dart.twig b/templates/flutter/lib/src/realtime_mixin.dart.twig index a213669b7d..e03381ec94 100644 --- a/templates/flutter/lib/src/realtime_mixin.dart.twig +++ b/templates/flutter/lib/src/realtime_mixin.dart.twig @@ -31,6 +31,7 @@ mixin RealtimeMixin { final Map _subscriptions = {}; final Map> _pendingSubscribes = {}; Map? _pendingPresence; + bool _appConnected = false; WebSocketChannel? _websok; String? _lastUrl; late WebSocketFactory getWebSocket; @@ -45,6 +46,7 @@ mixin RealtimeMixin { Future _closeConnection() async { _stopHeartbeat(); + _appConnected = false; await _websocketSubscription?.cancel(); await _websok?.sink.close(status.normalClosure, 'Ending session'); _lastUrl = null; @@ -124,6 +126,7 @@ mixin RealtimeMixin { 'queries': entry.value.queries, }; } + _appConnected = true; _sendPendingSubscribes(); _flushPendingPresence(); _startHeartbeat(); // Start heartbeat after successful connection @@ -150,9 +153,11 @@ mixin RealtimeMixin { break; } }, onDone: () { + _appConnected = false; _stopHeartbeat(); _retry(); }, onError: (err, stack) { + _appConnected = false; _stopHeartbeat(); for (var subscription in _subscriptions.values) { subscription.controller.addError(err, stack); @@ -281,6 +286,7 @@ mixin RealtimeMixin { final data = _pendingPresence; if (data == null) return; if (_websok == null || _websok?.closeCode != null) return; + if (!_appConnected) return; _websok!.sink.add(jsonEncode({'type': 'presence', 'data': data})); } diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index 10ffaaa8ed..dff76607ca 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -105,6 +105,7 @@ export class Realtime { private activeSubscriptions = new Map>(); private pendingSubscribes = new Map(); private pendingPresence?: Record; + private appConnected = false; private heartbeatTimer?: number; private subCallDepth = 0; @@ -239,6 +240,7 @@ export class Realtime { if (connectionId !== this.connectionId || socket !== this.socket) { return; } + this.appConnected = false; this.stopHeartbeat(); this.onCloseCallbacks.forEach(callback => callback()); @@ -360,6 +362,7 @@ export class Realtime { this.activeSubscriptions.clear(); this.pendingSubscribes.clear(); this.pendingPresence = undefined; + this.appConnected = false; this.reconnect = false; await this.closeSocket(); } @@ -601,6 +604,9 @@ export class Realtime { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { return; } + if (!this.appConnected) { + return; + } this.socket.send(JSONbig.stringify({ type: 'presence', data: this.pendingPresence @@ -657,6 +663,7 @@ export class Realtime { for (const subscriptionId of this.activeSubscriptions.keys()) { this.enqueuePendingSubscribe(subscriptionId); } + this.appConnected = true; this.sendPendingSubscribes(); this.flushPendingPresence(); }