local StudioService = game:GetService("StudioService") local RunService = game:GetService("RunService") local ChangeHistoryService = game:GetService("ChangeHistoryService") local SerializationService = game:GetService("SerializationService") local Selection = game:GetService("Selection") local Packages = script.Parent.Parent.Packages local Log = require(Packages.Log) local Fmt = require(Packages.Fmt) local t = require(Packages.t) local Promise = require(Packages.Promise) local Timer = require(script.Parent.Timer) local ChangeBatcher = require(script.Parent.ChangeBatcher) local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate) local InstanceMap = require(script.Parent.InstanceMap) local PatchSet = require(script.Parent.PatchSet) local Reconciler = require(script.Parent.Reconciler) local strict = require(script.Parent.strict) local Settings = require(script.Parent.Settings) local Status = strict("Session.Status", { NotStarted = "NotStarted", Connecting = "Connecting", Connected = "Connected", Disconnected = "Disconnected", }) local function debugPatch(object) return Fmt.debugify(object, function(patch, output) output:writeLine("Patch {{") output:indent() for removed in ipairs(patch.removed) do output:writeLine("Remove ID {}", removed) end for id, added in pairs(patch.added) do output:writeLine("Add ID {} {:#?}", id, added) end for _, updated in ipairs(patch.updated) do output:writeLine("Update ID {} {:#?}", updated.id, updated) end output:unindent() output:write("}") end) end local ServeSession = {} ServeSession.__index = ServeSession ServeSession.Status = Status local validateServeOptions = t.strictInterface({ apiContext = t.table, twoWaySync = t.boolean, }) function ServeSession.new(options) assert(validateServeOptions(options)) -- Declare self ahead of time to capture it in a closure local self local function onInstanceChanged(instance, propertyName) if not self.__twoWaySync then return end self.__changeBatcher:add(instance, propertyName) end local function onChangesFlushed(patch) self.__apiContext:write(patch) end local instanceMap = InstanceMap.new(onInstanceChanged) local changeBatcher = ChangeBatcher.new(instanceMap, onChangesFlushed) local reconciler = Reconciler.new(instanceMap) local connections = {} local connection = StudioService:GetPropertyChangedSignal("ActiveScript"):Connect(function() local activeScript = StudioService.ActiveScript if activeScript ~= nil then self:__onActiveScriptChanged(activeScript) end end) table.insert(connections, connection) self = { __status = Status.NotStarted, __apiContext = options.apiContext, __twoWaySync = options.twoWaySync, __reconciler = reconciler, __instanceMap = instanceMap, __changeBatcher = changeBatcher, __statusChangedCallback = nil, __connections = connections, __precommitCallbacks = {}, __postcommitCallbacks = {}, } setmetatable(self, ServeSession) return self end function ServeSession:__fmtDebug(output) output:writeLine("ServeSession {{") output:indent() output:writeLine("API Context: {:#?}", self.__apiContext) output:writeLine("Instances: {:#?}", self.__instanceMap) output:unindent() output:write("}") end function ServeSession:getStatus() return self.__status end function ServeSession:onStatusChanged(callback) self.__statusChangedCallback = callback end function ServeSession:setConfirmCallback(callback) self.__userConfirmCallback = callback end --[=[ Hooks a function to run before patch application. The provided function is called with the incoming patch and an InstanceMap as parameters. ]=] function ServeSession:hookPrecommit(callback) table.insert(self.__precommitCallbacks, callback) Log.trace("Added precommit callback: {}", callback) return function() -- Remove the callback from the list for i, cb in self.__precommitCallbacks do if cb == callback then table.remove(self.__precommitCallbacks, i) Log.trace("Removed precommit callback: {}", callback) break end end end end --[=[ Hooks a function to run after patch application. The provided function is called with the applied patch, the current InstanceMap, and a PatchSet containing any unapplied changes. ]=] function ServeSession:hookPostcommit(callback) table.insert(self.__postcommitCallbacks, callback) Log.trace("Added postcommit callback: {}", callback) return function() -- Remove the callback from the list for i, cb in self.__postcommitCallbacks do if cb == callback then table.remove(self.__postcommitCallbacks, i) Log.trace("Removed postcommit callback: {}", callback) break end end end end function ServeSession:start() self:__setStatus(Status.Connecting) self.__apiContext :connect() :andThen(function(serverInfo) return self:__initialSync(serverInfo):andThen(function() self:__setStatus(Status.Connected, serverInfo.projectName) self:__applyGameAndPlaceId(serverInfo) return self:__mainSyncLoop() end) end) :catch(function(err) if self.__status ~= Status.Disconnected then self:__stopInternal(err) end end) end function ServeSession:stop() self:__stopInternal() end function ServeSession:__applyGameAndPlaceId(serverInfo) if serverInfo.gameId ~= nil then game:SetUniverseId(serverInfo.gameId) end if serverInfo.placeId ~= nil then game:SetPlaceId(serverInfo.placeId) end end function ServeSession:__onActiveScriptChanged(activeScript) if not Settings:get("openScriptsExternally") then Log.trace("Not opening script {} because feature not enabled.", activeScript) return end if self.__status ~= Status.Connected then Log.trace("Not opening script {} because session is not connected.", activeScript) return end local scriptId = self.__instanceMap.fromInstances[activeScript] if scriptId == nil then Log.trace("Not opening script {} because it is not known by Rojo.", activeScript) return end Log.debug("Trying to open script {} externally...", activeScript) -- Force-close the script inside Studio... with a small delay in the middle -- to prevent Studio from crashing. spawn(function() local existingParent = activeScript.Parent activeScript.Parent = nil for _ = 1, 3 do RunService.Heartbeat:Wait() end activeScript.Parent = existingParent end) -- Notify the Rojo server to open this script self.__apiContext:open(scriptId) end function ServeSession:__replaceInstances(idList) if #idList == 0 then return true, PatchSet.newEmpty() end -- It would be annoying if selection went away, so we try to preserve it. local selection = Selection:Get() local selectionMap = {} for i, instance in selection do selectionMap[instance] = i end -- TODO: Should we do this in multiple requests so we can more granularly mark failures? local modelSuccess, replacements = self.__apiContext :serialize(idList) :andThen(function(response) Log.debug("Deserializing results from serialize endpoint") local objects = SerializationService:DeserializeInstancesAsync(response.modelContents) if not objects[1] then return Promise.reject("Serialize endpoint did not deserialize into any Instances") end if #objects[1]:GetChildren() ~= #idList then return Promise.reject("Serialize endpoint did not return the correct number of Instances") end local instanceMap = {} for _, item in objects[1]:GetChildren() do instanceMap[item.Name] = item.Value end return instanceMap end) :await() local refSuccess, refPatch = self.__apiContext :refPatch(idList) :andThen(function(response) return response.patch end) :await() if not (modelSuccess and refSuccess) then return false end for id, replacement in replacements do local oldInstance = self.__instanceMap.fromIds[id] self.__instanceMap:insert(id, replacement) Log.trace("Swapping Instance {} out via api/models/ endpoint", id) local oldParent = oldInstance.Parent for _, child in oldInstance:GetChildren() do child.Parent = replacement end replacement.Parent = oldParent -- ChangeHistoryService doesn't like it if an Instance has been -- Destroyed. So, we have to accept the potential memory hit and -- just set the parent to `nil`. oldInstance.Parent = nil if selectionMap[oldInstance] then -- This is a bit funky, but it saves the order of Selection -- which might matter for some use cases. selection[selectionMap[oldInstance]] = replacement end end local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, refPatch) if patchApplySuccess then Selection:Set(selection) return true, unappliedPatch else error(unappliedPatch) end end function ServeSession:__applyPatch(patch) local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us") local historyRecording = ChangeHistoryService:TryBeginRecording("Rojo: Patch " .. patchTimestamp) if not historyRecording then -- There can only be one recording at a time Log.debug("Failed to begin history recording for " .. patchTimestamp .. ". Another recording is in progress.") end Timer.start("precommitCallbacks") -- Precommit callbacks must be serial in order to obey the contract that -- they execute before commit for _, callback in self.__precommitCallbacks do local success, err = pcall(callback, patch, self.__instanceMap) if not success then Log.warn("Precommit hook errored: {}", err) end end Timer.stop() local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, patch) if not patchApplySuccess then if historyRecording then ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit) end -- This might make a weird stack trace but the only way applyPatch can -- fail is if a bug occurs so it's probably fine. error(unappliedPatch) end if PatchSet.isEmpty(unappliedPatch) then if historyRecording then ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit) end return end local addedIdList = PatchSet.addedIdList(unappliedPatch) local updatedIdList = PatchSet.updatedIdList(unappliedPatch) local actualUnappliedPatches = PatchSet.newEmpty() if Settings:get("enableSyncFallback") then Log.debug("ServeSession:__replaceInstances(unappliedPatch.added)") Timer.start("ServeSession:__replaceInstances(unappliedPatch.added)") local addSuccess, unappliedAddedRefs = self:__replaceInstances(addedIdList) Timer.stop() Log.debug("ServeSession:__replaceInstances(unappliedPatch.updated)") Timer.start("ServeSession:__replaceInstances(unappliedPatch.updated)") local updateSuccess, unappliedUpdateRefs = self:__replaceInstances(updatedIdList) Timer.stop() if addSuccess then table.clear(unappliedPatch.added) PatchSet.assign(actualUnappliedPatches, unappliedAddedRefs) end if updateSuccess then table.clear(unappliedPatch.updated) PatchSet.assign(actualUnappliedPatches, unappliedUpdateRefs) end else Log.debug("Skipping ServeSession:__replaceInstances because of setting") end PatchSet.assign(actualUnappliedPatches, unappliedPatch) if not PatchSet.isEmpty(actualUnappliedPatches) then Log.debug( "Could not apply all changes requested by the Rojo server:\n{}", PatchSet.humanSummary(self.__instanceMap, unappliedPatch) ) end Timer.start("postcommitCallbacks") -- Postcommit callbacks can be called with spawn since regardless of firing order, they are -- guaranteed to be called after the commit for _, callback in self.__postcommitCallbacks do task.spawn(function() local success, err = pcall(callback, patch, self.__instanceMap, actualUnappliedPatches) if not success then Log.warn("Postcommit hook errored: {}", err) end end) end Timer.stop() if historyRecording then ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit) end end function ServeSession:__initialSync(serverInfo) return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody) -- Tell the API Context that we're up-to-date with the version of -- the tree defined in this response. self.__apiContext:setMessageCursor(readResponseBody.messageCursor) -- For any instances that line up with the Rojo server's view, start -- tracking them in the reconciler. Log.trace("Matching existing Roblox instances to Rojo IDs") self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game) -- Calculate the initial patch to apply to the DataModel to catch us -- up to what Rojo thinks the place should look like. Log.trace("Computing changes that plugin needs to make to catch up to server...") local success, catchUpPatch = self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game) if not success then Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch) end for _, update in catchUpPatch.updated do if update.id == self.__instanceMap.fromInstances[game] and update.changedClassName ~= nil then -- Non-place projects will try to update the classname of game from DataModel to -- something like Folder, ModuleScript, etc. This would fail, so we exit with a clear -- message instead of crashing. return Promise.reject( "Cannot sync a model as a place." .. "\nEnsure Rojo is serving a project file that has a DataModel at the root of its tree and try again." .. "\nSee project file docs: https://rojo.space/docs/v7/project-format/" ) end end Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch)) local userDecision = "Accept" if self.__userConfirmCallback ~= nil then userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo) end if userDecision == "Abort" then return Promise.reject("Aborted Rojo sync operation") elseif userDecision == "Reject" then if not self.__twoWaySync then return Promise.reject("Cannot reject sync operation without two-way sync enabled") end -- The user wants their studio DOM to write back to their Rojo DOM -- so we will reverse the patch and send it back local inversePatch = PatchSet.newEmpty() -- Send back the current properties for _, change in catchUpPatch.updated do local instance = self.__instanceMap.fromIds[change.id] if not instance then continue end local update = encodePatchUpdate(instance, change.id, change.changedProperties) table.insert(inversePatch.updated, update) end -- Add the removed instances back to Rojo -- selene:allow(empty_if, unused_variable, empty_loop) for _, instance in catchUpPatch.removed do -- TODO: Generate ID for our instance and add it to inversePatch.added end -- Remove the additions we've rejected for id, _change in catchUpPatch.added do table.insert(inversePatch.removed, id) end return self.__apiContext:write(inversePatch) elseif userDecision == "Accept" then self:__applyPatch(catchUpPatch) return Promise.resolve() else return Promise.reject("Invalid user decision: " .. userDecision) end 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() self.__instanceMap:stop() self.__changeBatcher:stop() for _, connection in ipairs(self.__connections) do connection:Disconnect() end self.__connections = {} end function ServeSession:__setStatus(status, detail) self.__status = status if self.__statusChangedCallback ~= nil then self.__statusChangedCallback(status, detail) end end return ServeSession