@el4cteo/rbx-studio-mcp 0.3.0 → 0.3.5

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.
Files changed (43) hide show
  1. package/README.md +20 -26
  2. package/dist/index.js +1 -1
  3. package/dist/lib/format.js +18 -2
  4. package/dist/lib/format.js.map +1 -1
  5. package/dist/lib/pluginbuild.js +23 -13
  6. package/dist/lib/pluginbuild.js.map +1 -1
  7. package/dist/tools/api.js +20 -1
  8. package/dist/tools/api.js.map +1 -1
  9. package/dist/tools/debug.js +18 -8
  10. package/dist/tools/debug.js.map +1 -1
  11. package/dist/tools/discover.js +5 -0
  12. package/dist/tools/discover.js.map +1 -1
  13. package/dist/tools/instances.js +49 -2
  14. package/dist/tools/instances.js.map +1 -1
  15. package/dist/tools/perf.js +11 -1
  16. package/dist/tools/perf.js.map +1 -1
  17. package/dist/tools/scripts.js +27 -3
  18. package/dist/tools/scripts.js.map +1 -1
  19. package/dist/tools/session.js +16 -0
  20. package/dist/tools/session.js.map +1 -1
  21. package/package.json +1 -1
  22. package/plugin/src/Config.luau +1 -1
  23. package/plugin/src/Console.luau +257 -116
  24. package/plugin/src/Paths.luau +138 -69
  25. package/plugin/src/Serialize.luau +43 -3
  26. package/plugin/src/ThemePicker.luau +458 -0
  27. package/plugin/src/Themes/Aurora.luau +147 -0
  28. package/plugin/src/Themes/Blueprint.luau +213 -0
  29. package/plugin/src/Themes/Draw.luau +177 -0
  30. package/plugin/src/Themes/Lattice.luau +304 -0
  31. package/plugin/src/Themes/Nebula.luau +182 -0
  32. package/plugin/src/Themes/Observatory.luau +200 -0
  33. package/plugin/src/Themes/Orbit.luau +268 -0
  34. package/plugin/src/Themes/Phosphor.luau +200 -0
  35. package/plugin/src/Themes/Theme.luau +121 -0
  36. package/plugin/src/Themes/Void.luau +230 -0
  37. package/plugin/src/Themes/init.luau +116 -0
  38. package/plugin/src/Visuals.luau +788 -907
  39. package/plugin/src/handlers/Discover.luau +75 -1
  40. package/plugin/src/handlers/Exec.luau +17 -5
  41. package/plugin/src/handlers/Instances.luau +35 -3
  42. package/plugin/src/handlers/Scripts.luau +58 -2
  43. package/plugin/src/init.server.luau +386 -354
@@ -1,354 +1,386 @@
1
- --!strict
2
- --[[
3
- rbx-studio -- plugin entry point.
4
-
5
- Owns the toolbar UI, this window's Studio identity, and the command loop.
6
- Handlers do the actual work; this file only wires them to the transport and
7
- reports what is happening to the console widget.
8
- ]]
9
-
10
- local HttpService = game:GetService("HttpService")
11
- local RunService = game:GetService("RunService")
12
-
13
- local Config = require(script.Config)
14
- local Console = require(script.Console)
15
- local Dispatch = require(script.Dispatch)
16
- local LogBuffer = require(script.LogBuffer)
17
- local Phrase = require(script.Phrase)
18
- local Transport = require(script.Transport)
19
- local Debug = require(script.handlers.Debug)
20
- local Assets = require(script.handlers.Assets)
21
- local Capture = require(script.handlers.Capture)
22
- local Character = require(script.handlers.Character)
23
- local Geometry = require(script.handlers.Geometry)
24
- local World = require(script.handlers.World)
25
- local Discover = require(script.handlers.Discover)
26
- local Exec = require(script.handlers.Exec)
27
- local Instances = require(script.handlers.Instances)
28
- local Perf = require(script.handlers.Perf)
29
- local Playtest = require(script.handlers.Playtest)
30
- local Viewport = require(script.handlers.Viewport)
31
- local Input = require(script.handlers.Input)
32
- local Device = require(script.handlers.Device)
33
- local Api = require(script.handlers.Api)
34
- local Scripts = require(script.handlers.Scripts)
35
- local Session = require(script.handlers.Session)
36
-
37
- local SETTING_PORT = "port"
38
- local SETTING_AUTOCONNECT = "autoConnect"
39
- local SETTING_FORCE_POLL = "forcePoll"
40
-
41
- --[[
42
- A fresh id per plugin load, which in practice means one per Studio window.
43
-
44
- This was originally persisted with `plugin:SetSetting`, on the reasoning that a
45
- stable id keeps reconnects mapping to the same session. That is wrong as soon
46
- as the user opens a second window: plugin settings live in one file shared by
47
- every Studio process, so both windows announce the same id, and the server
48
- treats the second connection as the first one reconnecting -- closing the
49
- original stream and making it impossible to address the two places separately.
50
-
51
- Held in memory instead. A reconnect within one load (the SSE stream hits its
52
- 30-minute cap) reuses this id, and `plugin.Unloading` detaches cleanly on
53
- reload, so ghost entries do not accumulate.
54
- ]]
55
- local SESSION_ID = HttpService:GenerateGUID(false)
56
-
57
- local function studioId(): string
58
- return SESSION_ID
59
- end
60
-
61
- --[[
62
- Whether this copy of the plugin can reach the bridge at all.
63
-
64
- Pressing Play loads the plugin into the playtest's DataModels as well as the
65
- editor's, and HttpService refuses every request from a client one: "Http
66
- requests can only be executed by game server". The transport read that as a
67
- dropped connection and retried forever, filling the console with red while
68
- nothing was actually wrong.
69
-
70
- The editor session and the play session's server can both connect and are
71
- worth connecting -- addressing a running server is useful. The client half
72
- simply says so once and stops.
73
- ]]
74
- local function canConnect(): (boolean, string?)
75
- if RunService:IsEdit() or RunService:IsServer() then
76
- return true, nil
77
- end
78
- --[[
79
- The wording names the fix, because the symptom is indistinguishable
80
- from a broken server: a user watching this window during a playtest
81
- sees a console that logs nothing while tools plainly work, and has no
82
- way to guess that the activity is in a different view of the same
83
- Studio. Reported once here and again as the strip's caption, since
84
- one line scrolled off the top is easy to miss.
85
- ]]
86
- return false,
87
- "client view of a playtest -- Studio forbids client sessions from making HTTP "
88
- .. "requests. The playtest's server session is connected and handling this "
89
- .. "place; switch Studio to the Server view (Test tab, Current: Server) to "
90
- .. "watch it work."
91
- end
92
-
93
- -- First thing, before any handler or the transport can log: the buffer only
94
- -- holds what was printed after it subscribed, so every line ahead of this call
95
- -- is unrecoverable. This runs even in a client session that will never connect,
96
- -- since the console tool reads it and connectivity is a separate question.
97
- LogBuffer.start()
98
-
99
- local storedPort = plugin:GetSetting(SETTING_PORT)
100
- if typeof(storedPort) == "number" then
101
- Config.setPort(storedPort)
102
- end
103
-
104
- Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
105
-
106
- Session.register()
107
- Discover.register()
108
- Debug.register()
109
- Instances.register()
110
- Perf.register(plugin)
111
- Playtest.register()
112
- Capture.register()
113
- Assets.register()
114
- Character.register()
115
- Geometry.register()
116
- World.register()
117
- Exec.register()
118
- Viewport.register()
119
- Scripts.register()
120
- Input.register()
121
- Device.register()
122
- Api.register()
123
-
124
- local toolbar = plugin:CreateToolbar("rbx-studio")
125
- local button = toolbar:CreateButton(
126
- "rbx-studio",
127
- "Show the rbx-studio console",
128
- "rbxasset://textures/ui/common/robux.png"
129
- )
130
- button.ClickableWhenViewportHidden = true
131
-
132
- -- Enabled by default: the whole point is that opening a place shows you it
133
- -- connected without touching anything. Studio remembers the user's choice after
134
- -- the first time they close it.
135
- local widget = plugin:CreateDockWidgetPluginGuiAsync(
136
- "StudioMCP_Console",
137
- DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 560, 320, 360, 200)
138
- )
139
- widget.Title = "rbx-studio"
140
-
141
- local currentStatus: Transport.Status = "disconnected"
142
-
143
- local function refreshMeta()
144
- Console.setStatus(
145
- currentStatus,
146
- string.format(
147
- "127.0.0.1:%d %s build %s",
148
- Config.getPort(),
149
- Transport.getMode(),
150
- Config.BUILD_ID
151
- )
152
- )
153
- button:SetActive(currentStatus == "connected")
154
- end
155
-
156
- local connect: () -> ()
157
-
158
- Console.mount(widget, {
159
- onReconnect = function()
160
- Console.log("info", "reconnect requested")
161
- Transport.stop()
162
- task.wait(0.2)
163
- connect()
164
- end,
165
- onClear = function()
166
- Console.clear()
167
- end,
168
- })
169
-
170
- Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
171
- Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
172
-
173
- local function onStatus(status: Transport.Status, detail: string?)
174
- local previous = currentStatus
175
- currentStatus = status
176
- refreshMeta()
177
-
178
- -- Only narrate transitions. The transport re-reports its state on every
179
- -- reconnect attempt, and echoing an unchanged status would bury real events.
180
- if status == previous and detail == nil then
181
- return
182
- end
183
-
184
- if status == "connected" then
185
- Console.log(
186
- "ok",
187
- string.format("connected to 127.0.0.1:%d", Config.getPort()),
188
- if detail then "(" .. detail .. ")" else nil
189
- )
190
- elseif status == "connecting" then
191
- Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
192
- else
193
- Console.log("error", "disconnected", detail)
194
- end
195
- end
196
-
197
- --[[
198
- Runs each command on its own task. Handlers may yield -- `UpdateSourceAsync`
199
- and any `*Async` call does -- and serialising them would let one slow edit
200
- stall every other request on the stream.
201
- ]]
202
- local function onCommand(id: string, op: string, params: { [string]: any }?)
203
- task.spawn(function()
204
- local startedAt = os.clock()
205
- --[[
206
- Named for what it does, not for how it travels. `script.edit` on
207
- ServerScriptService.Systems.KillBrick reads as "Edit KillBrick",
208
- which is the thing someone watching actually wants to know; the wire
209
- name is kept alongside so the log still maps onto the protocol when
210
- something needs debugging.
211
- ]]
212
- local title = Phrase.of(op, params)
213
- --[[
214
- Announced synchronously, unlike the logging below.
215
-
216
- Deferring this looked like free latency and was not. `beginCall` sets
217
- two labels and some fields -- it never touches the RichText log, which
218
- is where the cost actually is -- and deferring it let a call that
219
- finished inside one frame record its result before the call had been
220
- announced, so the bar on the trace lost the name it was supposed to
221
- carry. A microsecond is not worth an ordering hazard.
222
- ]]
223
- Console.beginCall(title, Phrase.kindOf(op))
224
-
225
- local result = Dispatch.invoke(id, op, params)
226
- local milliseconds = (os.clock() - startedAt) * 1000
227
-
228
- --[[
229
- The answer goes out before the console hears about it.
230
-
231
- This used to be the last line of the function, which put a
232
- `table.concat` of three hundred strings, a RichText relayout of the
233
- whole log, two `Instance.new` calls and a forty-bar relayout in front
234
- of the reply on its way back to the agent. None of that is work the
235
- caller asked for, and all of it was being billed to the round trip
236
- this project measures. Drawing happens below, on time the agent is no
237
- longer waiting for.
238
- ]]
239
- Transport.sendResult(result)
240
-
241
- local elapsed = string.format("%.0fms", milliseconds)
242
- Console.recordCall(result.ok, milliseconds)
243
-
244
- if result.ok then
245
- Console.log("reply", title, elapsed)
246
- else
247
- local err = result.error
248
- Console.log(
249
- "error",
250
- string.format("%s failed: %s", title, if err then err.code else "unknown"),
251
- elapsed
252
- )
253
- if err and err.message then
254
- Console.log("dim", " " .. err.message)
255
- end
256
- end
257
- end)
258
- end
259
-
260
- --[[
261
- Bridge news that is not a command.
262
-
263
- Kept deliberately narrow: an unknown event is ignored rather than logged,
264
- because a newer server talking to an older plugin is a supported situation
265
- and "unknown event" rows would be the only symptom of it working correctly.
266
- ]]
267
- local function onEvent(event: { [string]: any })
268
- if event.event == "clients" and typeof(event.count) == "number" then
269
- Console.setClients(event.count)
270
- elseif event.event == "agent" and event.state == "finished" then
271
- Console.agentFinished()
272
- end
273
- end
274
-
275
- function connect()
276
- local allowed, reason = canConnect()
277
- if not allowed then
278
- -- Reported as standby rather than an error: nothing failed, and this
279
- -- session was never going to connect.
280
- currentStatus = "disconnected"
281
- Console.log("dim", "standby", reason)
282
- Console.setStatus(
283
- "standby",
284
- string.format("%s build %s", "client view", Config.BUILD_ID)
285
- )
286
- Console.setCaption("switch to the Server view to watch this playtest")
287
- return
288
- end
289
- Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
290
- plugin:SetSetting(SETTING_AUTOCONNECT, true)
291
- end
292
-
293
- button.Click:Connect(function()
294
- widget.Enabled = not widget.Enabled
295
- end)
296
-
297
- plugin.Unloading:Connect(function()
298
- Transport.stop()
299
- end)
300
-
301
- --[[
302
- Switches this session between the push and long-poll transports.
303
-
304
- Registered here rather than in a handler module because it is the only
305
- command that has to reach back into the plugin object and the connection
306
- loop, both of which live in this file -- and registered THIS far down the
307
- file on purpose: `connect` is a forward-declared local, so a closure written
308
- above its declaration captures the global of that name instead, which is nil.
309
- That cost a session. The plugin loaded and connected perfectly, and then died
310
- the first time somebody asked it to switch transport.
311
-
312
- The reply is sent before the reconnect, and the reconnect is deferred:
313
- tearing the stream down inside the handler would strand the answer to the
314
- very call that asked for the switch, which is a confusing way to succeed.
315
- ]]
316
- Dispatch.registerAll("studio", {
317
- transport = function(params: { [string]: any }): { [string]: any }
318
- local requested = params.mode
319
- if requested ~= nil and requested ~= "sse" and requested ~= "poll" then
320
- Dispatch.fail("BAD_PARAMS", 'transport mode must be "sse" or "poll".')
321
- end
322
- if requested == nil then
323
- return { mode = Transport.getMode(), forcePoll = Transport.getForcePoll(), changed = false }
324
- end
325
-
326
- local wantPoll = requested == "poll"
327
- if Transport.getMode() == requested and Transport.getForcePoll() == wantPoll then
328
- return { mode = Transport.getMode(), forcePoll = wantPoll, changed = false }
329
- end
330
-
331
- Transport.setForcePoll(wantPoll)
332
- plugin:SetSetting(SETTING_FORCE_POLL, wantPoll)
333
- task.defer(function()
334
- Transport.stop()
335
- task.wait(0.1)
336
- connect()
337
- end)
338
- return {
339
- mode = requested,
340
- forcePoll = wantPoll,
341
- changed = true,
342
- note = "Reconnecting on the new transport; the next call will use it.",
343
- }
344
- end,
345
- })
346
-
347
- refreshMeta()
348
-
349
- -- Connect on load unless the user explicitly disconnected last session.
350
- if plugin:GetSetting(SETTING_AUTOCONNECT) ~= false then
351
- connect()
352
- else
353
- Console.log("warn", "auto-connect disabled — press reconnect to start")
354
- end
1
+ --!strict
2
+ --[[
3
+ rbx-studio -- plugin entry point.
4
+
5
+ Owns the toolbar UI, this window's Studio identity, and the command loop.
6
+ Handlers do the actual work; this file only wires them to the transport and
7
+ reports what is happening to the console widget.
8
+ ]]
9
+
10
+ local HttpService = game:GetService("HttpService")
11
+ local RunService = game:GetService("RunService")
12
+
13
+ local Config = require(script.Config)
14
+ local Console = require(script.Console)
15
+ local Dispatch = require(script.Dispatch)
16
+ local LogBuffer = require(script.LogBuffer)
17
+ local Phrase = require(script.Phrase)
18
+ local Themes = require(script.Themes)
19
+ local Transport = require(script.Transport)
20
+ local Debug = require(script.handlers.Debug)
21
+ local Assets = require(script.handlers.Assets)
22
+ local Capture = require(script.handlers.Capture)
23
+ local Character = require(script.handlers.Character)
24
+ local Geometry = require(script.handlers.Geometry)
25
+ local World = require(script.handlers.World)
26
+ local Discover = require(script.handlers.Discover)
27
+ local Exec = require(script.handlers.Exec)
28
+ local Instances = require(script.handlers.Instances)
29
+ local Perf = require(script.handlers.Perf)
30
+ local Playtest = require(script.handlers.Playtest)
31
+ local Viewport = require(script.handlers.Viewport)
32
+ local Input = require(script.handlers.Input)
33
+ local Device = require(script.handlers.Device)
34
+ local Api = require(script.handlers.Api)
35
+ local Scripts = require(script.handlers.Scripts)
36
+ local Session = require(script.handlers.Session)
37
+
38
+ local SETTING_PORT = "port"
39
+ local SETTING_AUTOCONNECT = "autoConnect"
40
+ local SETTING_FORCE_POLL = "forcePoll"
41
+ local SETTING_THEME = "theme"
42
+
43
+ --[[
44
+ A fresh id per plugin load, which in practice means one per Studio window.
45
+
46
+ This was originally persisted with `plugin:SetSetting`, on the reasoning that a
47
+ stable id keeps reconnects mapping to the same session. That is wrong as soon
48
+ as the user opens a second window: plugin settings live in one file shared by
49
+ every Studio process, so both windows announce the same id, and the server
50
+ treats the second connection as the first one reconnecting -- closing the
51
+ original stream and making it impossible to address the two places separately.
52
+
53
+ Held in memory instead. A reconnect within one load (the SSE stream hits its
54
+ 30-minute cap) reuses this id, and `plugin.Unloading` detaches cleanly on
55
+ reload, so ghost entries do not accumulate.
56
+ ]]
57
+ local SESSION_ID = HttpService:GenerateGUID(false)
58
+
59
+ local function studioId(): string
60
+ return SESSION_ID
61
+ end
62
+
63
+ --[[
64
+ Whether this copy of the plugin can reach the bridge at all.
65
+
66
+ Pressing Play loads the plugin into the playtest's DataModels as well as the
67
+ editor's, and HttpService refuses every request from a client one: "Http
68
+ requests can only be executed by game server". The transport read that as a
69
+ dropped connection and retried forever, filling the console with red while
70
+ nothing was actually wrong.
71
+
72
+ The editor session and the play session's server can both connect and are
73
+ worth connecting -- addressing a running server is useful. The client half
74
+ simply says so once and stops.
75
+ ]]
76
+ local function canConnect(): (boolean, string?)
77
+ if RunService:IsEdit() or RunService:IsServer() then
78
+ return true, nil
79
+ end
80
+ --[[
81
+ The wording names the fix, because the symptom is indistinguishable
82
+ from a broken server: a user watching this window during a playtest
83
+ sees a console that logs nothing while tools plainly work, and has no
84
+ way to guess that the activity is in a different view of the same
85
+ Studio. Reported once here and again as the strip's caption, since
86
+ one line scrolled off the top is easy to miss.
87
+ ]]
88
+ return false,
89
+ "client view of a playtest -- Studio forbids client sessions from making HTTP "
90
+ .. "requests. The playtest's server session is connected and handling this "
91
+ .. "place; switch Studio to the Server view (Test tab, Current: Server) to "
92
+ .. "watch it work."
93
+ end
94
+
95
+ -- First thing, before any handler or the transport can log: the buffer only
96
+ -- holds what was printed after it subscribed, so every line ahead of this call
97
+ -- is unrecoverable. This runs even in a client session that will never connect,
98
+ -- since the console tool reads it and connectivity is a separate question.
99
+ LogBuffer.start()
100
+
101
+ local storedPort = plugin:GetSetting(SETTING_PORT)
102
+ if typeof(storedPort) == "number" then
103
+ Config.setPort(storedPort)
104
+ end
105
+
106
+ Transport.setForcePoll(plugin:GetSetting(SETTING_FORCE_POLL) == true)
107
+
108
+ --[[
109
+ The console's colour preset, restored before anything is drawn.
110
+
111
+ Applied here rather than after mounting so the panel is built in the right
112
+ palette from the start -- restoring it afterwards would flash the default
113
+ theme for a frame on every Studio launch. An unknown id (a preset renamed,
114
+ or a setting written by a newer build) falls back to the default rather than
115
+ failing, which is `Themes.get`'s job.
116
+ ]]
117
+ local storedTheme = plugin:GetSetting(SETTING_THEME)
118
+ if typeof(storedTheme) == "string" then
119
+ Themes.use(storedTheme)
120
+ end
121
+
122
+ Session.register()
123
+ Discover.register()
124
+ Debug.register()
125
+ Instances.register()
126
+ Perf.register(plugin)
127
+ Playtest.register()
128
+ Capture.register()
129
+ Assets.register()
130
+ Character.register()
131
+ Geometry.register()
132
+ World.register()
133
+ Exec.register()
134
+ Viewport.register()
135
+ Scripts.register()
136
+ Input.register()
137
+ Device.register()
138
+ Api.register()
139
+
140
+ --[[
141
+ The toolbar button.
142
+
143
+ The icon has to be an uploaded asset: `CreateButton` takes a content string,
144
+ and the only ones Studio resolves are `rbxassetid://` for uploaded images and
145
+ `rbxasset://` for files that ship inside Studio itself. A path to something in
146
+ this repository is not one of them, which is why the mark in `assets/` has to
147
+ go through an upload before it can appear here.
148
+
149
+ It used to borrow `textures/ui/common/robux.png` -- a Robux coin, sitting in
150
+ the toolbar next to a plugin that has nothing to do with purchases.
151
+ ]]
152
+ local toolbar = plugin:CreateToolbar("rbx-studio")
153
+ local button = toolbar:CreateButton(
154
+ "rbx-studio",
155
+ "Show the rbx-studio console",
156
+ "rbxassetid://125390773465346"
157
+ )
158
+ button.ClickableWhenViewportHidden = true
159
+
160
+ -- Enabled by default: the whole point is that opening a place shows you it
161
+ -- connected without touching anything. Studio remembers the user's choice after
162
+ -- the first time they close it.
163
+ local widget = plugin:CreateDockWidgetPluginGuiAsync(
164
+ "StudioMCP_Console",
165
+ DockWidgetPluginGuiInfo.new(Enum.InitialDockState.Float, true, false, 560, 320, 360, 200)
166
+ )
167
+ widget.Title = "rbx-studio"
168
+
169
+ local currentStatus: Transport.Status = "disconnected"
170
+
171
+ local function refreshMeta()
172
+ Console.setStatus(
173
+ currentStatus,
174
+ string.format(
175
+ "127.0.0.1:%d %s build %s",
176
+ Config.getPort(),
177
+ Transport.getMode(),
178
+ Config.BUILD_ID
179
+ )
180
+ )
181
+ button:SetActive(currentStatus == "connected")
182
+ end
183
+
184
+ local connect: () -> ()
185
+
186
+ Console.mount(widget, {
187
+ onReconnect = function()
188
+ Console.log("info", "reconnect requested")
189
+ Transport.stop()
190
+ task.wait(0.2)
191
+ connect()
192
+ end,
193
+ onClear = function()
194
+ Console.clear()
195
+ end,
196
+ onTheme = function(id)
197
+ plugin:SetSetting(SETTING_THEME, id)
198
+ Console.log("dim", string.format("theme: %s", id))
199
+ end,
200
+ })
201
+
202
+ Console.log("info", string.format("rbx-studio v%s", Config.PLUGIN_VERSION), "build " .. Config.BUILD_ID)
203
+ Console.log("dim", string.format("place: %s (%d)", game.Name, game.PlaceId))
204
+
205
+ local function onStatus(status: Transport.Status, detail: string?)
206
+ local previous = currentStatus
207
+ currentStatus = status
208
+ refreshMeta()
209
+
210
+ -- Only narrate transitions. The transport re-reports its state on every
211
+ -- reconnect attempt, and echoing an unchanged status would bury real events.
212
+ if status == previous and detail == nil then
213
+ return
214
+ end
215
+
216
+ if status == "connected" then
217
+ Console.log(
218
+ "ok",
219
+ string.format("connected to 127.0.0.1:%d", Config.getPort()),
220
+ if detail then "(" .. detail .. ")" else nil
221
+ )
222
+ elseif status == "connecting" then
223
+ Console.log("dim", string.format("connecting to 127.0.0.1:%d...", Config.getPort()))
224
+ else
225
+ Console.log("error", "disconnected", detail)
226
+ end
227
+ end
228
+
229
+ --[[
230
+ Runs each command on its own task. Handlers may yield -- `UpdateSourceAsync`
231
+ and any `*Async` call does -- and serialising them would let one slow edit
232
+ stall every other request on the stream.
233
+ ]]
234
+ local function onCommand(id: string, op: string, params: { [string]: any }?)
235
+ task.spawn(function()
236
+ local startedAt = os.clock()
237
+ --[[
238
+ Named for what it does, not for how it travels. `script.edit` on
239
+ ServerScriptService.Systems.KillBrick reads as "Edit KillBrick",
240
+ which is the thing someone watching actually wants to know; the wire
241
+ name is kept alongside so the log still maps onto the protocol when
242
+ something needs debugging.
243
+ ]]
244
+ local title = Phrase.of(op, params)
245
+ --[[
246
+ Announced synchronously, unlike the logging below.
247
+
248
+ Deferring this looked like free latency and was not. `beginCall` sets
249
+ two labels and some fields -- it never touches the RichText log, which
250
+ is where the cost actually is -- and deferring it let a call that
251
+ finished inside one frame record its result before the call had been
252
+ announced, so the bar on the trace lost the name it was supposed to
253
+ carry. A microsecond is not worth an ordering hazard.
254
+ ]]
255
+ Console.beginCall(title, Phrase.kindOf(op))
256
+
257
+ local result = Dispatch.invoke(id, op, params)
258
+ local milliseconds = (os.clock() - startedAt) * 1000
259
+
260
+ --[[
261
+ The answer goes out before the console hears about it.
262
+
263
+ This used to be the last line of the function, which put a
264
+ `table.concat` of three hundred strings, a RichText relayout of the
265
+ whole log, two `Instance.new` calls and a forty-bar relayout in front
266
+ of the reply on its way back to the agent. None of that is work the
267
+ caller asked for, and all of it was being billed to the round trip
268
+ this project measures. Drawing happens below, on time the agent is no
269
+ longer waiting for.
270
+ ]]
271
+ Transport.sendResult(result)
272
+
273
+ local elapsed = string.format("%.0fms", milliseconds)
274
+ Console.recordCall(result.ok, milliseconds)
275
+
276
+ if result.ok then
277
+ Console.log("reply", title, elapsed)
278
+ else
279
+ local err = result.error
280
+ Console.log(
281
+ "error",
282
+ string.format("%s failed: %s", title, if err then err.code else "unknown"),
283
+ elapsed
284
+ )
285
+ if err and err.message then
286
+ Console.log("dim", " " .. err.message)
287
+ end
288
+ end
289
+ end)
290
+ end
291
+
292
+ --[[
293
+ Bridge news that is not a command.
294
+
295
+ Kept deliberately narrow: an unknown event is ignored rather than logged,
296
+ because a newer server talking to an older plugin is a supported situation
297
+ and "unknown event" rows would be the only symptom of it working correctly.
298
+ ]]
299
+ local function onEvent(event: { [string]: any })
300
+ if event.event == "clients" and typeof(event.count) == "number" then
301
+ Console.setClients(event.count)
302
+ elseif event.event == "agent" and event.state == "finished" then
303
+ Console.agentFinished()
304
+ end
305
+ end
306
+
307
+ function connect()
308
+ local allowed, reason = canConnect()
309
+ if not allowed then
310
+ -- Reported as standby rather than an error: nothing failed, and this
311
+ -- session was never going to connect.
312
+ currentStatus = "disconnected"
313
+ Console.log("dim", "standby", reason)
314
+ Console.setStatus(
315
+ "standby",
316
+ string.format("%s build %s", "client view", Config.BUILD_ID)
317
+ )
318
+ Console.setCaption("switch to the Server view to watch this playtest")
319
+ return
320
+ end
321
+ Transport.start(studioId(), { onCommand = onCommand, onStatus = onStatus, onEvent = onEvent })
322
+ plugin:SetSetting(SETTING_AUTOCONNECT, true)
323
+ end
324
+
325
+ button.Click:Connect(function()
326
+ widget.Enabled = not widget.Enabled
327
+ end)
328
+
329
+ plugin.Unloading:Connect(function()
330
+ Transport.stop()
331
+ end)
332
+
333
+ --[[
334
+ Switches this session between the push and long-poll transports.
335
+
336
+ Registered here rather than in a handler module because it is the only
337
+ command that has to reach back into the plugin object and the connection
338
+ loop, both of which live in this file -- and registered THIS far down the
339
+ file on purpose: `connect` is a forward-declared local, so a closure written
340
+ above its declaration captures the global of that name instead, which is nil.
341
+ That cost a session. The plugin loaded and connected perfectly, and then died
342
+ the first time somebody asked it to switch transport.
343
+
344
+ The reply is sent before the reconnect, and the reconnect is deferred:
345
+ tearing the stream down inside the handler would strand the answer to the
346
+ very call that asked for the switch, which is a confusing way to succeed.
347
+ ]]
348
+ Dispatch.registerAll("studio", {
349
+ transport = function(params: { [string]: any }): { [string]: any }
350
+ local requested = params.mode
351
+ if requested ~= nil and requested ~= "sse" and requested ~= "poll" then
352
+ Dispatch.fail("BAD_PARAMS", 'transport mode must be "sse" or "poll".')
353
+ end
354
+ if requested == nil then
355
+ return { mode = Transport.getMode(), forcePoll = Transport.getForcePoll(), changed = false }
356
+ end
357
+
358
+ local wantPoll = requested == "poll"
359
+ if Transport.getMode() == requested and Transport.getForcePoll() == wantPoll then
360
+ return { mode = Transport.getMode(), forcePoll = wantPoll, changed = false }
361
+ end
362
+
363
+ Transport.setForcePoll(wantPoll)
364
+ plugin:SetSetting(SETTING_FORCE_POLL, wantPoll)
365
+ task.defer(function()
366
+ Transport.stop()
367
+ task.wait(0.1)
368
+ connect()
369
+ end)
370
+ return {
371
+ mode = requested,
372
+ forcePoll = wantPoll,
373
+ changed = true,
374
+ note = "Reconnecting on the new transport; the next call will use it.",
375
+ }
376
+ end,
377
+ })
378
+
379
+ refreshMeta()
380
+
381
+ -- Connect on load unless the user explicitly disconnected last session.
382
+ if plugin:GetSetting(SETTING_AUTOCONNECT) ~= false then
383
+ connect()
384
+ else
385
+ Console.log("warn", "auto-connect disabled — press reconnect to start")
386
+ end