@el4cteo/rbx-studio-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/bridge/rpc.js +243 -0
- package/dist/bridge/rpc.js.map +1 -0
- package/dist/bridge/server.js +281 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/index.js +104 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/apidump.js +269 -0
- package/dist/lib/apidump.js.map +1 -0
- package/dist/lib/errors.js +38 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/format.js +191 -0
- package/dist/lib/format.js.map +1 -0
- package/dist/lib/pluginbuild.js +83 -0
- package/dist/lib/pluginbuild.js.map +1 -0
- package/dist/lib/png.js +84 -0
- package/dist/lib/png.js.map +1 -0
- package/dist/lib/protocol.js +22 -0
- package/dist/lib/protocol.js.map +1 -0
- package/dist/lib/tool.js +27 -0
- package/dist/lib/tool.js.map +1 -0
- package/dist/resources.js +70 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools/api.js +78 -0
- package/dist/tools/api.js.map +1 -0
- package/dist/tools/character.js +94 -0
- package/dist/tools/character.js.map +1 -0
- package/dist/tools/debug.js +211 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/device.js +74 -0
- package/dist/tools/device.js.map +1 -0
- package/dist/tools/discover.js +217 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/exec.js +191 -0
- package/dist/tools/exec.js.map +1 -0
- package/dist/tools/input.js +96 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/instances.js +261 -0
- package/dist/tools/instances.js.map +1 -0
- package/dist/tools/perf.js +367 -0
- package/dist/tools/perf.js.map +1 -0
- package/dist/tools/playtest.js +153 -0
- package/dist/tools/playtest.js.map +1 -0
- package/dist/tools/screenshot.js +75 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/scripts.js +316 -0
- package/dist/tools/scripts.js.map +1 -0
- package/dist/tools/session.js +152 -0
- package/dist/tools/session.js.map +1 -0
- package/dist/tools/world.js +281 -0
- package/dist/tools/world.js.map +1 -0
- package/package.json +62 -0
- package/plugin/default.project.json +6 -0
- package/plugin/src/Config.luau +59 -0
- package/plugin/src/Console.luau +657 -0
- package/plugin/src/Context.luau +35 -0
- package/plugin/src/Dispatch.luau +90 -0
- package/plugin/src/Editor.luau +142 -0
- package/plugin/src/Emulation.luau +151 -0
- package/plugin/src/LogBuffer.luau +277 -0
- package/plugin/src/Net.luau +102 -0
- package/plugin/src/Paths.luau +255 -0
- package/plugin/src/Phrase.luau +465 -0
- package/plugin/src/Png.luau +238 -0
- package/plugin/src/Scope.luau +78 -0
- package/plugin/src/ScriptEdit.luau +100 -0
- package/plugin/src/Serialize.luau +287 -0
- package/plugin/src/TextEdit.luau +296 -0
- package/plugin/src/Transport.luau +328 -0
- package/plugin/src/Undo.luau +72 -0
- package/plugin/src/Visuals.luau +710 -0
- package/plugin/src/handlers/Api.luau +242 -0
- package/plugin/src/handlers/Assets.luau +145 -0
- package/plugin/src/handlers/Capture.luau +187 -0
- package/plugin/src/handlers/Character.luau +361 -0
- package/plugin/src/handlers/Debug.luau +391 -0
- package/plugin/src/handlers/Device.luau +119 -0
- package/plugin/src/handlers/Discover.luau +289 -0
- package/plugin/src/handlers/Exec.luau +270 -0
- package/plugin/src/handlers/Geometry.luau +261 -0
- package/plugin/src/handlers/Input.luau +287 -0
- package/plugin/src/handlers/Instances.luau +389 -0
- package/plugin/src/handlers/Perf.luau +645 -0
- package/plugin/src/handlers/Playtest.luau +205 -0
- package/plugin/src/handlers/Scripts.luau +387 -0
- package/plugin/src/handlers/Session.luau +168 -0
- package/plugin/src/handlers/Viewport.luau +302 -0
- package/plugin/src/handlers/World.luau +176 -0
- package/plugin/src/init.server.luau +317 -0
- package/scripts/build-plugin.mjs +157 -0
- package/scripts/check-plugin.mjs +97 -0
- package/scripts/install-plugin.mjs +39 -0
- package/scripts/latency.mjs +201 -0
- package/scripts/locate-luau.mjs +51 -0
- package/scripts/sourcemap.mjs +58 -0
- package/scripts/test-plugin.mjs +82 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The edit engine: pure text in, pure text out.
|
|
4
|
+
|
|
5
|
+
Kept free of Roblox APIs on purpose. Getting a line splice or a find/replace
|
|
6
|
+
subtly wrong is the one bug class here that produces no error at all -- it
|
|
7
|
+
just writes the wrong code into the user's game -- so this logic is unit
|
|
8
|
+
tested outside Studio (see tests/textedit.luau). Everything that actually
|
|
9
|
+
touches the DataModel lives in handlers/Scripts.luau.
|
|
10
|
+
]]
|
|
11
|
+
|
|
12
|
+
local Dispatch = require(script.Parent.Dispatch)
|
|
13
|
+
|
|
14
|
+
local TextEdit = {}
|
|
15
|
+
|
|
16
|
+
export type Edit = {
|
|
17
|
+
path: string,
|
|
18
|
+
find: string?,
|
|
19
|
+
replace: string?,
|
|
20
|
+
replaceAll: boolean?,
|
|
21
|
+
startLine: number?,
|
|
22
|
+
endLine: number?,
|
|
23
|
+
replacement: string?,
|
|
24
|
+
source: string?,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type LineEdit = {
|
|
28
|
+
startLine: number,
|
|
29
|
+
endLine: number,
|
|
30
|
+
replacement: string,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
--[[
|
|
34
|
+
Splits source into lines. A trailing newline does not produce a final empty
|
|
35
|
+
line, so a round trip through `toLines`/`concat` leaves the text unchanged.
|
|
36
|
+
]]
|
|
37
|
+
function TextEdit.toLines(source: string): { string }
|
|
38
|
+
local lines: { string } = {}
|
|
39
|
+
for line in string.gmatch(source .. "\n", "([^\n]*)\n") do
|
|
40
|
+
table.insert(lines, line)
|
|
41
|
+
end
|
|
42
|
+
-- gmatch on the sentinel newline yields one trailing empty entry.
|
|
43
|
+
if #lines > 0 and lines[#lines] == "" then
|
|
44
|
+
table.remove(lines)
|
|
45
|
+
end
|
|
46
|
+
return lines
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
--[[
|
|
50
|
+
Literal (non-pattern) find and replace.
|
|
51
|
+
|
|
52
|
+
`string.gsub` would be shorter but treats `%` as an escape in both the needle
|
|
53
|
+
and the replacement, so a single `%d` in the user's code silently changes
|
|
54
|
+
meaning. Agent-supplied text has to be taken exactly as given.
|
|
55
|
+
]]
|
|
56
|
+
function TextEdit.replaceLiteral(text: string, needle: string, replacement: string, all: boolean): string
|
|
57
|
+
local out: { string } = {}
|
|
58
|
+
local index = 1
|
|
59
|
+
|
|
60
|
+
while true do
|
|
61
|
+
local from, to = string.find(text, needle, index, true)
|
|
62
|
+
if not from or not to then
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
table.insert(out, string.sub(text, index, from - 1))
|
|
66
|
+
table.insert(out, replacement)
|
|
67
|
+
index = to + 1
|
|
68
|
+
if not all then
|
|
69
|
+
break
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
table.insert(out, string.sub(text, index))
|
|
74
|
+
return table.concat(out)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
function TextEdit.countLiteral(text: string, needle: string): number
|
|
78
|
+
local index = 1
|
|
79
|
+
local count = 0
|
|
80
|
+
while true do
|
|
81
|
+
local from, to = string.find(text, needle, index, true)
|
|
82
|
+
if not from or not to then
|
|
83
|
+
return count
|
|
84
|
+
end
|
|
85
|
+
count += 1
|
|
86
|
+
index = to + 1
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
--[[
|
|
91
|
+
Rejects a malformed edit before anything is written.
|
|
92
|
+
|
|
93
|
+
Validation is a separate pass on purpose: a batch that turns out to be
|
|
94
|
+
nonsense should fail with nothing touched and no undo entry, rather than
|
|
95
|
+
applying three scripts' worth of changes and then rolling them back.
|
|
96
|
+
]]
|
|
97
|
+
function TextEdit.validate(edit: Edit, position: number)
|
|
98
|
+
if typeof(edit.path) ~= "string" or edit.path == "" then
|
|
99
|
+
Dispatch.fail(
|
|
100
|
+
"BAD_PARAMS",
|
|
101
|
+
string.format("edits[%d] has no `path`.", position),
|
|
102
|
+
'Every edit needs the path of the script to change, e.g. "ServerScriptService.Main".'
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
local modes = 0
|
|
107
|
+
if edit.source ~= nil then
|
|
108
|
+
modes += 1
|
|
109
|
+
end
|
|
110
|
+
if edit.startLine ~= nil then
|
|
111
|
+
modes += 1
|
|
112
|
+
end
|
|
113
|
+
if edit.find ~= nil then
|
|
114
|
+
modes += 1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
if modes == 0 then
|
|
118
|
+
Dispatch.fail(
|
|
119
|
+
"BAD_PARAMS",
|
|
120
|
+
string.format("edits[%d] (%s) says what to edit but not how.", position, edit.path),
|
|
121
|
+
"Give exactly one of: `find` + `replace`, `startLine` (+ `endLine`) + "
|
|
122
|
+
.. "`replacement`, or `source` to replace the whole script."
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
if modes > 1 then
|
|
126
|
+
Dispatch.fail(
|
|
127
|
+
"BAD_PARAMS",
|
|
128
|
+
string.format("edits[%d] (%s) combines more than one edit mode.", position, edit.path),
|
|
129
|
+
"Use `find`, `startLine` or `source` -- one per edit. Send several entries "
|
|
130
|
+
.. "in the array if you need several changes."
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if edit.find ~= nil then
|
|
135
|
+
if edit.find == "" then
|
|
136
|
+
Dispatch.fail(
|
|
137
|
+
"BAD_PARAMS",
|
|
138
|
+
string.format("edits[%d] (%s) has an empty `find`.", position, edit.path)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
if typeof(edit.replace) ~= "string" then
|
|
142
|
+
Dispatch.fail(
|
|
143
|
+
"BAD_PARAMS",
|
|
144
|
+
string.format("edits[%d] (%s) has `find` but no `replace`.", position, edit.path),
|
|
145
|
+
"Pass an empty `replace` to delete the matched text."
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
elseif edit.startLine ~= nil then
|
|
149
|
+
if typeof(edit.replacement) ~= "string" then
|
|
150
|
+
Dispatch.fail(
|
|
151
|
+
"BAD_PARAMS",
|
|
152
|
+
string.format("edits[%d] (%s) has `startLine` but no `replacement`.", position, edit.path),
|
|
153
|
+
"Pass an empty `replacement` to delete those lines."
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
--[[
|
|
160
|
+
Applies every edit destined for one script to its source text. `path` is used
|
|
161
|
+
only for error messages.
|
|
162
|
+
|
|
163
|
+
Order is fixed rather than left to the caller: line edits run first, bottom
|
|
164
|
+
up, so all their numbers refer to the file as the agent read it instead of
|
|
165
|
+
drifting as earlier edits resize it. Find/replace edits then run in the given
|
|
166
|
+
order against the result, which is safe because they address text, not
|
|
167
|
+
positions.
|
|
168
|
+
]]
|
|
169
|
+
function TextEdit.apply(path: string, source: string, edits: { Edit }): string
|
|
170
|
+
for _, edit in edits do
|
|
171
|
+
if edit.source ~= nil then
|
|
172
|
+
if #edits > 1 then
|
|
173
|
+
Dispatch.fail(
|
|
174
|
+
"CONFLICTING_EDITS",
|
|
175
|
+
string.format("%s gets a whole-script `source` edit plus %d other edit(s).", path, #edits - 1),
|
|
176
|
+
"A `source` edit replaces the entire script, so any other edit to the "
|
|
177
|
+
.. "same script would be discarded. Send it on its own."
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
return edit.source :: string
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
local lineEdits: { LineEdit } = {}
|
|
185
|
+
local findEdits: { Edit } = {}
|
|
186
|
+
for _, edit in edits do
|
|
187
|
+
local start = edit.startLine
|
|
188
|
+
if start ~= nil then
|
|
189
|
+
table.insert(lineEdits, {
|
|
190
|
+
startLine = start,
|
|
191
|
+
endLine = tonumber(edit.endLine) or start,
|
|
192
|
+
replacement = edit.replacement :: string,
|
|
193
|
+
})
|
|
194
|
+
else
|
|
195
|
+
table.insert(findEdits, edit)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
local lines = TextEdit.toLines(source)
|
|
200
|
+
|
|
201
|
+
if #lineEdits > 0 then
|
|
202
|
+
table.sort(lineEdits, function(a, b)
|
|
203
|
+
return a.startLine > b.startLine
|
|
204
|
+
end)
|
|
205
|
+
|
|
206
|
+
-- Every range is checked against the file as the agent read it, before any
|
|
207
|
+
-- of them is applied. Validating inside the apply loop instead would test
|
|
208
|
+
-- later edits against a file the earlier ones had already resized, which
|
|
209
|
+
-- reports an invented line count and hides genuine overlaps behind a
|
|
210
|
+
-- spurious BAD_RANGE.
|
|
211
|
+
local lineCount = #lines
|
|
212
|
+
for index, edit in lineEdits do
|
|
213
|
+
if edit.startLine < 1 or edit.startLine > lineCount + 1 then
|
|
214
|
+
Dispatch.fail(
|
|
215
|
+
"BAD_RANGE",
|
|
216
|
+
string.format("startLine %d is outside %s, which has %d lines.", edit.startLine, path, lineCount),
|
|
217
|
+
"Call script_read on this script first. Line numbers shift after every edit."
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
if edit.endLine < edit.startLine - 1 or edit.endLine > lineCount then
|
|
221
|
+
Dispatch.fail(
|
|
222
|
+
"BAD_RANGE",
|
|
223
|
+
string.format(
|
|
224
|
+
"endLine %d is invalid for %s (%d lines): it must be at least startLine - 1 and at most the line count.",
|
|
225
|
+
edit.endLine,
|
|
226
|
+
path,
|
|
227
|
+
lineCount
|
|
228
|
+
),
|
|
229
|
+
"Set endLine one below startLine to insert without replacing anything."
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
-- Sorted descending, so the previous entry is the next range up the
|
|
234
|
+
-- file. Overlapping ranges would make the result depend on apply order.
|
|
235
|
+
local previous = lineEdits[index - 1]
|
|
236
|
+
if previous and edit.endLine >= previous.startLine then
|
|
237
|
+
Dispatch.fail(
|
|
238
|
+
"CONFLICTING_EDITS",
|
|
239
|
+
string.format(
|
|
240
|
+
"Two edits to %s cover overlapping lines (%d-%d and %d-%d).",
|
|
241
|
+
path,
|
|
242
|
+
edit.startLine,
|
|
243
|
+
edit.endLine,
|
|
244
|
+
previous.startLine,
|
|
245
|
+
previous.endLine
|
|
246
|
+
),
|
|
247
|
+
"Merge them into one edit spanning the whole range."
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
for _, edit in lineEdits do
|
|
253
|
+
local result: { string } = {}
|
|
254
|
+
table.move(lines, 1, edit.startLine - 1, 1, result)
|
|
255
|
+
for _, line in TextEdit.toLines(edit.replacement) do
|
|
256
|
+
table.insert(result, line)
|
|
257
|
+
end
|
|
258
|
+
if edit.endLine < #lines then
|
|
259
|
+
table.move(lines, edit.endLine + 1, #lines, #result + 1, result)
|
|
260
|
+
end
|
|
261
|
+
lines = result
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
local text = table.concat(lines, "\n")
|
|
266
|
+
|
|
267
|
+
for _, edit in findEdits do
|
|
268
|
+
local needle = edit.find :: string
|
|
269
|
+
local occurrences = TextEdit.countLiteral(text, needle)
|
|
270
|
+
|
|
271
|
+
if occurrences == 0 then
|
|
272
|
+
Dispatch.fail(
|
|
273
|
+
"NO_MATCH",
|
|
274
|
+
string.format("The `find` text does not appear in %s.", path),
|
|
275
|
+
"Call script_read on the script and copy the text exactly, including "
|
|
276
|
+
.. "indentation -- the match is literal and whitespace-sensitive."
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
-- Refusing an ambiguous match is the point: silently taking the first of
|
|
280
|
+
-- five identical lines is how an agent edits the wrong function.
|
|
281
|
+
if occurrences > 1 and edit.replaceAll ~= true then
|
|
282
|
+
Dispatch.fail(
|
|
283
|
+
"AMBIGUOUS_EDIT",
|
|
284
|
+
string.format("The `find` text appears %d times in %s.", occurrences, path),
|
|
285
|
+
"Include surrounding lines so the text is unique, or set "
|
|
286
|
+
.. "`replaceAll` to change every occurrence."
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
text = TextEdit.replaceLiteral(text, needle, edit.replace :: string, edit.replaceAll == true)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
return text
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
return TextEdit
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Command channel between the bridge and this Studio instance.
|
|
4
|
+
|
|
5
|
+
Preferred path is a single Server-Sent Events stream opened with
|
|
6
|
+
`HttpService:CreateWebStreamClient`, which pushes commands the moment they are
|
|
7
|
+
issued. Every other Roblox Studio MCP server long-polls on a fixed interval,
|
|
8
|
+
which adds half the poll period to every single call and keeps a request in
|
|
9
|
+
flight forever; a stream costs nothing while idle.
|
|
10
|
+
|
|
11
|
+
Studio allows only 4 concurrent web stream connections and closes them after
|
|
12
|
+
30 minutes, so exactly one stream is held and reconnects are treated as
|
|
13
|
+
routine. Where web streams are unavailable (older Studio builds, or the 4
|
|
14
|
+
slots being taken by other plugins) the same command loop runs over long-poll
|
|
15
|
+
instead, and neither the server nor the handlers can tell the difference.
|
|
16
|
+
]]
|
|
17
|
+
|
|
18
|
+
local HttpService = game:GetService("HttpService")
|
|
19
|
+
|
|
20
|
+
local Config = require(script.Parent.Config)
|
|
21
|
+
local Context = require(script.Parent.Context)
|
|
22
|
+
local Net = require(script.Parent.Net)
|
|
23
|
+
|
|
24
|
+
local Transport = {}
|
|
25
|
+
|
|
26
|
+
export type Status = "disconnected" | "connecting" | "connected"
|
|
27
|
+
|
|
28
|
+
type Callbacks = {
|
|
29
|
+
onCommand: (id: string, op: string, params: { [string]: any }?) -> (),
|
|
30
|
+
onStatus: (status: Status, detail: string?) -> (),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
local running = false
|
|
34
|
+
local studioId: string = ""
|
|
35
|
+
local activeStream: any = nil
|
|
36
|
+
local mode: "sse" | "poll" = "sse"
|
|
37
|
+
|
|
38
|
+
--[[
|
|
39
|
+
Forces the long-poll path even where streaming is available.
|
|
40
|
+
|
|
41
|
+
Two reasons it is worth being able to ask for the slower transport. Studio
|
|
42
|
+
caps SSE at four concurrent connections for the whole process, shared with
|
|
43
|
+
every other plugin the user has installed, so a session can find itself
|
|
44
|
+
unable to open a stream through no fault of its own -- and being able to say
|
|
45
|
+
"just poll" beats discovering that as an intermittent failure to connect.
|
|
46
|
+
|
|
47
|
+
The other is honesty: this project's headline claim is that pushing beats
|
|
48
|
+
polling, and a claim like that should be checkable by whoever doubts it, on
|
|
49
|
+
their own machine, without patching the source to see the other number.
|
|
50
|
+
]]
|
|
51
|
+
local forcePoll = false
|
|
52
|
+
|
|
53
|
+
--[[
|
|
54
|
+
True when this Studio build exposes web streams. Guarded rather than assumed:
|
|
55
|
+
the API shipped in August 2025 and users on older builds must still work.
|
|
56
|
+
]]
|
|
57
|
+
local function supportsWebStream(): boolean
|
|
58
|
+
if typeof((HttpService :: any).CreateWebStreamClient) ~= "function" then
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
local ok, enumItem = pcall(function()
|
|
62
|
+
return (Enum :: any).WebStreamClientType.SSE
|
|
63
|
+
end)
|
|
64
|
+
return ok and enumItem ~= nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
local function identity(transport: string): { [string]: any }
|
|
68
|
+
return {
|
|
69
|
+
studioId = studioId,
|
|
70
|
+
placeName = if game.Name ~= "" then game.Name else "Untitled place",
|
|
71
|
+
placeId = game.PlaceId,
|
|
72
|
+
pluginVersion = Config.PLUGIN_VERSION,
|
|
73
|
+
buildId = Config.BUILD_ID,
|
|
74
|
+
transport = transport,
|
|
75
|
+
-- Announced at the handshake rather than left for a later status call.
|
|
76
|
+
-- The ambiguity error that forces someone to choose between two rows
|
|
77
|
+
-- with the same place name is raised before any such call could have
|
|
78
|
+
-- run, so without this it lists them with nothing to choose between.
|
|
79
|
+
context = Context.of(),
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
--[[
|
|
84
|
+
Parses one SSE payload. `MessageReceived` hands over the event data, but the
|
|
85
|
+
exact framing is not contractual, so a stray `data:` prefix is tolerated and
|
|
86
|
+
comment/keepalive lines are dropped.
|
|
87
|
+
]]
|
|
88
|
+
local function parseFrame(message: string): { [string]: any }?
|
|
89
|
+
local text = (string.gsub(message, "^%s+", ""))
|
|
90
|
+
if text == "" or string.sub(text, 1, 1) == ":" then
|
|
91
|
+
return nil
|
|
92
|
+
end
|
|
93
|
+
if string.sub(text, 1, 5) == "data:" then
|
|
94
|
+
text = (string.gsub(string.sub(text, 6), "^%s+", ""))
|
|
95
|
+
end
|
|
96
|
+
local decoded = Net.decode(text)
|
|
97
|
+
if typeof(decoded) ~= "table" then
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
return decoded
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
local function handleFrame(callbacks: Callbacks, frame: { [string]: any })
|
|
104
|
+
local id = frame.id
|
|
105
|
+
local op = frame.op
|
|
106
|
+
if typeof(id) == "string" and typeof(op) == "string" then
|
|
107
|
+
callbacks.onCommand(id, op, frame.params)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
--[[
|
|
112
|
+
Opens the stream and blocks until it closes. Returns false when the stream
|
|
113
|
+
could not be opened at all, which is the signal to fall back to long-poll.
|
|
114
|
+
]]
|
|
115
|
+
local function runStream(callbacks: Callbacks): boolean
|
|
116
|
+
local opened = false
|
|
117
|
+
|
|
118
|
+
local ok, client = pcall(function()
|
|
119
|
+
return (HttpService :: any):CreateWebStreamClient((Enum :: any).WebStreamClientType.SSE, {
|
|
120
|
+
Url = Config.baseUrl() .. "/events",
|
|
121
|
+
Method = "POST",
|
|
122
|
+
Headers = Config.headers(),
|
|
123
|
+
Body = HttpService:JSONEncode(identity("sse")),
|
|
124
|
+
})
|
|
125
|
+
end)
|
|
126
|
+
|
|
127
|
+
if not ok or client == nil then
|
|
128
|
+
return false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
activeStream = client
|
|
132
|
+
local connections: { RBXScriptConnection } = {}
|
|
133
|
+
|
|
134
|
+
table.insert(
|
|
135
|
+
connections,
|
|
136
|
+
client.Opened:Connect(function(statusCode: number)
|
|
137
|
+
if statusCode == 200 then
|
|
138
|
+
opened = true
|
|
139
|
+
mode = "sse"
|
|
140
|
+
callbacks.onStatus("connected", "streaming")
|
|
141
|
+
end
|
|
142
|
+
end)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
table.insert(
|
|
146
|
+
connections,
|
|
147
|
+
client.MessageReceived:Connect(function(message: string)
|
|
148
|
+
local frame = parseFrame(message)
|
|
149
|
+
if frame then
|
|
150
|
+
handleFrame(callbacks, frame)
|
|
151
|
+
end
|
|
152
|
+
end)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
local streamError: string? = nil
|
|
156
|
+
table.insert(
|
|
157
|
+
connections,
|
|
158
|
+
client.Error:Connect(function(statusCode: number, errorMessage: string)
|
|
159
|
+
streamError = string.format("stream error %d: %s", statusCode, tostring(errorMessage))
|
|
160
|
+
end)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
client.Closed:Wait()
|
|
164
|
+
for _, connection in connections do
|
|
165
|
+
connection:Disconnect()
|
|
166
|
+
end
|
|
167
|
+
activeStream = nil
|
|
168
|
+
|
|
169
|
+
if not opened then
|
|
170
|
+
callbacks.onStatus("disconnected", streamError or Net.explain("could not connect"))
|
|
171
|
+
return false
|
|
172
|
+
end
|
|
173
|
+
callbacks.onStatus("disconnected", "stream closed")
|
|
174
|
+
return true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
--[[
|
|
178
|
+
Long-poll fallback. The bridge parks each request for ~25s and answers with a
|
|
179
|
+
command or an idle marker, so this is a blocking wait rather than a busy loop.
|
|
180
|
+
|
|
181
|
+
Returns true when it is giving way so streaming can be tried again.
|
|
182
|
+
|
|
183
|
+
That return exists because this loop used to have no exit while it was
|
|
184
|
+
succeeding, which quietly made the fallback permanent: streaming is only
|
|
185
|
+
attempted when a connection is being established, so a single failed attempt
|
|
186
|
+
-- Studio's four-stream cap being momentarily full, or the bridge restarting
|
|
187
|
+
at the wrong instant -- dropped the session onto long-poll for as long as it
|
|
188
|
+
stayed connected. Nothing reported it, because polling works; it is just
|
|
189
|
+
slower, which is precisely the thing this project claims to fix. Both windows
|
|
190
|
+
of this session were found sitting on poll with no error anywhere.
|
|
191
|
+
]]
|
|
192
|
+
local function runPolling(callbacks: Callbacks): boolean
|
|
193
|
+
mode = "poll"
|
|
194
|
+
local handshake = Net.postJson("/connect", identity("poll"))
|
|
195
|
+
if not handshake.ok then
|
|
196
|
+
callbacks.onStatus("disconnected", handshake.error or "handshake failed")
|
|
197
|
+
return false
|
|
198
|
+
end
|
|
199
|
+
callbacks.onStatus("connected", "polling")
|
|
200
|
+
|
|
201
|
+
local retryStreamAt = os.clock() + Config.RESTREAM_INTERVAL
|
|
202
|
+
|
|
203
|
+
while running do
|
|
204
|
+
local response = Net.request("GET", "/poll?studioId=" .. studioId)
|
|
205
|
+
if not running then
|
|
206
|
+
return false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if response.statusCode == 409 then
|
|
210
|
+
-- The server restarted and no longer knows us; re-handshake.
|
|
211
|
+
local retry = Net.postJson("/connect", identity("poll"))
|
|
212
|
+
if not retry.ok then
|
|
213
|
+
callbacks.onStatus("disconnected", retry.error or "reconnect failed")
|
|
214
|
+
return false
|
|
215
|
+
end
|
|
216
|
+
elseif not response.ok then
|
|
217
|
+
callbacks.onStatus("disconnected", response.error or "poll failed")
|
|
218
|
+
return false
|
|
219
|
+
else
|
|
220
|
+
local payload = Net.decode(response.body)
|
|
221
|
+
if typeof(payload) == "table" and typeof(payload.command) == "table" then
|
|
222
|
+
handleFrame(callbacks, payload.command)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
-- Checked after handling rather than before waiting, so the handover
|
|
227
|
+
-- happens between commands instead of while one is outstanding.
|
|
228
|
+
if os.clock() >= retryStreamAt then
|
|
229
|
+
return true
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
return false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
--[[
|
|
236
|
+
Posts a command result. Fire-and-forget: if it fails the server's own timeout
|
|
237
|
+
will surface an actionable error to the agent, and retrying here would risk
|
|
238
|
+
answering a request that has already been abandoned.
|
|
239
|
+
]]
|
|
240
|
+
function Transport.sendResult(result: { [string]: any })
|
|
241
|
+
task.spawn(function()
|
|
242
|
+
Net.postJson("/result?studioId=" .. studioId, result)
|
|
243
|
+
end)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
function Transport.getMode(): string
|
|
247
|
+
return mode
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
function Transport.getForcePoll(): boolean
|
|
251
|
+
return forcePoll
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
--[[
|
|
255
|
+
Takes effect on the next connection, not this one: the change has to happen
|
|
256
|
+
between commands, and tearing the stream down from inside a handler would
|
|
257
|
+
strand the reply to the very call that asked for it.
|
|
258
|
+
]]
|
|
259
|
+
function Transport.setForcePoll(value: boolean)
|
|
260
|
+
forcePoll = value
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
function Transport.isRunning(): boolean
|
|
264
|
+
return running
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
--[[
|
|
268
|
+
Connects and keeps reconnecting until `stop` is called. Backs off on repeated
|
|
269
|
+
failure so a server that is simply not running does not spin the CPU.
|
|
270
|
+
]]
|
|
271
|
+
function Transport.start(id: string, callbacks: Callbacks)
|
|
272
|
+
if running then
|
|
273
|
+
return
|
|
274
|
+
end
|
|
275
|
+
running = true
|
|
276
|
+
studioId = id
|
|
277
|
+
|
|
278
|
+
task.spawn(function()
|
|
279
|
+
local delay = Config.RECONNECT_DELAY
|
|
280
|
+
while running do
|
|
281
|
+
callbacks.onStatus("connecting")
|
|
282
|
+
|
|
283
|
+
local streamed = not forcePoll and supportsWebStream() and runStream(callbacks)
|
|
284
|
+
local upgrading = false
|
|
285
|
+
if running and not streamed then
|
|
286
|
+
upgrading = runPolling(callbacks)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
if not running then
|
|
290
|
+
break
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
-- Polling was healthy and stood aside to let streaming be retried.
|
|
294
|
+
-- The backoff exists to slow down repeated failures, and this is not
|
|
295
|
+
-- one, so go straight back round.
|
|
296
|
+
if upgrading then
|
|
297
|
+
delay = Config.RECONNECT_DELAY
|
|
298
|
+
continue
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
task.wait(delay)
|
|
302
|
+
delay = math.min(delay * 2, Config.MAX_RECONNECT_DELAY)
|
|
303
|
+
-- A connection that lasted long enough to do real work resets the
|
|
304
|
+
-- backoff; only repeated fast failures should slow retries down.
|
|
305
|
+
if activeStream ~= nil then
|
|
306
|
+
delay = Config.RECONNECT_DELAY
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
function Transport.stop()
|
|
313
|
+
if not running then
|
|
314
|
+
return
|
|
315
|
+
end
|
|
316
|
+
running = false
|
|
317
|
+
pcall(function()
|
|
318
|
+
Net.postJson("/bye?studioId=" .. studioId, {})
|
|
319
|
+
end)
|
|
320
|
+
if activeStream then
|
|
321
|
+
pcall(function()
|
|
322
|
+
activeStream:Close()
|
|
323
|
+
end)
|
|
324
|
+
activeStream = nil
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
return Transport
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
Wraps every mutation in a ChangeHistoryService recording.
|
|
4
|
+
|
|
5
|
+
Two things fall out of this that competing servers do not give you:
|
|
6
|
+
|
|
7
|
+
1. One agent action is one Ctrl+Z. A tool that creates 200 parts collapses to
|
|
8
|
+
a single undo step named after the tool, instead of 200 separate steps or
|
|
9
|
+
-- worse -- no undo entry at all.
|
|
10
|
+
2. A batch that fails part way through is *cancelled*, not committed. Without
|
|
11
|
+
this, a `modify` call that sets 5 of 10 properties and then hits a bad
|
|
12
|
+
value leaves the place in a half-edited state the user has to unpick by
|
|
13
|
+
hand.
|
|
14
|
+
|
|
15
|
+
`SetWaypoint` is deliberately not used; it has been superseded by recordings
|
|
16
|
+
and cannot express the cancel case.
|
|
17
|
+
]]
|
|
18
|
+
|
|
19
|
+
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
|
20
|
+
|
|
21
|
+
local Undo = {}
|
|
22
|
+
|
|
23
|
+
--[[
|
|
24
|
+
Runs `body` inside a named recording. Returns its result, and whether a
|
|
25
|
+
recording was actually opened.
|
|
26
|
+
|
|
27
|
+
`TryBeginRecording` returns nil when another recording is already open (for
|
|
28
|
+
example the user is mid-drag in the viewport) or when history is unavailable.
|
|
29
|
+
The work still runs in that case -- refusing to act would be worse than a
|
|
30
|
+
slightly coarser undo stack -- it simply joins the surrounding recording.
|
|
31
|
+
|
|
32
|
+
That second return value is not decoration. Whether an operation is undoable
|
|
33
|
+
is something tools tell the agent, and therefore the user, so it has to be
|
|
34
|
+
observed rather than assumed: a claim of "one Ctrl+Z" that silently degrades
|
|
35
|
+
to no undo entry at all is worse than no claim.
|
|
36
|
+
]]
|
|
37
|
+
function Undo.record<T>(name: string, displayName: string, body: () -> T): (T, boolean)
|
|
38
|
+
local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
|
|
39
|
+
if not identifier then
|
|
40
|
+
return body(), false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
local ok, result = pcall(body)
|
|
44
|
+
if ok then
|
|
45
|
+
ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
|
|
46
|
+
return result, true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Cancel)
|
|
50
|
+
error(result, 0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
--[[
|
|
54
|
+
Commits the recording even when `body` fails, for operations whose partial
|
|
55
|
+
effects are still wanted. Used only by handlers that report per-item results
|
|
56
|
+
and therefore tell the agent exactly what did and did not apply.
|
|
57
|
+
]]
|
|
58
|
+
function Undo.recordPartial<T>(name: string, displayName: string, body: () -> T): (T, boolean)
|
|
59
|
+
local identifier = ChangeHistoryService:TryBeginRecording(name, displayName)
|
|
60
|
+
if not identifier then
|
|
61
|
+
return body(), false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
local ok, result = pcall(body)
|
|
65
|
+
ChangeHistoryService:FinishRecording(identifier, Enum.FinishRecordingOperation.Commit)
|
|
66
|
+
if not ok then
|
|
67
|
+
error(result, 0)
|
|
68
|
+
end
|
|
69
|
+
return result, true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return Undo
|