azul-sync 1.3.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 (133) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. package/README.md +142 -0
  5. package/dist/build.d.ts +19 -0
  6. package/dist/build.d.ts.map +1 -0
  7. package/dist/build.js +92 -0
  8. package/dist/build.js.map +1 -0
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +397 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/config.d.ts +26 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +105 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/fs/fileWriter.d.ts +100 -0
  18. package/dist/fs/fileWriter.d.ts.map +1 -0
  19. package/dist/fs/fileWriter.js +342 -0
  20. package/dist/fs/fileWriter.js.map +1 -0
  21. package/dist/fs/treeManager.d.ts +84 -0
  22. package/dist/fs/treeManager.d.ts.map +1 -0
  23. package/dist/fs/treeManager.js +365 -0
  24. package/dist/fs/treeManager.js.map +1 -0
  25. package/dist/fs/watcher.d.ts +39 -0
  26. package/dist/fs/watcher.d.ts.map +1 -0
  27. package/dist/fs/watcher.js +120 -0
  28. package/dist/fs/watcher.js.map +1 -0
  29. package/dist/index.d.ts +61 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +349 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/ipc/httpPolling.d.ts +56 -0
  34. package/dist/ipc/httpPolling.d.ts.map +1 -0
  35. package/dist/ipc/httpPolling.js +171 -0
  36. package/dist/ipc/httpPolling.js.map +1 -0
  37. package/dist/ipc/messages.d.ts +112 -0
  38. package/dist/ipc/messages.d.ts.map +1 -0
  39. package/dist/ipc/messages.js +5 -0
  40. package/dist/ipc/messages.js.map +1 -0
  41. package/dist/ipc/server.d.ts +50 -0
  42. package/dist/ipc/server.d.ts.map +1 -0
  43. package/dist/ipc/server.js +168 -0
  44. package/dist/ipc/server.js.map +1 -0
  45. package/dist/pack.d.ts +19 -0
  46. package/dist/pack.d.ts.map +1 -0
  47. package/dist/pack.js +225 -0
  48. package/dist/pack.js.map +1 -0
  49. package/dist/push.d.ts +43 -0
  50. package/dist/push.d.ts.map +1 -0
  51. package/dist/push.js +532 -0
  52. package/dist/push.js.map +1 -0
  53. package/dist/rojo.d.ts +9 -0
  54. package/dist/rojo.d.ts.map +1 -0
  55. package/dist/rojo.js +114 -0
  56. package/dist/rojo.js.map +1 -0
  57. package/dist/snapshot/rojo.d.ts +39 -0
  58. package/dist/snapshot/rojo.d.ts.map +1 -0
  59. package/dist/snapshot/rojo.js +364 -0
  60. package/dist/snapshot/rojo.js.map +1 -0
  61. package/dist/snapshot.d.ts +23 -0
  62. package/dist/snapshot.d.ts.map +1 -0
  63. package/dist/snapshot.js +132 -0
  64. package/dist/snapshot.js.map +1 -0
  65. package/dist/sourcemap/generator.d.ts +78 -0
  66. package/dist/sourcemap/generator.d.ts.map +1 -0
  67. package/dist/sourcemap/generator.js +351 -0
  68. package/dist/sourcemap/generator.js.map +1 -0
  69. package/dist/sourcemap/propertyLoader.d.ts +19 -0
  70. package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
  71. package/dist/sourcemap/propertyLoader.js +131 -0
  72. package/dist/sourcemap/propertyLoader.js.map +1 -0
  73. package/dist/util/id.d.ts +9 -0
  74. package/dist/util/id.d.ts.map +1 -0
  75. package/dist/util/id.js +14 -0
  76. package/dist/util/id.js.map +1 -0
  77. package/dist/util/log.d.ts +13 -0
  78. package/dist/util/log.d.ts.map +1 -0
  79. package/dist/util/log.js +51 -0
  80. package/dist/util/log.js.map +1 -0
  81. package/docs/assets/azul-logo.pdn +0 -0
  82. package/docs/assets/logo-200px.png +0 -0
  83. package/docs/assets/logo.png +0 -0
  84. package/docs/assets/plugin/toolbox.png +0 -0
  85. package/docs/assets/synced.png +0 -0
  86. package/package.json +41 -0
  87. package/plugin/README.md +54 -0
  88. package/plugin/sourcemap.json +264 -0
  89. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
  90. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
  91. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
  92. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
  93. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
  94. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
  95. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
  96. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
  97. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
  98. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
  99. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
  100. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
  101. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
  102. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
  103. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
  104. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
  105. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
  106. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
  107. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
  108. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
  109. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
  110. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
  111. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
  112. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
  113. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
  114. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
  115. package/src/build.ts +120 -0
  116. package/src/cli.ts +496 -0
  117. package/src/config.ts +170 -0
  118. package/src/fs/fileWriter.ts +414 -0
  119. package/src/fs/treeManager.ts +458 -0
  120. package/src/fs/watcher.ts +142 -0
  121. package/src/index.ts +450 -0
  122. package/src/ipc/httpPolling.ts +214 -0
  123. package/src/ipc/messages.ts +159 -0
  124. package/src/ipc/server.ts +196 -0
  125. package/src/pack.ts +309 -0
  126. package/src/push.ts +726 -0
  127. package/src/snapshot/rojo.ts +467 -0
  128. package/src/snapshot.ts +161 -0
  129. package/src/sourcemap/generator.ts +504 -0
  130. package/src/sourcemap/propertyLoader.ts +195 -0
  131. package/src/util/id.ts +15 -0
  132. package/src/util/log.ts +94 -0
  133. package/tsconfig.json +24 -0
@@ -0,0 +1,905 @@
1
+ --!strict
2
+ --[[
3
+ Azul - Roblox Studio Plugin.
4
+ Companion plugin for Azul to sync scripts between Roblox Studio and external editors.
5
+
6
+ Ransomwave 2025
7
+ ]]
8
+
9
+ -- Services
10
+ local HttpService = game:GetService("HttpService")
11
+ local RunService = game:GetService("RunService")
12
+
13
+ -- Prevent from running if the game is being Playtested
14
+ if RunService:IsRunning() then return end
15
+
16
+ local pluginParent = script.Parent
17
+ if not pluginParent then return end
18
+ local azulPluginFolder = pluginParent.Parent
19
+ if not azulPluginFolder then return end
20
+
21
+ -- Modules
22
+ local WebSocketClient = require("../WebSocketClient")
23
+ local UI = require("../UI")
24
+ local CONFIG = require("../Config")
25
+ local Enums = require("../Enums")
26
+ local AzulService = require("../AzulService")
27
+
28
+ -- Settings
29
+ local PUSH_CONFIG_PATH = {
30
+ "ServerStorage",
31
+ "Azul",
32
+ "Config",
33
+ }
34
+
35
+ local SETTINGS_SCOPE = Enums.scope.GLOBAL -- or "project"
36
+ local SETTINGS_KEY = "AZUL_SETTINGS"
37
+ local SETTINGS_SCOPE_KEY = "AZUL_SETTINGS_SCOPE_BY_PLACE"
38
+
39
+ -- Logging helpers
40
+ local function debugPrint(...)
41
+ if CONFIG.SILENT_MODE or not CONFIG.DEBUG_MODE then return end
42
+ print(`[🐛 Azul]:`, ...)
43
+ end
44
+
45
+ local function infoPrint(...)
46
+ if CONFIG.SILENT_MODE then return end
47
+ print(`[Azul]: `, ...)
48
+ end
49
+
50
+ local function getScopeKey(scope)
51
+ local placeId = tostring(game.PlaceId or "0")
52
+ if scope == Enums.scope.PROJECT then return `project_{placeId}` end
53
+ return "global"
54
+ end
55
+
56
+ local function getPlaceScopeSelectionKey()
57
+ return tostring(game.PlaceId or "0")
58
+ end
59
+
60
+ local function saveSelectedScope()
61
+ local selectedScopes = plugin:GetSetting(SETTINGS_SCOPE_KEY)
62
+ if type(selectedScopes) ~= "table" then selectedScopes = {} end
63
+
64
+ selectedScopes[getPlaceScopeSelectionKey()] = SETTINGS_SCOPE
65
+ plugin:SetSetting(SETTINGS_SCOPE_KEY, selectedScopes)
66
+ end
67
+
68
+ local function loadSelectedScope()
69
+ local selectedScopes = plugin:GetSetting(SETTINGS_SCOPE_KEY)
70
+ if type(selectedScopes) ~= "table" then return end
71
+
72
+ local scope = selectedScopes[getPlaceScopeSelectionKey()]
73
+ if scope == Enums.scope.GLOBAL or scope == Enums.scope.PROJECT then SETTINGS_SCOPE = scope end
74
+ end
75
+
76
+ local _storedSettings = {}
77
+
78
+ -- Save settings
79
+ local function saveSettings()
80
+ local all = plugin:GetSetting(SETTINGS_KEY)
81
+ if type(all) ~= "table" then all = {} end
82
+
83
+ local scopeKey = getScopeKey(SETTINGS_SCOPE)
84
+ local scopedCopy = {}
85
+ for key, value in pairs(CONFIG) do
86
+ scopedCopy[key] = value
87
+ end
88
+
89
+ -- Always persist debug/silent at the global level
90
+ local globalScoped = all["global"]
91
+ if type(globalScoped) ~= "table" then globalScoped = {} end
92
+ globalScoped.DEBUG_MODE = CONFIG.DEBUG_MODE
93
+ globalScoped.SILENT_MODE = CONFIG.SILENT_MODE
94
+ all["global"] = globalScoped
95
+
96
+ all[scopeKey] = scopedCopy
97
+ _storedSettings = all
98
+ plugin:SetSetting(SETTINGS_KEY, all)
99
+ end
100
+
101
+ local function loadSettings()
102
+ local all = plugin:GetSetting(SETTINGS_KEY)
103
+ if type(all) ~= "table" then
104
+ all = {}
105
+ -- Legacy flat settings fallback
106
+ for key, _ in pairs(CONFIG) do
107
+ local legacyValue = plugin:GetSetting(key)
108
+ if legacyValue ~= nil then CONFIG[key] = legacyValue end
109
+ end
110
+ _storedSettings = all
111
+ return
112
+ end
113
+
114
+ _storedSettings = all
115
+ local scopeKey = getScopeKey(SETTINGS_SCOPE)
116
+ local scoped = all[scopeKey]
117
+ local globalScoped = all["global"]
118
+
119
+ if type(scoped) ~= "table" then scoped = {} end
120
+ if type(globalScoped) ~= "table" then globalScoped = {} end
121
+
122
+ for key, _ in pairs(CONFIG) do
123
+ local value = scoped[key]
124
+ if value == nil and scopeKey ~= "global" then value = globalScoped[key] end
125
+ if value ~= nil then CONFIG[key] = value end
126
+ end
127
+
128
+ -- DEBUG_MODE and SILENT_MODE always come from global
129
+ if globalScoped.DEBUG_MODE ~= nil then CONFIG.DEBUG_MODE = globalScoped.DEBUG_MODE end
130
+ if globalScoped.SILENT_MODE ~= nil then CONFIG.SILENT_MODE = globalScoped.SILENT_MODE end
131
+ end
132
+
133
+ loadSelectedScope()
134
+ loadSettings()
135
+
136
+ -- Sync state
137
+ local syncEnabled = false
138
+ local wsClient = nil
139
+ local trackedInstances = {}
140
+ local guidMap = {}
141
+ local usedGuids = {}
142
+ local lastHeartbeat = 0
143
+ local applyingPatch = false
144
+ local lastPatchTime = {} -- Track last patch time per GUID to prevent loops
145
+ local recentPatches = {} -- Track which scripts were recently patched from daemon
146
+ local lastInstanceUpdate = {} -- Deduplicate identical rapid instanceUpdated payloads per GUID
147
+ local connections = {}
148
+ local suppressOutbound = false -- Prevent emitting events while applying inbound patches
149
+
150
+ local pendingInstanceUpdates = {}
151
+ local pendingScriptChanges = {}
152
+ local pendingScriptChangeReady = {}
153
+ local scriptChangeDebounceTokenByGuid = {}
154
+ local pendingDeletes = {}
155
+
156
+ local batchingEnabled = false
157
+ local batchFlushToken = 0 -- Incremented to invalidate pending flush timers
158
+ local batchAccumulator = 0
159
+
160
+ local BATCH_INTERVAL = 0.2 -- Seconds between outbound batch flushes
161
+ local BATCH_IDLE_WINDOW = 0.1 -- Seconds of inactivity before sending batch
162
+ local SCRIPT_CHANGE_DEBOUNCE = 0.35 -- Quiet window before script source changes are eligible to send
163
+
164
+ -- Utility: Check if instance is a script
165
+ local function isScript(instance)
166
+ return instance:IsA("Script") or instance:IsA("LocalScript") or instance:IsA("ModuleScript")
167
+ end
168
+
169
+ local serviceSet = {}
170
+
171
+ AzulService.rebuildServiceSet(serviceSet)
172
+
173
+ -- Utility: Check if instance should be included in snapshot (all instances)
174
+ local function shouldIncludeInSnapshot(instance)
175
+ if not instance then return false end
176
+
177
+ return not AzulService.isExcluded(instance, serviceSet)
178
+ end
179
+
180
+ -- Send message to daemon
181
+ local function sendMessage(messageTypeOrPayload, data: any?)
182
+ if not wsClient or not wsClient.connected then return false end
183
+
184
+ local message
185
+ if type(messageTypeOrPayload) == "table" then
186
+ message = {}
187
+ for key, value in pairs(messageTypeOrPayload) do
188
+ message[key] = value
189
+ end
190
+ else
191
+ message = {
192
+ type = messageTypeOrPayload,
193
+ }
194
+ for key, value in pairs(data or {}) do
195
+ message[key] = value
196
+ end
197
+ end
198
+
199
+ debugPrint(`Sending message: {message.type}`)
200
+
201
+ local json = HttpService:JSONEncode(message)
202
+ return wsClient:send(json)
203
+ end
204
+
205
+ -- Toggle outbound emission (used while applying inbound patches)
206
+ local function setOutboundSuppressed(suppressed: boolean)
207
+ suppressOutbound = suppressed
208
+ if suppressed then
209
+ -- Drop any pending outbound changes to avoid echoing daemon writes
210
+ table.clear(pendingInstanceUpdates)
211
+ table.clear(pendingScriptChanges)
212
+ table.clear(pendingScriptChangeReady)
213
+ table.clear(scriptChangeDebounceTokenByGuid)
214
+ table.clear(pendingDeletes)
215
+ end
216
+ end
217
+
218
+ -- Send full snapshot
219
+ local function sendFullSnapshot(options: { includeProperties: boolean, scriptsAndDescendantsOnly: boolean }?)
220
+ infoPrint("Sending full snapshot...")
221
+
222
+ local includeProperties = options and options.includeProperties == true
223
+ local scriptsAndDescendantsOnly = options and options.scriptsAndDescendantsOnly == true
224
+ local includeAncestorsOfScripts: { [Instance]: boolean } = {}
225
+
226
+ if scriptsAndDescendantsOnly then
227
+ for _, descendant in ipairs(game:GetDescendants()) do
228
+ if isScript(descendant) and shouldIncludeInSnapshot(descendant) then
229
+ local current: Instance? = descendant
230
+ while current and current ~= game do
231
+ if shouldIncludeInSnapshot(current) then includeAncestorsOfScripts[current] = true end
232
+ current = current.Parent
233
+ end
234
+ end
235
+ end
236
+ end
237
+
238
+ -- Reset tracking to ensure fresh GUID deduping
239
+ trackedInstances = {}
240
+ guidMap = {}
241
+ usedGuids = {}
242
+
243
+ local instances = {}
244
+ local scriptCount = 0
245
+ local batchSize = 400 -- Yield periodically to avoid plugin thread timeouts
246
+
247
+ -- Build a queue to iterate without deep recursion
248
+ local queue = {}
249
+ for _, service in ipairs(game:GetChildren()) do
250
+ if shouldIncludeInSnapshot(service) then
251
+ table.insert(queue, {
252
+ instance = service,
253
+ hasScriptAncestor = false,
254
+ })
255
+ end
256
+ end
257
+
258
+ local index = 1
259
+ while index <= #queue do
260
+ if not syncEnabled then
261
+ infoPrint("Snapshot aborted - sync disabled")
262
+ return
263
+ end
264
+
265
+ local queued = queue[index]
266
+ local instance = queued.instance
267
+ local hasScriptAncestor = queued.hasScriptAncestor == true
268
+
269
+ local includeByMode = true
270
+ if scriptsAndDescendantsOnly then
271
+ includeByMode = isScript(instance) or hasScriptAncestor or includeAncestorsOfScripts[instance] == true
272
+ end
273
+
274
+ if shouldIncludeInSnapshot(instance) and includeByMode then
275
+ local data = AzulService.instanceToData(instance, trackedInstances, guidMap, usedGuids, {
276
+ includeProperties = includeProperties,
277
+ })
278
+ if data then
279
+ table.insert(instances, data)
280
+
281
+ trackedInstances[instance] = data.guid
282
+ guidMap[data.guid] = instance
283
+
284
+ if isScript(instance) then
285
+ scriptCount += 1
286
+ end
287
+ end
288
+ end
289
+
290
+ -- Enqueue children that are eligible for traversal
291
+ for _, child in ipairs(instance:GetChildren()) do
292
+ if shouldIncludeInSnapshot(child) then
293
+ table.insert(queue, {
294
+ instance = child,
295
+ hasScriptAncestor = hasScriptAncestor or isScript(instance),
296
+ })
297
+ end
298
+ end
299
+
300
+ -- Yield periodically to avoid hitting Studio's execution time limit
301
+ if index % batchSize == 0 then task.wait() end
302
+
303
+ index += 1
304
+ end
305
+
306
+ -- Send snapshot
307
+ sendMessage("fullSnapshot", { data = instances })
308
+ infoPrint("Snapshot sent:", #instances, "instances (", scriptCount, "scripts )")
309
+
310
+ if syncEnabled then
311
+ if includeProperties or scriptsAndDescendantsOnly then
312
+ debugPrint(
313
+ `Snapshot sent with options (includeProperties={tostring(includeProperties)}, scriptsAndDescendantsOnly={tostring(
314
+ scriptsAndDescendantsOnly
315
+ )}) - stopping sync since "pack" is one-shot.`
316
+ )
317
+ stopSync()
318
+ end
319
+
320
+ batchingEnabled = true
321
+ batchFlushToken += 1 -- Invalidate any pre-snapshot timers
322
+ batchAccumulator = 0
323
+ end
324
+ end
325
+
326
+ -- Queue and batch outbound messages to reduce chatter
327
+ local function flushOutboundQueues()
328
+ local messages: { [number]: { type: string, data: any } } = {}
329
+
330
+ for _, data in pairs(pendingInstanceUpdates) do
331
+ table.insert(messages, {
332
+ type = "instanceUpdated",
333
+ data = data,
334
+ })
335
+ end
336
+
337
+ for _, payload in pairs(pendingScriptChanges) do
338
+ if pendingScriptChangeReady[payload.guid] == true then
339
+ table.insert(messages, {
340
+ type = "scriptChanged",
341
+ data = {
342
+ guid = payload.guid,
343
+ path = payload.path,
344
+ className = payload.className,
345
+ source = payload.source,
346
+ },
347
+ })
348
+ pendingScriptChanges[payload.guid] = nil
349
+ pendingScriptChangeReady[payload.guid] = nil
350
+ scriptChangeDebounceTokenByGuid[payload.guid] = nil
351
+ end
352
+ end
353
+
354
+ for guid, shouldSend in pairs(pendingDeletes) do
355
+ if shouldSend then table.insert(messages, {
356
+ type = "deleted",
357
+ data = {
358
+ guid = guid,
359
+ },
360
+ }) end
361
+ end
362
+
363
+ table.clear(pendingInstanceUpdates)
364
+ table.clear(pendingDeletes)
365
+
366
+ if #messages == 0 then return end
367
+
368
+ if batchingEnabled and #messages > 1 then
369
+ if #messages > 200 then
370
+ debugPrint(`Too many messages ({#messages}) in batch, sending full snapshot instead.`)
371
+ sendFullSnapshot()
372
+ else
373
+ sendMessage("batch", { messages = messages })
374
+ end
375
+ else
376
+ for _, message in ipairs(messages) do
377
+ sendMessage(message)
378
+ end
379
+ end
380
+ end
381
+
382
+ -- Schedule a batch flush after a quiet window
383
+ local function scheduleBatchFlush()
384
+ if not batchingEnabled then return end
385
+
386
+ batchFlushToken += 1
387
+ local token = batchFlushToken
388
+
389
+ task.delay(BATCH_IDLE_WINDOW, function()
390
+ if token ~= batchFlushToken then return end
391
+ flushOutboundQueues()
392
+ end)
393
+ end
394
+
395
+ local function queueInstanceUpdate(data)
396
+ if suppressOutbound or not syncEnabled then return end
397
+ if not data or not data.guid then return end
398
+
399
+ pendingInstanceUpdates[data.guid] = data
400
+ scheduleBatchFlush()
401
+ end
402
+
403
+ local function queueScriptChange(payload)
404
+ if suppressOutbound or not syncEnabled then return end
405
+ if not payload or not payload.guid then return end
406
+
407
+ pendingScriptChangeReady[payload.guid] = false
408
+ pendingScriptChanges[payload.guid] = payload
409
+
410
+ local token = (scriptChangeDebounceTokenByGuid[payload.guid] or 0) + 1
411
+ scriptChangeDebounceTokenByGuid[payload.guid] = token
412
+
413
+ task.delay(SCRIPT_CHANGE_DEBOUNCE, function()
414
+ if not syncEnabled or suppressOutbound then return end
415
+ if scriptChangeDebounceTokenByGuid[payload.guid] ~= token then return end
416
+ if not pendingScriptChanges[payload.guid] then return end
417
+
418
+ pendingScriptChangeReady[payload.guid] = true
419
+ scheduleBatchFlush()
420
+ end)
421
+ end
422
+
423
+ local function queueDeletion(guid: string)
424
+ if suppressOutbound or not syncEnabled then return end
425
+ if not guid or guid == "" then return end
426
+
427
+ pendingDeletes[guid] = true
428
+ scheduleBatchFlush()
429
+ end
430
+
431
+ -- Deduped instance update sender (used for adds, renames, reparent)
432
+ local function sendInstanceUpdateDedup(instance)
433
+ if suppressOutbound or not syncEnabled then return end
434
+
435
+ local data = AzulService.instanceToDataDedup(instance, trackedInstances, guidMap, usedGuids, lastInstanceUpdate)
436
+ if not data then return end
437
+
438
+ local guid = data.guid
439
+ trackedInstances[instance] = guid
440
+ guidMap[guid] = instance
441
+
442
+ queueInstanceUpdate(data)
443
+ end
444
+
445
+ -- Utility: Check if instance should be synced (scripts only)
446
+ local function shouldSync(instance)
447
+ if not instance then return false end
448
+
449
+ -- Only sync scripts
450
+ if not isScript(instance) then return false end
451
+
452
+ return not AzulService.isExcluded(instance, serviceSet)
453
+ end
454
+
455
+ -- Handle script change
456
+ local function onScriptChanged(changedScript: Script | LocalScript | ModuleScript)
457
+ if suppressOutbound or not shouldSync(changedScript) then return end
458
+
459
+ local guid = AzulService.getOrCreateGUID(changedScript, trackedInstances, guidMap, usedGuids)
460
+
461
+ -- Don't send changes if this was just patched from daemon
462
+ if recentPatches[guid] then
463
+ debugPrint("Ignoring change (was just patched from daemon):", `{changedScript.Parent}.{changedScript.Name}`)
464
+ recentPatches[guid] = nil
465
+ return
466
+ end
467
+
468
+ -- Don't send changes if we're applying a patch from daemon
469
+ if applyingPatch then return end
470
+
471
+ -- Don't send changes within 1 second of receiving a patch (debounce)
472
+ local lastPatch = lastPatchTime[guid] or 0
473
+ local now = tick()
474
+ if now - lastPatch < 1 then
475
+ debugPrint("Ignoring change (too soon after patch):", `{changedScript.Parent}.{changedScript.Name}`)
476
+ return
477
+ end
478
+
479
+ local path = AzulService.getInstancePath(changedScript)
480
+ if not path then return end
481
+
482
+ queueScriptChange({
483
+ guid = guid,
484
+ path = path,
485
+ className = changedScript.ClassName,
486
+ source = changedScript.Source,
487
+ })
488
+ end
489
+
490
+ -- Utility: register change listeners on an instance for name/parent/source updates
491
+ local function attachListeners(instance)
492
+ if not shouldIncludeInSnapshot(instance) then return end
493
+
494
+ -- Name changes should propagate to daemon (renames / path changes)
495
+ local nameConnnection = instance:GetPropertyChangedSignal("Name"):Connect(function()
496
+ sendInstanceUpdateDedup(instance)
497
+ end)
498
+
499
+ table.insert(connections, nameConnnection)
500
+
501
+ -- Parent changes (reparent/move) also change path
502
+ local parentConnection = instance:GetPropertyChangedSignal("Parent"):Connect(function()
503
+ -- If parent is nil (destroy in progress), rely on DescendantRemoving -> deleted
504
+ if instance.Parent == nil then return end
505
+ sendInstanceUpdateDedup(instance)
506
+ end)
507
+
508
+ table.insert(connections, parentConnection)
509
+
510
+ -- Source changes (scripts only)
511
+ if isScript(instance) then
512
+ local sourceConnection = (instance :: Script):GetPropertyChangedSignal("Source"):Connect(function()
513
+ if syncEnabled then onScriptChanged(instance :: Script) end
514
+ end)
515
+ table.insert(connections, sourceConnection)
516
+ end
517
+ end
518
+
519
+ local function wipeChildren(container: Instance)
520
+ for _, child in ipairs(container:GetChildren()) do
521
+ if AzulService.isProtectedRobloxContainer(child) then continue end
522
+ child:Destroy()
523
+ end
524
+ end
525
+
526
+ -- Locate the ModuleScript that carries push settings for this place
527
+
528
+ local function sendDaemonConfig(warnOnFail: boolean?)
529
+ local configPayload, err = AzulService.readPushConfig(PUSH_CONFIG_PATH)
530
+ if not configPayload then
531
+ if warnOnFail then
532
+ warn(`[Azul]: daemon config unavailable: {err}`)
533
+ else
534
+ debugPrint(`daemon config unavailable: {err}`)
535
+ end
536
+ return false
537
+ end
538
+
539
+ return sendMessage("pushConfig", {
540
+ config = configPayload,
541
+ })
542
+ end
543
+
544
+ -- Handle instance added
545
+ local function onInstanceAdded(instance: Instance)
546
+ if suppressOutbound then return end
547
+ -- Include all non-excluded instances (scripts + containers) so sourcemap stays accurate
548
+ if not shouldIncludeInSnapshot(instance) then return end
549
+
550
+ local data = AzulService.instanceToData(instance, trackedInstances, guidMap, usedGuids)
551
+ if not data then return end
552
+ -- local guid = data.guid
553
+ sendInstanceUpdateDedup(instance)
554
+
555
+ -- Track subsequent changes (rename/reparent/source)
556
+ attachListeners(instance)
557
+
558
+ -- Watch for source changes (scripts only)
559
+ -- (handled inside attachListeners)
560
+ end
561
+
562
+ -- Handle instance removed
563
+ local function onInstanceRemoved(instance)
564
+ if suppressOutbound then return end
565
+
566
+ if not trackedInstances[instance] then
567
+ -- debugPrint(`Instance is excluded: {instance.Parent}.{instance.Name}`)
568
+ return
569
+ end
570
+
571
+ local guid = trackedInstances[instance]
572
+
573
+ -- Fallback: if we never tracked this instance (edge cases), use debug id directly
574
+ if not guid then guid = instance:GetDebugId(0) end
575
+
576
+ if not guid then return end
577
+
578
+ trackedInstances[instance] = nil
579
+ guidMap[guid] = nil
580
+ usedGuids[guid] = nil
581
+
582
+ queueDeletion(guid)
583
+ end
584
+
585
+ -- Process incoming daemon message
586
+ local function processMessage(message)
587
+ debugPrint("Processing message type:", message.type)
588
+
589
+ if message.type == "patchScript" then
590
+ debugPrint("Patch requested for GUID:", message.guid)
591
+ -- Update script source
592
+ local instance = guidMap[message.guid]
593
+ if instance and isScript(instance) then
594
+ setOutboundSuppressed(true)
595
+ -- Mark this script as recently patched from daemon
596
+ recentPatches[message.guid] = true
597
+
598
+ -- Record patch time BEFORE applying to prevent echo
599
+ lastPatchTime[message.guid] = tick()
600
+
601
+ -- Update source (large scripts via ScriptEditorService)
602
+ applyingPatch = true
603
+ AzulService.setScriptSource(instance :: Script, message.source) -- We already checked isScript above, we can assert this cast
604
+
605
+ -- Keep flag set longer to cover any async events
606
+ task.delay(0.2, function()
607
+ applyingPatch = false
608
+ setOutboundSuppressed(false)
609
+ end)
610
+
611
+ infoPrint("Updated script:", `{instance.Parent}.{instance.Name}`)
612
+
613
+ -- Clear the patch marker after editor refresh completes
614
+ task.delay(0.2, function()
615
+ task.wait(0.2)
616
+ recentPatches[message.guid] = nil
617
+ end)
618
+ else
619
+ warn("[Azul]: Cannot apply patch - instance not found for GUID:", message.guid)
620
+ local count = 0
621
+ for _ in pairs(guidMap) do
622
+ count = count + 1
623
+ end
624
+ warn("[Azul]: Total tracked instances:", count)
625
+ end
626
+ elseif message.type == "requestSnapshot" then
627
+ -- Daemon is requesting a full snapshot
628
+ infoPrint("Snapshot requested by daemon")
629
+ sendFullSnapshot(message.options)
630
+ elseif message.type == "buildSnapshot" then
631
+ -- One-time push from filesystem into Studio
632
+ infoPrint("Applying build snapshot from daemon")
633
+ setOutboundSuppressed(true)
634
+
635
+ local ok, err = pcall(function()
636
+ local created, updated = AzulService.applySnapshotInstances(message.data)
637
+ -- Update tracking with new instances
638
+ for _, item in ipairs(message.data or {}) do
639
+ local instance = guidMap[item.guid]
640
+ if instance then
641
+ trackedInstances[instance] = item.guid
642
+ attachListeners(instance)
643
+ end
644
+ end
645
+ infoPrint(`Build snapshot applied ({created} created, {updated} updated)`)
646
+ end)
647
+
648
+ setOutboundSuppressed(false)
649
+ if not ok then warn("[Azul]: Error applying build snapshot", err) end
650
+
651
+ -- Disconnect after build completes (daemon will exit)
652
+ task.delay(0.5, function()
653
+ stopSync()
654
+ end)
655
+ elseif message.type == "pushSnapshot" then
656
+ infoPrint("Applying push snapshot from daemon")
657
+ setOutboundSuppressed(true)
658
+
659
+ local ok, err = pcall(function()
660
+ for _, mapping in ipairs(message.mappings or {}) do
661
+ local destination = mapping.destination or {}
662
+ if #destination == 0 then
663
+ warn("[Azul]: Push mapping missing destination; skipping")
664
+ continue
665
+ end
666
+
667
+ local targetContainer = AzulService.getOrCreatePath(destination)
668
+ if mapping.destructive then wipeChildren(targetContainer) end
669
+
670
+ local created = AzulService.applySnapshotInstances(mapping.instances or {})
671
+ -- Update tracking with new instances
672
+ for _, item in ipairs(mapping.instances or {}) do
673
+ local instance = guidMap[item.guid]
674
+ if instance then
675
+ trackedInstances[instance] = item.guid
676
+ attachListeners(instance)
677
+ end
678
+ end
679
+ infoPrint(`Push applied to {table.concat(destination, "/")} ({created} created/updated)`)
680
+ end
681
+ end)
682
+
683
+ setOutboundSuppressed(false)
684
+ if not ok then warn("[Azul]: Error applying push snapshot", err) end
685
+
686
+ -- Disconnect after push completes (daemon will exit)
687
+ task.delay(0.5, function()
688
+ stopSync()
689
+ end)
690
+ elseif message.type == "requestPushConfig" then
691
+ sendDaemonConfig(true)
692
+ elseif message.type == "error" then
693
+ warn("[Azul]: Daemon error:", message.message)
694
+ elseif message.type == "pong" then
695
+ -- Heartbeat response
696
+ debugPrint("Received pong")
697
+ else
698
+ warn("[Azul]: Unknown message type:", message.type)
699
+ end
700
+ end
701
+
702
+ -- Declare azulUI before using it
703
+ local azulUI
704
+
705
+ -- Start sync
706
+ local function startSync()
707
+ if syncEnabled then return end
708
+
709
+ infoPrint("Starting sync...")
710
+ syncEnabled = true
711
+ batchingEnabled = false
712
+ batchFlushToken += 1
713
+
714
+ if azulUI then azulUI:UpdateSyncState(true) end
715
+
716
+ -- Create and connect WebSocket client
717
+ wsClient = WebSocketClient.new(CONFIG.WS_URL, {
718
+ debugMode = CONFIG.DEBUG_MODE,
719
+ silentMode = CONFIG.SILENT_MODE,
720
+ })
721
+
722
+ -- Set up message handler
723
+ wsClient:on("message", function(message)
724
+ processMessage(message)
725
+ end)
726
+
727
+ -- Set up connection handler
728
+ wsClient:on("connect", function()
729
+ infoPrint("Connected to daemon")
730
+ -- Send initial snapshot after connection
731
+ sendDaemonConfig()
732
+ end)
733
+
734
+ -- Set up disconnect handler
735
+ wsClient:on("disconnect", function()
736
+ infoPrint("Disconnected from daemon")
737
+ stopSync()
738
+ end)
739
+
740
+ -- Set up error handler
741
+ wsClient:on("error", function(error)
742
+ warn("[Azul]: Connection error:", error)
743
+ end)
744
+
745
+ -- Connect to daemon
746
+ local connected = wsClient:connect()
747
+
748
+ if not connected then
749
+ warn("[Azul]: Failed to connect to daemon")
750
+ stopSync()
751
+ return
752
+ end
753
+
754
+ -- Defer listener setup to avoid blocking
755
+ task.defer(function()
756
+ -- Setup listeners for existing instances
757
+ local function setupListeners(parent)
758
+ for _, child in ipairs(parent:GetChildren()) do
759
+ attachListeners(child)
760
+ setupListeners(child)
761
+ end
762
+ end
763
+
764
+ for _, service in ipairs(game:GetChildren()) do
765
+ setupListeners(service)
766
+ task.wait() -- Yield between services
767
+ end
768
+ end)
769
+
770
+ -- Listen for new instances
771
+ local descendantAddedConnection = game.DescendantAdded:Connect(function(instance)
772
+ if syncEnabled then onInstanceAdded(instance) end
773
+ end)
774
+ table.insert(connections, descendantAddedConnection)
775
+
776
+ -- Listen for removed instances
777
+ local descendantRemovingConnection = game.DescendantRemoving:Connect(function(instance)
778
+ if syncEnabled then onInstanceRemoved(instance) end
779
+ end)
780
+ table.insert(connections, descendantRemovingConnection)
781
+
782
+ -- Start heartbeat
783
+ local heartbeatConnection = RunService.Heartbeat:Connect(function(dt)
784
+ if syncEnabled then
785
+ -- Send heartbeat periodically
786
+ local now = os.time()
787
+ if now - lastHeartbeat > CONFIG.HEARTBEAT_INTERVAL then
788
+ sendMessage("ping", {})
789
+ lastHeartbeat = now
790
+ end
791
+
792
+ if batchingEnabled then
793
+ -- Batch flush uses an idle timer; heartbeat just keeps the client alive
794
+ else
795
+ -- Legacy fast path before initial snapshot: flush at a fixed interval
796
+ batchAccumulator += dt
797
+ if batchAccumulator >= BATCH_INTERVAL then
798
+ batchAccumulator -= BATCH_INTERVAL
799
+ flushOutboundQueues()
800
+ end
801
+ end
802
+ end
803
+ end)
804
+ table.insert(connections, heartbeatConnection)
805
+
806
+ infoPrint("Sync enabled")
807
+ end
808
+
809
+ -- Stop sync
810
+ function stopSync()
811
+ if not syncEnabled then return end
812
+
813
+ sendMessage("clientDisconnect", {})
814
+
815
+ infoPrint("Stopping sync...")
816
+ syncEnabled = false
817
+
818
+ if azulUI then azulUI:UpdateSyncState(false) end
819
+
820
+ -- Close WebSocket connection
821
+ if wsClient then wsClient:disconnect() end
822
+ wsClient = nil :: any
823
+
824
+ -- Clear GUID attributes if configured
825
+ if CONFIG.CLEAR_GUIDS_ON_EXIT then
826
+ AzulService.clearAllGUIDAttributes()
827
+ infoPrint("Cleared all GUID attributes")
828
+ end
829
+
830
+ trackedInstances = {}
831
+ guidMap = {}
832
+ batchAccumulator = 0
833
+ table.clear(pendingInstanceUpdates)
834
+ table.clear(pendingScriptChanges)
835
+ table.clear(pendingScriptChangeReady)
836
+ table.clear(scriptChangeDebounceTokenByGuid)
837
+ table.clear(pendingDeletes)
838
+
839
+ batchingEnabled = false
840
+ batchFlushToken += 1
841
+
842
+ for _, conn in ipairs(connections) do
843
+ conn:Disconnect()
844
+ end
845
+ connections = {}
846
+
847
+ infoPrint("Sync stopped")
848
+ end
849
+
850
+ -- Cleanup on plugin unload
851
+ plugin.Unloading:Connect(function()
852
+ saveSettings()
853
+ saveSelectedScope()
854
+ stopSync()
855
+ end)
856
+
857
+ infoPrint("Plugin loaded. Click on the 'Azul' button to connect.")
858
+ debugPrint("Debug mode is enabled!")
859
+ debugPrint(`Service list type is set to: "{CONFIG.LIST_TYPE}"`)
860
+
861
+ -- Initialize UI
862
+ local uiCallbacks: UI.CallbackFunctions = {
863
+ onStartSync = startSync,
864
+ onStopSync = stopSync,
865
+ onConfigChanged = function(key, value)
866
+ debugPrint(`Config {key} updated to: {value}`)
867
+ end,
868
+ onSettingsScopeChanged = function(newScope)
869
+ local newScopeIsGlobal = newScope == Enums.scope.GLOBAL
870
+ if newScopeIsGlobal == (SETTINGS_SCOPE == Enums.scope.GLOBAL) then return end
871
+
872
+ -- Persist the current scope before switching
873
+ saveSettings()
874
+ SETTINGS_SCOPE = newScope
875
+ saveSelectedScope()
876
+ azulUI:SetSettingsScope(SETTINGS_SCOPE)
877
+ loadSettings()
878
+ AzulService.rebuildServiceSet(serviceSet)
879
+ azulUI:UpdateConfig()
880
+
881
+ local scopeLabel = if SETTINGS_SCOPE == Enums.scope.GLOBAL then "Global" else "Project"
882
+ infoPrint(`Settings scope set to {scopeLabel}`)
883
+ end,
884
+ onClearGuids = AzulService.clearAllGUIDAttributes,
885
+ onSourcemapReload = function()
886
+ infoPrint("Reloading sourcemap...")
887
+ sendFullSnapshot()
888
+ end,
889
+ }
890
+
891
+ local uiHelpers = {
892
+ debugPrint = debugPrint,
893
+ infoPrint = infoPrint,
894
+ saveSettings = saveSettings,
895
+ loadSettings = loadSettings,
896
+ rebuildServiceSet = function()
897
+ AzulService.rebuildServiceSet(serviceSet)
898
+ end,
899
+ }
900
+
901
+ azulUI = UI.new(plugin, uiCallbacks, SETTINGS_SCOPE, uiHelpers)
902
+
903
+ -- applyConfigToUI = function()
904
+ -- if azulUI then azulUI:UpdateConfig() end
905
+ -- end