@quenty/studio-bridge 0.22.1 → 0.24.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.
Files changed (231) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +36 -8
  3. package/dist/src/bridge/bridge-connection.d.ts.map +1 -1
  4. package/dist/src/bridge/bridge-connection.js +1 -0
  5. package/dist/src/bridge/bridge-connection.js.map +1 -1
  6. package/dist/src/bridge/bridge-session.d.ts +11 -16
  7. package/dist/src/bridge/bridge-session.d.ts.map +1 -1
  8. package/dist/src/bridge/bridge-session.js +37 -29
  9. package/dist/src/bridge/bridge-session.js.map +1 -1
  10. package/dist/src/bridge/bridge-session.test.js +35 -0
  11. package/dist/src/bridge/bridge-session.test.js.map +1 -1
  12. package/dist/src/bridge/internal/bridge-host.d.ts +2 -0
  13. package/dist/src/bridge/internal/bridge-host.d.ts.map +1 -1
  14. package/dist/src/bridge/internal/bridge-host.js +1 -0
  15. package/dist/src/bridge/internal/bridge-host.js.map +1 -1
  16. package/dist/src/bridge/internal/host-protocol.d.ts.map +1 -1
  17. package/dist/src/bridge/internal/host-protocol.js +23 -10
  18. package/dist/src/bridge/internal/host-protocol.js.map +1 -1
  19. package/dist/src/bridge/types.d.ts +5 -2
  20. package/dist/src/bridge/types.d.ts.map +1 -1
  21. package/dist/src/bridge/types.js.map +1 -1
  22. package/dist/src/commands/console/exec/exec.d.ts +13 -3
  23. package/dist/src/commands/console/exec/exec.d.ts.map +1 -1
  24. package/dist/src/commands/console/exec/exec.js +82 -7
  25. package/dist/src/commands/console/exec/exec.js.map +1 -1
  26. package/dist/src/commands/console/exec/exec.test.js +63 -1
  27. package/dist/src/commands/console/exec/exec.test.js.map +1 -1
  28. package/dist/src/commands/framework/action-loader.test.js +1 -1
  29. package/dist/src/commands/framework/action-loader.test.js.map +1 -1
  30. package/dist/src/commands/playtest/player/playtest-players.d.ts.map +1 -1
  31. package/dist/src/commands/playtest/player/playtest-players.js +15 -93
  32. package/dist/src/commands/playtest/player/playtest-players.js.map +1 -1
  33. package/dist/src/commands/playtest/player/playtest-players.test.js +34 -21
  34. package/dist/src/commands/playtest/player/playtest-players.test.js.map +1 -1
  35. package/dist/src/commands/playtest/playtest-sessions.d.ts +19 -3
  36. package/dist/src/commands/playtest/playtest-sessions.d.ts.map +1 -1
  37. package/dist/src/commands/playtest/playtest-sessions.js +54 -74
  38. package/dist/src/commands/playtest/playtest-sessions.js.map +1 -1
  39. package/dist/src/commands/playtest/playtest-sessions.test.js +74 -8
  40. package/dist/src/commands/playtest/playtest-sessions.test.js.map +1 -1
  41. package/dist/src/commands/playtest/run/run.d.ts +9 -7
  42. package/dist/src/commands/playtest/run/run.d.ts.map +1 -1
  43. package/dist/src/commands/playtest/run/run.js +33 -38
  44. package/dist/src/commands/playtest/run/run.js.map +1 -1
  45. package/dist/src/commands/process/close/close.d.ts +4 -0
  46. package/dist/src/commands/process/close/close.d.ts.map +1 -1
  47. package/dist/src/commands/process/close/close.js +54 -4
  48. package/dist/src/commands/process/close/close.js.map +1 -1
  49. package/dist/src/commands/process/close/close.test.js +36 -3
  50. package/dist/src/commands/process/close/close.test.js.map +1 -1
  51. package/dist/src/commands/process/info/info.d.ts +15 -2
  52. package/dist/src/commands/process/info/info.d.ts.map +1 -1
  53. package/dist/src/commands/process/info/info.js +44 -4
  54. package/dist/src/commands/process/info/info.js.map +1 -1
  55. package/dist/src/commands/process/info/info.test.js +48 -0
  56. package/dist/src/commands/process/info/info.test.js.map +1 -1
  57. package/dist/src/commands/process/launch/launch.d.ts.map +1 -1
  58. package/dist/src/commands/process/launch/launch.js +22 -3
  59. package/dist/src/commands/process/launch/launch.js.map +1 -1
  60. package/dist/src/commands/process/launch/launch.test.js +27 -5
  61. package/dist/src/commands/process/launch/launch.test.js.map +1 -1
  62. package/dist/src/commands/process/list/list.d.ts +49 -6
  63. package/dist/src/commands/process/list/list.d.ts.map +1 -1
  64. package/dist/src/commands/process/list/list.js +185 -18
  65. package/dist/src/commands/process/list/list.js.map +1 -1
  66. package/dist/src/commands/process/list/list.test.js +92 -1
  67. package/dist/src/commands/process/list/list.test.js.map +1 -1
  68. package/dist/src/commands/process/run/run.d.ts +1 -1
  69. package/dist/src/commands/process/run/run.d.ts.map +1 -1
  70. package/dist/src/commands/process/run/run.js +3 -3
  71. package/dist/src/commands/process/run/run.js.map +1 -1
  72. package/dist/src/docker/docker-delegator.js +1 -1
  73. package/dist/src/docker/docker-delegator.js.map +1 -1
  74. package/dist/src/docker/docker-delegator.test.js +1 -1
  75. package/dist/src/docker/docker-delegator.test.js.map +1 -1
  76. package/dist/src/plugin/playtest-bridge.d.ts +36 -0
  77. package/dist/src/plugin/playtest-bridge.d.ts.map +1 -0
  78. package/dist/src/plugin/playtest-bridge.js +65 -0
  79. package/dist/src/plugin/playtest-bridge.js.map +1 -0
  80. package/dist/src/plugin/playtest-bridge.test.d.ts +2 -0
  81. package/dist/src/plugin/playtest-bridge.test.d.ts.map +1 -0
  82. package/dist/src/plugin/playtest-bridge.test.js +35 -0
  83. package/dist/src/plugin/playtest-bridge.test.js.map +1 -0
  84. package/dist/src/process/studio-launch-registry.d.ts +14 -0
  85. package/dist/src/process/studio-launch-registry.d.ts.map +1 -0
  86. package/dist/src/process/studio-launch-registry.js +37 -0
  87. package/dist/src/process/studio-launch-registry.js.map +1 -0
  88. package/dist/src/process/studio-launch-registry.test.d.ts +2 -0
  89. package/dist/src/process/studio-launch-registry.test.d.ts.map +1 -0
  90. package/dist/src/process/studio-launch-registry.test.js +38 -0
  91. package/dist/src/process/studio-launch-registry.test.js.map +1 -0
  92. package/dist/src/process/studio-process-closer.d.ts +3 -1
  93. package/dist/src/process/studio-process-closer.d.ts.map +1 -1
  94. package/dist/src/process/studio-process-closer.js +29 -11
  95. package/dist/src/process/studio-process-closer.js.map +1 -1
  96. package/dist/src/process/studio-process-identity.d.ts +10 -0
  97. package/dist/src/process/studio-process-identity.d.ts.map +1 -0
  98. package/dist/src/process/studio-process-identity.js +63 -0
  99. package/dist/src/process/studio-process-identity.js.map +1 -0
  100. package/dist/src/process/studio-process-identity.test.d.ts +2 -0
  101. package/dist/src/process/studio-process-identity.test.d.ts.map +1 -0
  102. package/dist/src/process/studio-process-identity.test.js +30 -0
  103. package/dist/src/process/studio-process-identity.test.js.map +1 -0
  104. package/dist/src/roblox-mcp/index.d.ts +3 -0
  105. package/dist/src/roblox-mcp/index.d.ts.map +1 -0
  106. package/dist/src/roblox-mcp/index.js +3 -0
  107. package/dist/src/roblox-mcp/index.js.map +1 -0
  108. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts +60 -0
  109. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.d.ts.map +1 -0
  110. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js +171 -0
  111. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.js.map +1 -0
  112. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts +2 -0
  113. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.d.ts.map +1 -0
  114. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js +104 -0
  115. package/dist/src/roblox-mcp/mcp/mcp-stdio-client.test.js.map +1 -0
  116. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts +2 -0
  117. package/dist/src/roblox-mcp/mcp/repair-list-result.d.ts.map +1 -0
  118. package/dist/src/roblox-mcp/mcp/repair-list-result.js +22 -0
  119. package/dist/src/roblox-mcp/mcp/repair-list-result.js.map +1 -0
  120. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts +2 -0
  121. package/dist/src/roblox-mcp/mcp/repair-list-result.test.d.ts.map +1 -0
  122. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js +42 -0
  123. package/dist/src/roblox-mcp/mcp/repair-list-result.test.js.map +1 -0
  124. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts +34 -0
  125. package/dist/src/roblox-mcp/roblox-studio-mcp.d.ts.map +1 -0
  126. package/dist/src/roblox-mcp/roblox-studio-mcp.js +190 -0
  127. package/dist/src/roblox-mcp/roblox-studio-mcp.js.map +1 -0
  128. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts +2 -0
  129. package/dist/src/roblox-mcp/roblox-studio-mcp.test.d.ts.map +1 -0
  130. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js +123 -0
  131. package/dist/src/roblox-mcp/roblox-studio-mcp.test.js.map +1 -0
  132. package/dist/src/roblox-mcp/studio/studio-session.d.ts +39 -0
  133. package/dist/src/roblox-mcp/studio/studio-session.d.ts.map +1 -0
  134. package/dist/src/roblox-mcp/studio/studio-session.js +175 -0
  135. package/dist/src/roblox-mcp/studio/studio-session.js.map +1 -0
  136. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts +2 -0
  137. package/dist/src/roblox-mcp/studio/studio-session.test.d.ts.map +1 -0
  138. package/dist/src/roblox-mcp/studio/studio-session.test.js +213 -0
  139. package/dist/src/roblox-mcp/studio/studio-session.test.js.map +1 -0
  140. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts +19 -0
  141. package/dist/src/roblox-mcp/studio-mcp-locator.d.ts.map +1 -0
  142. package/dist/src/roblox-mcp/studio-mcp-locator.js +121 -0
  143. package/dist/src/roblox-mcp/studio-mcp-locator.js.map +1 -0
  144. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts +2 -0
  145. package/dist/src/roblox-mcp/studio-mcp-locator.test.d.ts.map +1 -0
  146. package/dist/src/roblox-mcp/studio-mcp-locator.test.js +107 -0
  147. package/dist/src/roblox-mcp/studio-mcp-locator.test.js.map +1 -0
  148. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts +23 -0
  149. package/dist/src/roblox-mcp/studio-mcp-snapshot.d.ts.map +1 -0
  150. package/dist/src/roblox-mcp/studio-mcp-snapshot.js +65 -0
  151. package/dist/src/roblox-mcp/studio-mcp-snapshot.js.map +1 -0
  152. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts +2 -0
  153. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.d.ts.map +1 -0
  154. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js +81 -0
  155. package/dist/src/roblox-mcp/studio-mcp-snapshot.test.js.map +1 -0
  156. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts +27 -0
  157. package/dist/src/roblox-mcp/test/fake-mcp-server.d.ts.map +1 -0
  158. package/dist/src/roblox-mcp/test/fake-mcp-server.js +55 -0
  159. package/dist/src/roblox-mcp/test/fake-mcp-server.js.map +1 -0
  160. package/dist/src/server/web-socket-protocol.d.ts +50 -15
  161. package/dist/src/server/web-socket-protocol.d.ts.map +1 -1
  162. package/dist/src/server/web-socket-protocol.js +63 -13
  163. package/dist/src/server/web-socket-protocol.js.map +1 -1
  164. package/dist/tsconfig.tsbuildinfo +1 -1
  165. package/docs/playtest-bridge.md +21 -0
  166. package/package.json +5 -5
  167. package/src/bridge/bridge-connection.ts +1 -0
  168. package/src/bridge/bridge-session.test.ts +51 -0
  169. package/src/bridge/bridge-session.ts +66 -47
  170. package/src/bridge/internal/bridge-host.ts +3 -0
  171. package/src/bridge/internal/host-protocol.ts +22 -9
  172. package/src/bridge/types.ts +5 -0
  173. package/src/commands/console/exec/exec.test.ts +94 -0
  174. package/src/commands/console/exec/exec.ts +130 -16
  175. package/src/commands/console/exec/execute.lua +147 -5
  176. package/src/commands/console/exec/execute.spec.lua +22 -2
  177. package/src/commands/framework/action-loader.test.ts +1 -1
  178. package/src/commands/playtest/player/playtest-players.lua +127 -0
  179. package/src/commands/playtest/player/playtest-players.spec.lua +48 -0
  180. package/src/commands/playtest/player/playtest-players.test.ts +40 -21
  181. package/src/commands/playtest/player/playtest-players.ts +30 -110
  182. package/src/commands/playtest/playtest-sessions.test.ts +116 -14
  183. package/src/commands/playtest/playtest-sessions.ts +77 -88
  184. package/src/commands/playtest/run/run.ts +44 -58
  185. package/src/commands/playtest/start/start-playtest.lua +105 -6
  186. package/src/commands/process/close/close.test.ts +52 -3
  187. package/src/commands/process/close/close.ts +78 -4
  188. package/src/commands/process/info/info.test.ts +76 -0
  189. package/src/commands/process/info/info.ts +74 -6
  190. package/src/commands/process/launch/launch.test.ts +33 -5
  191. package/src/commands/process/launch/launch.ts +41 -3
  192. package/src/commands/process/list/list.test.ts +130 -1
  193. package/src/commands/process/list/list.ts +290 -22
  194. package/src/commands/process/run/run.ts +4 -4
  195. package/src/docker/docker-delegator.test.ts +1 -1
  196. package/src/docker/docker-delegator.ts +1 -1
  197. package/src/plugin/playtest-bridge.test.ts +53 -0
  198. package/src/plugin/playtest-bridge.ts +98 -0
  199. package/src/process/studio-launch-registry.test.ts +56 -0
  200. package/src/process/studio-launch-registry.ts +63 -0
  201. package/src/process/studio-process-closer.ts +36 -11
  202. package/src/process/studio-process-identity.test.ts +45 -0
  203. package/src/process/studio-process-identity.ts +90 -0
  204. package/src/roblox-mcp/index.ts +17 -0
  205. package/src/roblox-mcp/mcp/mcp-stdio-client.test.ts +148 -0
  206. package/src/roblox-mcp/mcp/mcp-stdio-client.ts +270 -0
  207. package/src/roblox-mcp/mcp/repair-list-result.test.ts +52 -0
  208. package/src/roblox-mcp/mcp/repair-list-result.ts +23 -0
  209. package/src/roblox-mcp/roblox-studio-mcp.test.ts +168 -0
  210. package/src/roblox-mcp/roblox-studio-mcp.ts +330 -0
  211. package/src/roblox-mcp/studio/studio-session.test.ts +284 -0
  212. package/src/roblox-mcp/studio/studio-session.ts +279 -0
  213. package/src/roblox-mcp/studio-mcp-locator.test.ts +186 -0
  214. package/src/roblox-mcp/studio-mcp-locator.ts +180 -0
  215. package/src/roblox-mcp/studio-mcp-snapshot.test.ts +113 -0
  216. package/src/roblox-mcp/studio-mcp-snapshot.ts +104 -0
  217. package/src/roblox-mcp/test/fake-mcp-server.ts +74 -0
  218. package/src/server/web-socket-protocol.ts +119 -29
  219. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.client.lua +32 -0
  220. package/templates/studio-bridge-plugin/playtest/StudioBridgePlaytest.server.lua +40 -0
  221. package/templates/studio-bridge-plugin/src/Shared/ActionRouter.lua +25 -3
  222. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.lua +47 -0
  223. package/templates/studio-bridge-plugin/src/Shared/PrecompiledSources.spec.lua +57 -0
  224. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeClient.lua +357 -0
  225. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.lua +29 -3
  226. package/templates/studio-bridge-plugin/src/Shared/StudioBridgeSessionUtils.spec.lua +35 -2
  227. package/templates/studio-bridge-plugin/src/StudioBridgePlugin.server.lua +16 -292
  228. package/src/commands/playtest/run/playtest-runner.luau +0 -264
  229. package/src/commands/playtest/run/playtest-runner.spec.lua +0 -71
  230. package/src/commands/playtest/run/run-playtest-script.lua +0 -139
  231. package/src/commands/playtest/run/run-playtest-script.spec.lua +0 -43
@@ -0,0 +1,47 @@
1
+ --!strict
2
+ --[=[
3
+ Compiles source by looking it up among ModuleScripts compiled ahead of time,
4
+ for the bridge planted into a playtest: an ordinary Script there has neither
5
+ `loadstring` nor the permission to set a ModuleScript's source.
6
+
7
+ The edit session plants one ModuleScript per source the playtest may run (the
8
+ actions and any script `playtest run` sends), each returning
9
+ `{ fn = function(...) <source> end, source = <source> }`. A source that was
10
+ not planted cannot run there.
11
+
12
+ @class PrecompiledSources
13
+ ]=]
14
+
15
+ local PrecompiledSources = {}
16
+
17
+ export type Entry = {
18
+ fn: (...any) -> ...any,
19
+ source: string,
20
+ }
21
+
22
+ --[=[
23
+ Returns a compiler for the sources planted under `folder`, for
24
+ `ActionRouter:SetCompiler`.
25
+ ]=]
26
+ function PrecompiledSources.createCompiler(folder: Instance): (source: string, chunkName: string?) -> (any, string?)
27
+ local bySource: { [string]: (...any) -> ...any } = {}
28
+ for _, child in folder:GetChildren() do
29
+ if child:IsA("ModuleScript") then
30
+ local ok, entry = pcall(require, child)
31
+ if ok and type(entry) == "table" and type(entry.fn) == "function" and type(entry.source) == "string" then
32
+ bySource[entry.source] = entry.fn
33
+ end
34
+ end
35
+ end
36
+
37
+ return function(source: string, _chunkName: string?): (any, string?)
38
+ local fn = bySource[source]
39
+ if fn then
40
+ return fn, nil
41
+ end
42
+ return nil,
43
+ 'This playtest session runs without plugin permissions, so it can only run code planted when the playtest started. Turn on Studio\'s "Load user plugins on playtest" setting to run any code in it.'
44
+ end
45
+ end
46
+
47
+ return PrecompiledSources
@@ -0,0 +1,57 @@
1
+ --!strict
2
+ --[[
3
+ @class PrecompiledSources.spec.lua
4
+ ]]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local Jest = require("Jest")
9
+ local PrecompiledSources = require("PrecompiledSources")
10
+
11
+ local describe = Jest.Globals.describe
12
+ local expect = Jest.Globals.expect
13
+ local it = Jest.Globals.it
14
+
15
+ local function plant(folder: Instance, name: string, moduleSource: string)
16
+ local moduleScript = Instance.new("ModuleScript")
17
+ moduleScript.Name = name
18
+ moduleScript.Source = moduleSource
19
+ moduleScript.Parent = folder
20
+ end
21
+
22
+ describe("PrecompiledSources.createCompiler()", function()
23
+ it("should compile a planted source to its function", function()
24
+ local folder = Instance.new("Folder")
25
+ plant(folder, "1", 'return { fn = function(...) return 1 + 2\nend, source = "return 1 + 2" }')
26
+
27
+ local fn = PrecompiledSources.createCompiler(folder)("return 1 + 2")
28
+
29
+ expect(fn()).toEqual(3)
30
+ end)
31
+
32
+ it("should pass arguments through", function()
33
+ local folder = Instance.new("Folder")
34
+ plant(folder, "1", 'return { fn = function(...) return ...\nend, source = "return ..." }')
35
+
36
+ local fn = PrecompiledSources.createCompiler(folder)("return ...")
37
+
38
+ expect(fn("a")).toEqual("a")
39
+ end)
40
+
41
+ it("should explain that a source that was not planted cannot run", function()
42
+ local fn, err = PrecompiledSources.createCompiler(Instance.new("Folder"))("print(1)")
43
+
44
+ expect(fn).toBeNil()
45
+ expect(err).toContain("Load user plugins on playtest")
46
+ end)
47
+
48
+ it("should skip a planted module that does not load", function()
49
+ local folder = Instance.new("Folder")
50
+ plant(folder, "broken", "return {")
51
+ plant(folder, "1", 'return { fn = function(...) return 1\nend, source = "return 1" }')
52
+
53
+ local fn = PrecompiledSources.createCompiler(folder)("return 1")
54
+
55
+ expect(fn()).toEqual(1)
56
+ end)
57
+ end)
@@ -0,0 +1,357 @@
1
+ --!strict
2
+ --[=[
3
+ The studio-bridge client: an action router, the log buffer `queryLogs` reads,
4
+ and the WebSocket to the bridge. Two entry points start it:
5
+
6
+ - The plugin (StudioBridgePlugin.server.lua), in every Studio DataModel it
7
+ loads into.
8
+ - The bridge planted into a playtest's server (StudioBridgePlaytest.server.lua),
9
+ an ordinary Script that runs whether or not Studio loads user plugins into
10
+ playtests. It has no plugin permissions, so it cannot compile code; it runs
11
+ the sources compiled for it ahead of time through the `compile` option.
12
+
13
+ It connects one of three ways:
14
+
15
+ - Ephemeral: `ephemeral` names the port and session id the CLI built the
16
+ plugin for.
17
+ - Playtest: `playtestArgs` names the bridge port, handed over through the
18
+ StudioTestService test args, since a playtest cannot make the HTTP requests
19
+ port discovery needs.
20
+ - Persistent: scans for a running bridge with DiscoveryStateMachine and
21
+ reconnects whenever it drops.
22
+
23
+ @class StudioBridgeClient
24
+ ]=]
25
+
26
+ local HttpService = game:GetService("HttpService")
27
+ local LogService = game:GetService("LogService")
28
+ local Players = game:GetService("Players")
29
+
30
+ local ActionRouter = require(script.Parent.ActionRouter)
31
+ local BuiltInActions = require(script.Parent.BuiltInActions)
32
+ local DiscoveryStateMachine = require(script.Parent.DiscoveryStateMachine)
33
+ local MessageBuffer = require(script.Parent.MessageBuffer)
34
+ local StudioBridgeConnection = require(script.Parent.StudioBridgeConnection)
35
+ local StudioBridgeLogUtils = require(script.Parent.StudioBridgeLogUtils)
36
+ local StudioBridgeSessionUtils = require(script.Parent.StudioBridgeSessionUtils)
37
+
38
+ local LOG_BUFFER_CAPACITY = 1000
39
+ local POLL_INTERVAL_SEC = 2
40
+
41
+ local LOG_LEVELS: { [Enum.MessageType]: string } = {
42
+ [Enum.MessageType.MessageOutput] = "Print",
43
+ [Enum.MessageType.MessageInfo] = "Info",
44
+ [Enum.MessageType.MessageWarning] = "Warning",
45
+ [Enum.MessageType.MessageError] = "Error",
46
+ }
47
+
48
+ local StudioBridgeClient = {}
49
+
50
+ export type Options = {
51
+ ephemeral: { port: string, sessionId: string }?,
52
+ playtestArgs: StudioBridgeSessionUtils.PlaytestArgs?,
53
+ compile: ActionRouter.Compiler?,
54
+ vendorPng: any?,
55
+ }
56
+
57
+ --[=[
58
+ Starts the client and returns a function that stops it. Socket callbacks and
59
+ the poll loop outlive the script that started them, so the owner must stop it
60
+ when it unloads.
61
+ ]=]
62
+ function StudioBridgeClient.start(options: Options): () -> ()
63
+ local playtestArgs = options.playtestArgs
64
+
65
+ local function createNonce(): string
66
+ return string.lower(string.sub(HttpService:GenerateGUID(false), 1, 4))
67
+ end
68
+
69
+ local context: StudioBridgeSessionUtils.PluginContext = StudioBridgeSessionUtils.getContext()
70
+ local nonceOk, nonce = pcall(StudioBridgeSessionUtils.getOrCreateNonce, game:GetService("CoreGui"), createNonce)
71
+ if not nonceOk then
72
+ nonce = createNonce()
73
+ end
74
+
75
+ -- Read on every connect: Studio can load plugins before it names the DataModel after the place
76
+ local function getInstanceId(): string
77
+ if playtestArgs then
78
+ return playtestArgs.instanceId
79
+ end
80
+ return StudioBridgeSessionUtils.getInstanceId(game.Name, nonce)
81
+ end
82
+
83
+ local function getPlayerName(): string?
84
+ local player = if context == "client" then Players.LocalPlayer else nil
85
+ return if player then player.Name else nil
86
+ end
87
+
88
+ -- The clients of a multiplayer test share an instance id, so their session ids name their player
89
+ local function getSessionId(): string
90
+ local isMultiplayerClient = playtestArgs and playtestArgs.multiplayer and context == "client"
91
+ return StudioBridgeSessionUtils.getSessionId(
92
+ getInstanceId(),
93
+ context,
94
+ if isMultiplayerClient then getPlayerName() else nil
95
+ )
96
+ end
97
+
98
+ local router = ActionRouter.new()
99
+ local logBuffer = MessageBuffer.new(LOG_BUFFER_CAPACITY)
100
+ local currentConnection: StudioBridgeConnection.StudioBridgeConnection? = nil
101
+ local currentWebSocket: any = nil
102
+ local discovery: DiscoveryStateMachine.DiscoveryStateMachine? = nil
103
+ local pollThread: thread? = nil
104
+ local nameConnection: RBXScriptConnection? = nil
105
+ local isStopped = false
106
+
107
+ if options.vendorPng then
108
+ router:SetVendorPng(options.vendorPng)
109
+ end
110
+ if options.compile then
111
+ router:SetCompiler(options.compile)
112
+ end
113
+
114
+ BuiltInActions.register(router, function(message)
115
+ if currentConnection then
116
+ currentConnection:Send(message)
117
+ end
118
+ end, logBuffer)
119
+
120
+ -- Hybrid clock: the wall clock at load, then os.clock() deltas, which are cheaper per entry
121
+ local clockBase = os.clock()
122
+ local timeBase = DateTime.now().UnixTimestampMillis / 1000
123
+
124
+ local logConnection = LogService.MessageOut:Connect(function(message, messageType)
125
+ if string.sub(message, 1, #StudioBridgeLogUtils.PREFIX) == StudioBridgeLogUtils.PREFIX then
126
+ return
127
+ end
128
+
129
+ logBuffer:Push({
130
+ level = LOG_LEVELS[messageType] or "Print",
131
+ body = message,
132
+ timestamp = timeBase + (os.clock() - clockBase),
133
+ })
134
+ end)
135
+
136
+ local function openConnection(webSocket: any, sessionId: string, label: string)
137
+ if isStopped then
138
+ return
139
+ end
140
+
141
+ if currentConnection then
142
+ currentConnection:Destroy()
143
+ end
144
+
145
+ currentConnection = StudioBridgeConnection.new({
146
+ webSocket = webSocket,
147
+ router = router,
148
+ sessionId = sessionId,
149
+ registerPayload = StudioBridgeSessionUtils.createRegisterPayload(
150
+ getInstanceId(),
151
+ context,
152
+ StudioBridgeSessionUtils.getLaunchId(workspace),
153
+ getPlayerName(),
154
+ if playtestArgs then playtestArgs.playtestId else nil
155
+ ),
156
+ })
157
+
158
+ StudioBridgeLogUtils.logVerbose(`Connected to {label} as {sessionId}`)
159
+ end
160
+
161
+ local function createWebSocket(url: string): (boolean, any)
162
+ local ok, webSocket = pcall(function()
163
+ return HttpService:CreateWebStreamClient(Enum.WebStreamClientType.WebSocket, { Url = url })
164
+ end)
165
+ if ok then
166
+ currentWebSocket = webSocket
167
+ end
168
+ return ok, webSocket
169
+ end
170
+
171
+ local function stop()
172
+ if isStopped then
173
+ return
174
+ end
175
+ isStopped = true
176
+ logConnection:Disconnect()
177
+ if nameConnection then
178
+ nameConnection:Disconnect()
179
+ nameConnection = nil
180
+ end
181
+
182
+ if pollThread then
183
+ task.cancel(pollThread)
184
+ pollThread = nil
185
+ end
186
+ if discovery then
187
+ discovery:Stop()
188
+ end
189
+ if currentConnection then
190
+ currentConnection:Destroy()
191
+ currentConnection = nil
192
+ end
193
+ if currentWebSocket then
194
+ pcall(function()
195
+ currentWebSocket:Close()
196
+ end)
197
+ currentWebSocket = nil
198
+ end
199
+
200
+ router:Destroy()
201
+ end
202
+
203
+ local function warnOnError(webSocket: any)
204
+ webSocket.Error:Connect(function(status, err)
205
+ StudioBridgeLogUtils.logVerbose(`WebSocket error ({status}): {err}`)
206
+ end)
207
+ end
208
+
209
+ local ephemeral = options.ephemeral
210
+ if ephemeral then
211
+ -- Every open Studio loads this plugin, but only the one launched for this run
212
+ -- has the session id stamped on its place, which may load after the plugin
213
+ local function connectEphemeral()
214
+ if StudioBridgeSessionUtils.getLaunchId(workspace) ~= ephemeral.sessionId or currentWebSocket then
215
+ return
216
+ end
217
+
218
+ local ok, webSocket = createWebSocket(`ws://localhost:{ephemeral.port}/{ephemeral.sessionId}`)
219
+ if not (ok and webSocket) then
220
+ StudioBridgeLogUtils.warn("Failed to create WebSocket client")
221
+ return
222
+ end
223
+
224
+ webSocket.Opened:Connect(function()
225
+ openConnection(webSocket, ephemeral.sessionId, `localhost:{ephemeral.port} (ephemeral)`)
226
+ end)
227
+ warnOnError(webSocket)
228
+ end
229
+
230
+ nameConnection = workspace:GetAttributeChangedSignal("StudioBridgeSessionId"):Connect(connectEphemeral)
231
+ connectEphemeral()
232
+ return stop
233
+ end
234
+
235
+ -- A multiplayer client's session id names its player, so wait for one
236
+ if playtestArgs and playtestArgs.multiplayer and context == "client" then
237
+ while not Players.LocalPlayer do
238
+ Players:GetPropertyChangedSignal("LocalPlayer"):Wait()
239
+ end
240
+ end
241
+
242
+ local playtestPort = if playtestArgs then playtestArgs.port else nil
243
+
244
+ local stateMachine: DiscoveryStateMachine.DiscoveryStateMachine
245
+ stateMachine = DiscoveryStateMachine.new(nil, {
246
+ scanPortsAsync = if playtestPort
247
+ then function()
248
+ return playtestPort, nil
249
+ end
250
+ else function(ports: { number }, timeoutSec: number)
251
+ return StudioBridgeClient._scanPortsAsync(ports, timeoutSec)
252
+ end,
253
+ connectWebSocket = createWebSocket,
254
+ onStateChange = function(oldState, newState)
255
+ if oldState == "idle" and newState == "searching" then
256
+ StudioBridgeLogUtils.logVerbose("Searching for host on ports 38741-38744...")
257
+ end
258
+ end,
259
+ onConnected = function(webSocket, port)
260
+ webSocket.Opened:Connect(function()
261
+ openConnection(webSocket, getSessionId(), `localhost:{port}`)
262
+ end)
263
+ webSocket.Closed:Connect(function()
264
+ stateMachine:HandleDisconnect("closed")
265
+ end)
266
+ warnOnError(webSocket)
267
+ end,
268
+ onDisconnected = function(reason)
269
+ StudioBridgeLogUtils.logVerbose(`Disconnected ({reason}), searching...`)
270
+ end,
271
+ })
272
+
273
+ discovery = stateMachine
274
+
275
+ stateMachine:Start()
276
+
277
+ -- Reconnecting re-registers under the session id for the new name. A
278
+ -- playtest's id comes from its test args, so a rename leaves it unchanged.
279
+ if not playtestArgs then
280
+ nameConnection = game:GetPropertyChangedSignal("Name"):Connect(function()
281
+ if currentWebSocket then
282
+ pcall(function()
283
+ currentWebSocket:Close()
284
+ end)
285
+ end
286
+ end)
287
+ end
288
+
289
+ -- Each cycle gets a fixed time budget, which PollAsync threads through to the port scan
290
+ pollThread = task.spawn(function()
291
+ while true do
292
+ local startTime = os.clock()
293
+ stateMachine:PollAsync(POLL_INTERVAL_SEC)
294
+
295
+ local remaining = POLL_INTERVAL_SEC - (os.clock() - startTime)
296
+ if remaining > 0 then
297
+ task.wait(remaining)
298
+ else
299
+ task.wait()
300
+ end
301
+ end
302
+ end)
303
+
304
+ return stop
305
+ end
306
+
307
+ --[[
308
+ Probes every port's /health endpoint in parallel and returns the first port
309
+ to answer, or nil once all fail or the timeout passes. The caller is resumed
310
+ with task.defer so it has reached coroutine.yield() first.
311
+ ]]
312
+ function StudioBridgeClient._scanPortsAsync(ports: { number }, timeoutSec: number): (number?, string?)
313
+ local callerThread = coroutine.running()
314
+ local foundPort: number? = nil
315
+ local foundBody: string? = nil
316
+ local remaining = #ports
317
+ local settled = false
318
+
319
+ local function settle()
320
+ if not settled then
321
+ settled = true
322
+ task.defer(callerThread)
323
+ end
324
+ end
325
+
326
+ local threads: { thread } = {}
327
+ for _, port in ports do
328
+ table.insert(
329
+ threads,
330
+ task.spawn(function()
331
+ local ok, body = pcall(HttpService.GetAsync, HttpService, `http://localhost:{port}/health`)
332
+ if ok and not settled then
333
+ foundPort = port
334
+ foundBody = body
335
+ settle()
336
+ return
337
+ end
338
+
339
+ remaining -= 1
340
+ if remaining <= 0 then
341
+ settle()
342
+ end
343
+ end)
344
+ )
345
+ end
346
+ table.insert(threads, task.delay(timeoutSec, settle))
347
+
348
+ coroutine.yield()
349
+
350
+ for _, thread in threads do
351
+ pcall(task.cancel, thread)
352
+ end
353
+
354
+ return foundPort, foundBody
355
+ end
356
+
357
+ return StudioBridgeClient
@@ -12,6 +12,8 @@ local PLUGIN_VERSION = "{{PLUGIN_VERSION}}"
12
12
  local MAX_SLUG_LENGTH = 24
13
13
  local NONCE_VALUE_NAME = "StudioBridgeNonce"
14
14
  local LAUNCH_ID_ATTRIBUTE = "StudioBridgeSessionId"
15
+ local PLANTED_BRIDGE_NAME = "StudioBridgePlaytest"
16
+ local PLAYTEST_ID_ATTRIBUTE = "PlaytestId"
15
17
 
16
18
  local StudioBridgeSessionUtils = {}
17
19
 
@@ -21,6 +23,7 @@ export type PlaytestArgs = {
21
23
  port: number,
22
24
  instanceId: string,
23
25
  multiplayer: boolean,
26
+ playtestId: string?,
24
27
  }
25
28
 
26
29
  --[=[
@@ -45,6 +48,7 @@ StudioBridgeSessionUtils.CAPABILITIES = table.freeze({
45
48
  "grepScripts",
46
49
  "startPlaytest",
47
50
  "endPlaytest",
51
+ "playtestPlayers",
48
52
  })
49
53
 
50
54
  --[=[
@@ -115,7 +119,8 @@ function StudioBridgeSessionUtils.createRegisterPayload(
115
119
  instanceId: string,
116
120
  context: PluginContext,
117
121
  launchId: string?,
118
- playerName: string?
122
+ playerName: string?,
123
+ playtestId: string?
119
124
  ): { [string]: any }
120
125
  return {
121
126
  pluginVersion = PLUGIN_VERSION,
@@ -123,6 +128,7 @@ function StudioBridgeSessionUtils.createRegisterPayload(
123
128
  context = context,
124
129
  launchId = launchId,
125
130
  playerName = playerName,
131
+ playtestId = playtestId,
126
132
  placeName = game.Name or "Unknown",
127
133
  placeId = game.PlaceId,
128
134
  gameId = game.GameId,
@@ -143,8 +149,8 @@ end
143
149
  --[=[
144
150
  Returns what studio-bridge handed a playtest through its StudioTestService
145
151
  test args: the bridge port, the instance id of the edit session that
146
- started it, and whether it is a multiplayer test. Nil when studio-bridge
147
- did not start the playtest.
152
+ started it, whether it is a multiplayer test, and the playtest id. Nil when
153
+ studio-bridge did not start the playtest.
148
154
  ]=]
149
155
  function StudioBridgeSessionUtils.getPlaytestArgs(testArgs: any): PlaytestArgs?
150
156
  if
@@ -156,9 +162,29 @@ function StudioBridgeSessionUtils.getPlaytestArgs(testArgs: any): PlaytestArgs?
156
162
  port = testArgs.studioBridgePort,
157
163
  instanceId = testArgs.studioBridgeInstanceId,
158
164
  multiplayer = type(testArgs.studioBridgePlayers) == "number",
165
+ playtestId = if type(testArgs.studioBridgePlaytestId) == "string"
166
+ then testArgs.studioBridgePlaytestId
167
+ else nil,
159
168
  }
160
169
  end
161
170
  return nil
162
171
  end
163
172
 
173
+ --[=[
174
+ Returns the bridge planted under `container` for the playtest `playtestId`,
175
+ or nil. The edit session plants it when Studio does not load user plugins
176
+ into playtests, and a playtest copies it from the edit DataModel.
177
+ ]=]
178
+ function StudioBridgeSessionUtils.findPlantedBridge(container: Instance, playtestId: string?): Instance?
179
+ if not playtestId then
180
+ return nil
181
+ end
182
+
183
+ local planted = container:FindFirstChild(PLANTED_BRIDGE_NAME)
184
+ if planted and planted:GetAttribute(PLAYTEST_ID_ATTRIBUTE) == playtestId then
185
+ return planted
186
+ end
187
+ return nil
188
+ end
189
+
164
190
  return StudioBridgeSessionUtils
@@ -82,12 +82,12 @@ describe("StudioBridgeSessionUtils.getLaunchId()", function()
82
82
  end)
83
83
 
84
84
  describe("StudioBridgeSessionUtils.getPlaytestArgs()", function()
85
- it("should read the port and instance id studio-bridge passed in the test args", function()
85
+ it("should read the port, instance id and playtest id studio-bridge passed in the test args", function()
86
86
  expect(StudioBridgeSessionUtils.getPlaytestArgs({
87
87
  studioBridgePlaytestId = "abc",
88
88
  studioBridgePort = 38741,
89
89
  studioBridgeInstanceId = "test-place-3f9a",
90
- })).toEqual({ port = 38741, instanceId = "test-place-3f9a", multiplayer = false })
90
+ })).toEqual({ port = 38741, instanceId = "test-place-3f9a", multiplayer = false, playtestId = "abc" })
91
91
  end)
92
92
 
93
93
  it("should report a multiplayer test", function()
@@ -149,6 +149,12 @@ describe("StudioBridgeSessionUtils.createRegisterPayload()", function()
149
149
  expect(payload.playerName).toEqual("Player2")
150
150
  end)
151
151
 
152
+ it("should carry the playtest id of a playtest studio-bridge started", function()
153
+ local payload = StudioBridgeSessionUtils.createRegisterPayload("test-place-3f9a", "server", nil, nil, "abc")
154
+
155
+ expect(payload.playtestId).toEqual("abc")
156
+ end)
157
+
152
158
  it("should hand out a capabilities list the caller can change", function()
153
159
  local payload = StudioBridgeSessionUtils.createRegisterPayload("test-place-3f9a", "edit")
154
160
 
@@ -157,3 +163,30 @@ describe("StudioBridgeSessionUtils.createRegisterPayload()", function()
157
163
  expect(table.find(StudioBridgeSessionUtils.CAPABILITIES, "extra")).toBeNil()
158
164
  end)
159
165
  end)
166
+
167
+ describe("StudioBridgeSessionUtils.findPlantedBridge()", function()
168
+ local function plant(playtestId: string): Folder
169
+ local container = Instance.new("Folder")
170
+ local planted = Instance.new("Folder")
171
+ planted.Name = "StudioBridgePlaytest"
172
+ planted:SetAttribute("PlaytestId", playtestId)
173
+ planted.Parent = container
174
+ return container
175
+ end
176
+
177
+ it("should find the bridge planted for the playtest", function()
178
+ local container = plant("abc")
179
+
180
+ expect(StudioBridgeSessionUtils.findPlantedBridge(container, "abc")).toBe(
181
+ container:FindFirstChild("StudioBridgePlaytest")
182
+ )
183
+ end)
184
+
185
+ it("should ignore a bridge planted for another playtest", function()
186
+ expect(StudioBridgeSessionUtils.findPlantedBridge(plant("old"), "abc")).toBeNil()
187
+ end)
188
+
189
+ it("should find nothing without a playtest id", function()
190
+ expect(StudioBridgeSessionUtils.findPlantedBridge(plant("abc"), nil)).toBeNil()
191
+ end)
192
+ end)