Use WebSocket instead of Long Polling (#1142)

This commit is contained in:
boatbomber
2025-11-26 19:57:01 -08:00
committed by GitHub
parent a61a1bef55
commit 87f58e0a55
44 changed files with 1750 additions and 971 deletions

View File

@@ -69,10 +69,12 @@ Making a new release? Simply add the new header with the version and date undern
- `syncUnscriptable` defaults to `true` instead of `false`
- `ignoreTrees` doesn't require the root of the project's name in it.
* Fixed bugs and improved performance & UX for the script diff viewer ([#994])
* Rebuilt the internal communication between the server and plugin to use [websockets](https://devforum.roblox.com/t/websockets-support-in-studio-is-now-available/4021932/1) instead of [long polling](https://en.wikipedia.org/wiki/Push_technology#Long_polling) ([#1142])
* Added support for `.jsonc` files for all JSON-related files (e.g. `.project.jsonc` and `.meta.jsonc`) to accompany JSONC support ([#1159])
[#937]: https://github.com/rojo-rbx/rojo/pull/937
[#994]: https://github.com/rojo-rbx/rojo/pull/994
[#1142]: https://github.com/rojo-rbx/rojo/pull/1142
[#1159]: https://github.com/rojo-rbx/rojo/pull/1159
[#1172]: https://github.com/rojo-rbx/rojo/pull/1172

1566
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,7 @@ futures = "0.3.30"
globset = "0.4.14"
humantime = "2.1.0"
hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
hyper-tungstenite = "0.11.0"
jod-thread = "0.1.2"
log = "0.4.21"
num_cpus = "1.16.0"
@@ -87,10 +88,11 @@ roblox_install = "1.0.0"
serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.145"
jsonc-parser = { version = "0.27.0", features = ["serde"] }
strum = { version = "0.27", features = ["derive"] }
toml = "0.5.11"
termcolor = "1.4.1"
thiserror = "1.0.57"
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15"

View File

@@ -1,4 +1,5 @@
local Packages = script.Parent.Parent.Packages
local HttpService = game:GetService("HttpService")
local Http = require(Packages.Http)
local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
@@ -9,7 +10,7 @@ local Version = require(script.Parent.Version)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
local validateApiSocketPacket = Types.ifEnabled(Types.ApiSocketPacket)
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
@@ -99,6 +100,7 @@ function ApiContext.new(baseUrl)
__baseUrl = baseUrl,
__sessionId = nil,
__messageCursor = -1,
__wsClient = nil,
__connected = true,
__activeRequests = {},
}
@@ -126,6 +128,12 @@ function ApiContext:disconnect()
request:cancel()
end
self.__activeRequests = {}
if self.__wsClient then
Log.trace("Closing WebSocket client")
self.__wsClient:Close()
end
self.__wsClient = nil
end
function ApiContext:setMessageCursor(index)
@@ -207,38 +215,65 @@ function ApiContext:write(patch)
end)
end
function ApiContext:retrieveMessages()
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
function ApiContext:connectWebSocket(packetHandlers)
local url = ("%s/api/socket/%s"):format(self.__baseUrl, self.__messageCursor)
-- Convert HTTP/HTTPS URL to WS/WSS
url = url:gsub("^http://", "ws://"):gsub("^https://", "wss://")
local function sendRequest()
local request = Http.get(url):catch(function(err)
if err.type == Http.Error.Kind.Timeout and self.__connected then
return sendRequest()
return Promise.new(function(resolve, reject)
local success, wsClient =
pcall(HttpService.CreateWebStreamClient, HttpService, Enum.WebStreamClientType.WebSocket, {
Url = url,
})
if not success then
reject("Failed to create WebSocket client: " .. tostring(wsClient))
return
end
self.__wsClient = wsClient
local closed, errored, received
received = self.__wsClient.MessageReceived:Connect(function(msg)
local data = Http.jsonDecode(msg)
if data.sessionId ~= self.__sessionId then
Log.warn("Received message with wrong session ID; ignoring")
return
end
return Promise.reject(err)
assert(validateApiSocketPacket(data))
Log.trace("Received websocket packet: {:#?}", data)
local handler = packetHandlers[data.packetType]
if handler then
local ok, err = pcall(handler, data.body)
if not ok then
Log.error("Error in WebSocket packet handler for type '%s': %s", data.packetType, err)
end
else
Log.warn("No handler for WebSocket packet type '%s'", data.packetType)
end
end)
Log.trace("Tracking request {}", request)
self.__activeRequests[request] = true
closed = self.__wsClient.Closed:Connect(function()
closed:Disconnect()
errored:Disconnect()
received:Disconnect()
return request:finally(function(...)
Log.trace("Cleaning up request {}", request)
self.__activeRequests[request] = nil
return ...
if self.__connected then
reject("WebSocket connection closed unexpectedly")
else
resolve()
end
end)
end
return sendRequest():andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
errored = self.__wsClient.Error:Connect(function(code, msg)
closed:Disconnect()
errored:Disconnect()
received:Disconnect()
assert(validateApiSubscribe(body))
self:setMessageCursor(body.messageCursor)
return body.messages
reject("WebSocket error: " .. code .. " - " .. msg)
end)
end)
end

View File

@@ -174,6 +174,8 @@ function App:init()
end
function App:willUnmount()
self:endSession()
self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy()

View File

@@ -21,7 +21,7 @@ return strict("Config", {
codename = "Epiphany",
version = realVersion,
expectedServerVersionString = ("%d.%d or newer"):format(realVersion[1], realVersion[2]),
protocolVersion = 4,
protocolVersion = 5,
defaultHost = "localhost",
defaultPort = "34872",
})

View File

@@ -201,7 +201,20 @@ function ServeSession:start()
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
return self:__mainSyncLoop()
return self.__apiContext:connectWebSocket({
["messages"] = function(messagesPacket)
if self.__status == Status.Disconnected then
return
end
Log.debug("Received {} messages from Rojo server", #messagesPacket.messages)
for _, message in messagesPacket.messages do
self:__applyPatch(message)
end
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
end,
})
end)
end)
:catch(function(err)
@@ -536,40 +549,6 @@ function ServeSession:__initialSync(serverInfo)
end)
end
function ServeSession:__mainSyncLoop()
return Promise.new(function(resolve, reject)
while self.__status == Status.Connected do
local success, result = self.__apiContext
:retrieveMessages()
:andThen(function(messages)
if self.__status == Status.Disconnected then
-- In the time it took to retrieve messages, we disconnected
-- so we just resolve immediately without patching anything
return
end
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
for _, message in messages do
self:__applyPatch(message)
end
end)
:await()
if self.__status == Status.Disconnected then
-- If we are no longer connected after applying, we stop silently
-- without checking for errors as they are no longer relevant
break
elseif success == false then
reject(result)
end
end
-- We are no longer connected, so we resolve the promise
resolve()
end)
end
function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect()

View File

@@ -49,12 +49,21 @@ local ApiReadResponse = t.interface({
instances = t.map(RbxId, ApiInstance),
})
local ApiSubscribeResponse = t.interface({
sessionId = t.string,
local SocketPacketType = t.union(t.literal("messages"))
local MessagesPacket = t.interface({
messageCursor = t.number,
messages = t.array(ApiSubscribeMessage),
})
local SocketPacketBody = t.union(MessagesPacket)
local ApiSocketPacket = t.interface({
sessionId = t.string,
packetType = SocketPacketType,
body = SocketPacketBody,
})
local ApiSerializeResponse = t.interface({
sessionId = t.string,
modelContents = t.buffer,
@@ -85,7 +94,7 @@ return strict("Types", {
ApiInfoResponse = ApiInfoResponse,
ApiReadResponse = ApiReadResponse,
ApiSubscribeResponse = ApiSubscribeResponse,
ApiSocketPacket = ApiSocketPacket,
ApiError = ApiError,
ApiInstance = ApiInstance,

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: add_folder
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,19 +1,21 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added:
id-3:
Children: []
ClassName: Folder
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: my-new-folder
Parent: id-2
Properties: {}
removed: []
updated: []
body:
messageCursor: 1
messages:
- added:
id-3:
Children: []
ClassName: Folder
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: my-new-folder
Parent: id-2
Properties: {}
removed: []
updated: []
packetType: messages
sessionId: id-1

View File

@@ -7,7 +7,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: optional
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: edit_init
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,19 +1,19 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added: {}
removed: []
updated:
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Source:
String: "-- Edited contents"
id: id-2
body:
messageCursor: 1
messages:
- added: {}
removed: []
updated:
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Source:
String: "-- Edited contents"
id: id-2
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: empty
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: empty_folder
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,21 +1,23 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added:
id-3:
Children: []
ClassName: Model
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: test
Parent: id-2
Properties:
NeedsPivotMigration:
Bool: false
removed: []
updated: []
body:
messageCursor: 1
messages:
- added:
id-3:
Children: []
ClassName: Model
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: test
Parent: id-2
Properties:
NeedsPivotMigration:
Bool: false
removed: []
updated: []
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: forced_parent
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: meshpart
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,27 +1,29 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added:
id-6:
Children: []
ClassName: Actor
Id: id-6
Metadata:
ignoreUnknownInstances: true
Name: Actor
Parent: id-3
Properties:
NeedsPivotMigration:
Bool: false
removed: []
updated:
- changedClassName: ~
changedMetadata:
ignoreUnknownInstances: true
changedName: ~
changedProperties: {}
id: id-3
body:
messageCursor: 1
messages:
- added:
id-6:
Children: []
ClassName: Actor
Id: id-6
Metadata:
ignoreUnknownInstances: true
Name: Actor
Parent: id-3
Properties:
NeedsPivotMigration:
Bool: false
removed: []
updated:
- changedClassName: ~
changedMetadata:
ignoreUnknownInstances: true
changedName: ~
changedProperties: {}
id: id-3
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: move_folder_of_stuff
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,141 +1,141 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added:
id-10:
Children: []
ClassName: StringValue
Id: id-10
Metadata:
ignoreUnknownInstances: false
Name: "6"
Parent: id-3
Properties:
Value:
String: "File #6"
id-11:
Children: []
ClassName: StringValue
Id: id-11
Metadata:
ignoreUnknownInstances: false
Name: "7"
Parent: id-3
Properties:
Value:
String: "File #7"
id-12:
Children: []
ClassName: StringValue
Id: id-12
Metadata:
ignoreUnknownInstances: false
Name: "8"
Parent: id-3
Properties:
Value:
String: "File #8"
id-13:
Children: []
ClassName: StringValue
Id: id-13
Metadata:
ignoreUnknownInstances: false
Name: "9"
Parent: id-3
Properties:
Value:
String: "File #9"
id-3:
Children:
- id-4
- id-5
- id-6
- id-7
- id-8
- id-9
- id-10
- id-11
- id-12
- id-13
ClassName: Folder
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: new-stuff
Parent: id-2
Properties: {}
id-4:
Children: []
ClassName: StringValue
Id: id-4
Metadata:
ignoreUnknownInstances: false
Name: "0"
Parent: id-3
Properties:
Value:
String: "File #0"
id-5:
Children: []
ClassName: StringValue
Id: id-5
Metadata:
ignoreUnknownInstances: false
Name: "1"
Parent: id-3
Properties:
Value:
String: "File #1"
id-6:
Children: []
ClassName: StringValue
Id: id-6
Metadata:
ignoreUnknownInstances: false
Name: "2"
Parent: id-3
Properties:
Value:
String: "File #2"
id-7:
Children: []
ClassName: StringValue
Id: id-7
Metadata:
ignoreUnknownInstances: false
Name: "3"
Parent: id-3
Properties:
Value:
String: "File #3"
id-8:
Children: []
ClassName: StringValue
Id: id-8
Metadata:
ignoreUnknownInstances: false
Name: "4"
Parent: id-3
Properties:
Value:
String: "File #4"
id-9:
Children: []
ClassName: StringValue
Id: id-9
Metadata:
ignoreUnknownInstances: false
Name: "5"
Parent: id-3
Properties:
Value:
String: "File #5"
removed: []
updated: []
body:
messageCursor: 1
messages:
- added:
id-10:
Children: []
ClassName: StringValue
Id: id-10
Metadata:
ignoreUnknownInstances: false
Name: "6"
Parent: id-3
Properties:
Value:
String: "File #6"
id-11:
Children: []
ClassName: StringValue
Id: id-11
Metadata:
ignoreUnknownInstances: false
Name: "7"
Parent: id-3
Properties:
Value:
String: "File #7"
id-12:
Children: []
ClassName: StringValue
Id: id-12
Metadata:
ignoreUnknownInstances: false
Name: "8"
Parent: id-3
Properties:
Value:
String: "File #8"
id-13:
Children: []
ClassName: StringValue
Id: id-13
Metadata:
ignoreUnknownInstances: false
Name: "9"
Parent: id-3
Properties:
Value:
String: "File #9"
id-3:
Children:
- id-4
- id-5
- id-6
- id-7
- id-8
- id-9
- id-10
- id-11
- id-12
- id-13
ClassName: Folder
Id: id-3
Metadata:
ignoreUnknownInstances: false
Name: new-stuff
Parent: id-2
Properties: {}
id-4:
Children: []
ClassName: StringValue
Id: id-4
Metadata:
ignoreUnknownInstances: false
Name: "0"
Parent: id-3
Properties:
Value:
String: "File #0"
id-5:
Children: []
ClassName: StringValue
Id: id-5
Metadata:
ignoreUnknownInstances: false
Name: "1"
Parent: id-3
Properties:
Value:
String: "File #1"
id-6:
Children: []
ClassName: StringValue
Id: id-6
Metadata:
ignoreUnknownInstances: false
Name: "2"
Parent: id-3
Properties:
Value:
String: "File #2"
id-7:
Children: []
ClassName: StringValue
Id: id-7
Metadata:
ignoreUnknownInstances: false
Name: "3"
Parent: id-3
Properties:
Value:
String: "File #3"
id-8:
Children: []
ClassName: StringValue
Id: id-8
Metadata:
ignoreUnknownInstances: false
Name: "4"
Parent: id-3
Properties:
Value:
String: "File #4"
id-9:
Children: []
ClassName: StringValue
Id: id-9
Metadata:
ignoreUnknownInstances: false
Name: "5"
Parent: id-3
Properties:
Value:
String: "File #5"
removed: []
updated: []
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: top-level
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: no_name_project
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: no_name_top_level_project
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: pivot_migration
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: ref_properties
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: ref_properties
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,17 +1,19 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added: {}
removed: []
updated:
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Scale:
Float32: 1
id: id-8
body:
messageCursor: 1
messages:
- added: {}
removed: []
updated:
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Scale:
Float32: 1
id: id-8
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: ref_properties_remove
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,12 +1,13 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added: {}
removed:
- id-4
updated: []
body:
messageCursor: 1
messages:
- added: {}
removed:
- id-4
updated: []
packetType: messages
sessionId: id-1

View File

@@ -1,47 +1,49 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added:
id-11:
Children: []
ClassName: Model
Id: id-11
Metadata:
ignoreUnknownInstances: false
Name: ProjectPointer
Parent: id-7
Properties:
Attributes:
body:
messageCursor: 1
messages:
- added:
id-11:
Children: []
ClassName: Model
Id: id-11
Metadata:
ignoreUnknownInstances: false
Name: ProjectPointer
Parent: id-7
Properties:
Attributes:
Rojo_Target_PrimaryPart:
String: project target
NeedsPivotMigration:
Bool: false
PrimaryPart:
Ref: id-9
removed: []
updated:
- changedClassName: ~
changedMetadata:
ignoreUnknownInstances: false
changedName: ~
changedProperties:
Attributes:
Attributes:
Rojo_Target_PrimaryPart:
String: project target
NeedsPivotMigration:
Bool: false
PrimaryPart:
Ref: id-9
removed: []
updated:
- changedClassName: ~
changedMetadata:
ignoreUnknownInstances: false
changedName: ~
changedProperties:
Attributes:
Rojo_Id:
String: model target 2
id: id-7
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Attributes:
Attributes:
Rojo_Id:
String: model target 2
id: id-7
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Attributes:
Rojo_Target_PrimaryPart:
String: model target 2
PrimaryPart: ~
id: id-8
Attributes:
Rojo_Target_PrimaryPart:
String: model target 2
PrimaryPart: ~
id: id-8
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: remove_file
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,11 +1,13 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added: {}
removed:
- id-3
updated: []
body:
messageCursor: 1
messages:
- added: {}
removed:
- id-3
updated: []
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: scripts
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,19 +1,19 @@
---
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
expression: "socket_packet.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1
messages:
- added: {}
removed: []
updated:
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Source:
String: Updated foo!
id: id-4
body:
messageCursor: 1
messages:
- added: {}
removed: []
updated:
- changedClassName: ~
changedMetadata: ~
changedName: ~
changedProperties:
Source:
String: Updated foo!
id: id-4
packetType: messages
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: sync_rule_alone
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: sync_rule_complex
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: sync_rule_no_extension
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: sync_rule_no_name_project
protocolVersion: 4
protocolVersion: 5
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -9,7 +9,9 @@ use std::{
sync::Arc,
};
use futures::{sink::SinkExt, stream::StreamExt};
use hyper::{body, Body, Method, Request, Response, StatusCode};
use hyper_tungstenite::{is_upgrade_request, tungstenite::Message, upgrade, HyperWebsocket};
use opener::OpenError;
use rbx_dom_weak::{
types::{Ref, Variant},
@@ -22,16 +24,16 @@ use crate::{
snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
web::{
interface::{
ErrorResponse, Instance, OpenResponse, ReadResponse, ServerInfoResponse,
SubscribeMessage, SubscribeResponse, WriteRequest, WriteResponse, PROTOCOL_VERSION,
SERVER_VERSION,
ErrorResponse, Instance, MessagesPacket, OpenResponse, ReadResponse,
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
},
util::{json, json_ok},
},
web_api::{BufferEncode, InstanceUpdate, RefPatchResponse, SerializeResponse},
};
pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> Response<Body> {
pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> {
let service = ApiService::new(serve_session);
match (request.method(), request.uri().path()) {
@@ -39,8 +41,17 @@ pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> R
(&Method::GET, path) if path.starts_with("/api/read/") => {
service.handle_api_read(request).await
}
(&Method::GET, path) if path.starts_with("/api/subscribe/") => {
service.handle_api_subscribe(request).await
(&Method::GET, path) if path.starts_with("/api/socket/") => {
if is_upgrade_request(&request) {
service.handle_api_socket(&mut request).await
} else {
json(
ErrorResponse::bad_request(
"/api/socket must be called as a websocket upgrade request",
),
StatusCode::BAD_REQUEST,
)
}
}
(&Method::GET, path) if path.starts_with("/api/serialize/") => {
service.handle_api_serialize(request).await
@@ -88,10 +99,9 @@ impl ApiService {
})
}
/// Retrieve any messages past the given cursor index, and if
/// there weren't any, subscribe to receive any new messages.
async fn handle_api_subscribe(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/subscribe/".len()..];
/// Handle WebSocket upgrade for real-time message streaming
async fn handle_api_socket(&self, request: &mut Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/socket/".len()..];
let input_cursor: u32 = match argument.parse() {
Ok(v) => v,
Err(err) => {
@@ -102,36 +112,29 @@ impl ApiService {
}
};
let session_id = self.serve_session.session_id();
let result = self
.serve_session
.message_queue()
.subscribe(input_cursor)
.await;
let tree_handle = self.serve_session.tree_handle();
match result {
Ok((message_cursor, messages)) => {
let tree = tree_handle.lock().unwrap();
let api_messages = messages
.into_iter()
.map(|patch| SubscribeMessage::from_patch_update(&tree, patch))
.collect();
json_ok(SubscribeResponse {
session_id,
message_cursor,
messages: api_messages,
})
// Upgrade the connection to WebSocket
let (response, websocket) = match upgrade(request, None) {
Ok(result) => result,
Err(err) => {
return json(
ErrorResponse::internal_error(format!("WebSocket upgrade failed: {}", err)),
StatusCode::INTERNAL_SERVER_ERROR,
);
}
Err(_) => json(
ErrorResponse::internal_error("Message queue disconnected sender"),
StatusCode::INTERNAL_SERVER_ERROR,
),
}
};
let serve_session = Arc::clone(&self.serve_session);
// Spawn a task to handle the WebSocket connection
tokio::spawn(async move {
if let Err(e) =
handle_websocket_subscription(serve_session, websocket, input_cursor).await
{
log::error!("Error in websocket subscription: {}", e);
}
});
response
}
async fn handle_api_write(&self, request: Request<Body>) -> Response<Body> {
@@ -444,6 +447,113 @@ fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option<PathBuf> {
.map(|path| path.to_owned())
}
/// Handle WebSocket connection for streaming subscription messages
async fn handle_websocket_subscription(
serve_session: Arc<ServeSession>,
websocket: HyperWebsocket,
input_cursor: u32,
) -> anyhow::Result<()> {
let mut websocket = websocket.await?;
let session_id = serve_session.session_id();
let tree_handle = serve_session.tree_handle();
let message_queue = serve_session.message_queue();
log::debug!(
"WebSocket subscription established for session {}",
session_id
);
// Now continuously listen for new messages using select to handle both incoming messages
// and WebSocket control messages concurrently
let mut cursor = input_cursor;
loop {
let receiver = message_queue.subscribe(cursor);
tokio::select! {
// Handle new messages from the message queue
result = receiver => {
match result {
Ok((new_cursor, messages)) => {
if !messages.is_empty() {
let json_message = {
let tree = tree_handle.lock().unwrap();
let api_messages = messages
.into_iter()
.map(|patch| SubscribeMessage::from_patch_update(&tree, patch))
.collect();
let response = SocketPacket {
session_id,
packet_type: SocketPacketType::Messages,
body: SocketPacketBody::Messages(MessagesPacket {
message_cursor: new_cursor,
messages: api_messages,
}),
};
serde_json::to_string(&response)?
};
log::debug!("Sending batch of messages over WebSocket subscription");
if websocket.send(Message::Text(json_message)).await.is_err() {
// Client disconnected
log::debug!("WebSocket subscription closed by client");
break;
}
cursor = new_cursor;
}
}
Err(_) => {
// Message queue disconnected
log::debug!("Message queue disconnected; closing WebSocket subscription");
let _ = websocket.send(Message::Close(None)).await;
break;
}
}
}
// Handle incoming WebSocket messages (ping/pong/close)
msg = websocket.next() => {
match msg {
Some(Ok(Message::Close(_))) => {
log::debug!("WebSocket subscription closed by client");
break;
}
Some(Ok(Message::Ping(data))) => {
// tungstenite handles pong automatically
log::debug!("Received ping: {:?}", data);
}
Some(Ok(Message::Pong(data))) => {
log::debug!("Received pong: {:?}", data);
}
Some(Ok(Message::Text(_))) | Some(Ok(Message::Binary(_))) => {
// Ignore text/binary messages from client for subscription endpoint
// TODO: Use this for bidirectional sync or requesting fallbacks?
log::debug!("Ignoring message from client since we don't use it for anything yet: {:?}", msg);
}
Some(Ok(Message::Frame(_))) => {
// This should never happen according to tungstenite docs
unreachable!();
}
Some(Err(e)) => {
log::error!("WebSocket error: {}", e);
break;
}
None => {
// WebSocket stream ended
log::debug!("WebSocket stream ended");
break;
}
}
}
}
}
Ok(())
}
/// Certain Instances MUST be a child of specific classes. This function
/// tracks that information for the Serialize endpoint.
///

View File

@@ -12,6 +12,7 @@ use rbx_dom_weak::{
Ustr, UstrMap,
};
use serde::{Deserialize, Serialize};
use strum::Display;
use crate::{
session_id::SessionId,
@@ -24,7 +25,7 @@ use crate::{
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Current protocol version, which is required to match.
pub const PROTOCOL_VERSION: u64 = 4;
pub const PROTOCOL_VERSION: u64 = 5;
/// Message returned by Rojo API when a change has occurred.
#[derive(Debug, Serialize, Deserialize)]
@@ -192,15 +193,44 @@ pub struct WriteResponse {
pub session_id: SessionId,
}
/// Response body from /api/subscribe/{cursor}
/// Packet type enum for different websocket message types
#[derive(Debug, Serialize, Deserialize, Display, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum SocketPacketType {
Messages,
// TODO: Can we cleanly use the socket for all communication?
// Serialize,
// RefPatch,
}
/// Body content for messages packet type
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscribeResponse<'a> {
pub session_id: SessionId,
pub struct MessagesPacket<'a> {
pub message_cursor: u32,
pub messages: Vec<SubscribeMessage<'a>>,
}
/// Body content for different packet types
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SocketPacketBody<'a> {
Messages(MessagesPacket<'a>),
// TODO: Can we cleanly use the socket for all communication?
// Serialize(SerializePacket),
// RefPatch(RefPatchPacket<'a>),
}
/// Message content from /api/socket
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SocketPacket<'a> {
pub session_id: SessionId,
pub packet_type: SocketPacketType,
pub body: SocketPacketBody<'a>,
}
/// Response body from /api/open/{id}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@@ -3,7 +3,9 @@ use std::collections::HashMap;
use rbx_dom_weak::types::Ref;
use serde::Serialize;
use librojo::web_api::{Instance, InstanceUpdate, ReadResponse, SubscribeResponse};
use librojo::web_api::{
Instance, InstanceUpdate, MessagesPacket, ReadResponse, SocketPacket, SocketPacketBody,
};
use rojo_insta_ext::RedactionMap;
/// A convenience method to store all of the redactable data from a piece of
@@ -54,7 +56,16 @@ impl<'a> Internable<&'a HashMap<Ref, Instance<'_>>> for Instance<'a> {
}
}
impl Internable<()> for SubscribeResponse<'_> {
impl Internable<()> for SocketPacket<'_> {
fn intern(&self, redactions: &mut RedactionMap, extra: ()) {
redactions.intern(&self.session_id);
match &self.body {
SocketPacketBody::Messages(packet) => packet.intern(redactions, extra),
}
}
}
impl Internable<()> for MessagesPacket<'_> {
fn intern(&self, redactions: &mut RedactionMap, _extra: ()) {
for message in &self.messages {
intern_instance_updates(redactions, &message.updated);

View File

@@ -8,11 +8,14 @@ use std::{
time::Duration,
};
use hyper_tungstenite::tungstenite::{connect, Message};
use rbx_dom_weak::types::Ref;
use tempfile::{tempdir, TempDir};
use librojo::web_api::{ReadResponse, SerializeResponse, ServerInfoResponse, SubscribeResponse};
use librojo::web_api::{
ReadResponse, SerializeResponse, ServerInfoResponse, SocketPacket, SocketPacketType,
};
use rojo_insta_ext::RedactionMap;
use crate::rojo_test::io_util::{
@@ -173,13 +176,54 @@ impl TestServeSession {
Ok(serde_json::from_value(value).expect("Server returned malformed response"))
}
pub fn get_api_subscribe(
pub fn get_api_socket_packet(
&self,
packet_type: SocketPacketType,
cursor: u32,
) -> Result<SubscribeResponse<'static>, reqwest::Error> {
let url = format!("http://localhost:{}/api/subscribe/{}", self.port, cursor);
) -> Result<SocketPacket<'static>, Box<dyn std::error::Error>> {
let url = format!("ws://localhost:{}/api/socket/{}", self.port, cursor);
reqwest::blocking::get(url)?.json()
let (mut socket, _response) = connect(url)?;
// Wait for messages with a timeout
let timeout = Duration::from_secs(10);
let start = std::time::Instant::now();
loop {
if start.elapsed() > timeout {
return Err("Timeout waiting for packet from WebSocket".into());
}
match socket.read() {
Ok(Message::Text(text)) => {
let packet: SocketPacket = serde_json::from_str(&text)?;
if packet.packet_type != packet_type {
continue;
}
// Close the WebSocket connection now that we got what we were waiting for
let _ = socket.close(None);
return Ok(packet);
}
Ok(Message::Close(_)) => {
return Err("WebSocket closed before receiving messages".into());
}
Ok(_) => {
// Ignore other message types (ping, pong, binary)
continue;
}
Err(hyper_tungstenite::tungstenite::Error::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock =>
{
// No data available yet, sleep a bit and try again
thread::sleep(Duration::from_millis(100));
continue;
}
Err(e) => {
return Err(e.into());
}
}
}
}
pub fn get_api_serialize(&self, ids: &[Ref]) -> Result<SerializeResponse, reqwest::Error> {

View File

@@ -8,6 +8,8 @@ use crate::rojo_test::{
serve_util::{run_serve_test, serialize_to_xml_model},
};
use librojo::web_api::SocketPacketType;
#[test]
fn empty() {
run_serve_test("empty", |session, mut redactions| {
@@ -42,10 +44,12 @@ fn scripts() {
fs::write(session.path().join("src/foo.lua"), "Updated foo!").unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"scripts_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -74,10 +78,12 @@ fn add_folder() {
fs::create_dir(session.path().join("src/my-new-folder")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"add_folder_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -104,10 +110,12 @@ fn remove_file() {
fs::remove_file(session.path().join("src/hello.txt")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"remove_file_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -134,10 +142,12 @@ fn edit_init() {
fs::write(session.path().join("src/init.lua"), b"-- Edited contents").unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"edit_init_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -180,10 +190,12 @@ fn move_folder_of_stuff() {
// will fail otherwise.
fs::rename(stuff_path, session.path().join("src/new-stuff")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"move_folder_of_stuff_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -214,10 +226,12 @@ fn empty_json_model() {
)
.unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"empty_json_model_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -245,10 +259,12 @@ fn add_optional_folder() {
fs::create_dir(session.path().join("create-later")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"add_optional_folder_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -437,10 +453,12 @@ fn ref_properties() {
)
.unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"ref_properties_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -467,10 +485,12 @@ fn ref_properties_remove() {
fs::remove_file(session.path().join("src/target.model.json")).unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"ref_properties_remove_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -529,10 +549,12 @@ fn ref_properties_patch_update() {
)
.unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"ref_properties_patch_update_subscribe",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();
@@ -581,10 +603,12 @@ fn model_pivot_migration() {
)
.unwrap();
let subscribe_response = session.get_api_subscribe(0).unwrap();
let socket_packet = session
.get_api_socket_packet(SocketPacketType::Messages, 0)
.unwrap();
assert_yaml_snapshot!(
"model_pivot_migration_all",
subscribe_response.intern_and_redact(&mut redactions, ())
socket_packet.intern_and_redact(&mut redactions, ())
);
let read_response = session.get_api_read(root_id).unwrap();