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,1010 @@
1
+ --!strict
2
+ local AzulService = {}
3
+
4
+ local ScriptEditorService = game:GetService("ScriptEditorService")
5
+
6
+ -- Services
7
+
8
+ -- Modules
9
+ local parent = script.Parent
10
+ if not parent then error("AzulService: missing parent container") end
11
+ local CONFIG = require("./Config")
12
+ local Enums = require("./Enums")
13
+ local HttpService = game:GetService("HttpService")
14
+ local ReflectionService = nil
15
+
16
+ do
17
+ local ok, service = pcall(function()
18
+ return game:GetService("ReflectionService")
19
+ end)
20
+ if ok then ReflectionService = service end
21
+ end
22
+
23
+ -- Logging helpers
24
+ local function debugPrint(...)
25
+ if CONFIG.SILENT_MODE or not CONFIG.DEBUG_MODE then return end
26
+ print(`[🐛 AzulService]:`, ...)
27
+ end
28
+
29
+ local function infoPrint(...)
30
+ if CONFIG.SILENT_MODE then return end
31
+ print(`[AzulService]:`, ...)
32
+ end
33
+
34
+ local function isScript(instance)
35
+ return instance:IsA("Script") or instance:IsA("LocalScript") or instance:IsA("ModuleScript")
36
+ end
37
+
38
+ local FALLBACK_SERIALIZABLE_PROPERTIES = {
39
+ "Archivable",
40
+ "Enabled",
41
+ "RunContext",
42
+ "LinkedSource",
43
+ "Disabled",
44
+ "Visible",
45
+ "Active",
46
+ "AutomaticSize",
47
+ "AnchorPoint",
48
+ "Position",
49
+ "Rotation",
50
+ "Size",
51
+ "Text",
52
+ "TextColor3",
53
+ "TextSize",
54
+ "BackgroundColor3",
55
+ "BackgroundTransparency",
56
+ "Image",
57
+ "ImageColor3",
58
+ "ImageTransparency",
59
+ "LayoutOrder",
60
+ "ZIndex",
61
+ "AutoButtonColor",
62
+ "RichText",
63
+ "TextScaled",
64
+ "TextWrapped",
65
+ "FontFace",
66
+ "Transparency",
67
+ "Color",
68
+ "Material",
69
+ "Reflectance",
70
+ "CanCollide",
71
+ "CanTouch",
72
+ "CanQuery",
73
+ "CastShadow",
74
+ "Locked",
75
+ "Massless",
76
+ "Shape",
77
+ "SizeConstraint",
78
+ "CFrame",
79
+ "Orientation",
80
+ "PivotOffset",
81
+ "BrickColor",
82
+ "TopSurface",
83
+ "BottomSurface",
84
+ "LeftSurface",
85
+ "RightSurface",
86
+ "FrontSurface",
87
+ "BackSurface",
88
+ "MeshId",
89
+ "TextureID",
90
+ "DoubleSided",
91
+ "Offset",
92
+ "Scale",
93
+ "SoundId",
94
+ "Volume",
95
+ "PlaybackSpeed",
96
+ "Looped",
97
+ "RollOffMode",
98
+ "RollOffMaxDistance",
99
+ "RollOffMinDistance",
100
+ "PlayOnRemove",
101
+ "AnimationId",
102
+ "Value",
103
+ "CurrentCamera",
104
+ "FieldOfView",
105
+ "Ambient",
106
+ "Brightness",
107
+ "ClockTime",
108
+ "FogColor",
109
+ "FogEnd",
110
+ "FogStart",
111
+ "GlobalShadows",
112
+ "OutdoorAmbient",
113
+ "Technology",
114
+ "PrimaryPart",
115
+ "WorldPivot",
116
+ }
117
+
118
+ local FALLBACK_SERIALIZABLE_PROPERTY_SET: { [string]: boolean } = {}
119
+ for _, propertyName in ipairs(FALLBACK_SERIALIZABLE_PROPERTIES) do
120
+ FALLBACK_SERIALIZABLE_PROPERTY_SET[propertyName] = true
121
+ end
122
+
123
+ local SERIALIZABLE_PROPERTIES_CACHE: { string }? = nil
124
+ local SERIALIZABLE_PROPERTY_SET_CACHE: { [string]: boolean }? = nil
125
+
126
+ local function buildSerializablePropertiesFromReflection(): { string }
127
+ if not ReflectionService then return FALLBACK_SERIALIZABLE_PROPERTIES end
128
+
129
+ local classOk, classes = pcall(function()
130
+ return ReflectionService:GetClasses()
131
+ end)
132
+
133
+ if not classOk or type(classes) ~= "table" then return FALLBACK_SERIALIZABLE_PROPERTIES end
134
+
135
+ local propertySet: { [string]: boolean } = {}
136
+
137
+ for _, classInfo in ipairs(classes) do
138
+ local className = classInfo.Name
139
+ if type(className) ~= "string" then continue end
140
+
141
+ local propsOk, reflectedProperties = pcall(function()
142
+ return ReflectionService:GetPropertiesOfClass(className, {
143
+ ExcludeDisplay = true,
144
+ })
145
+ end)
146
+
147
+ if not propsOk or type(reflectedProperties) ~= "table" then continue end
148
+
149
+ for _, reflected in ipairs(reflectedProperties) do
150
+ local reflectedName = reflected.Name
151
+ if type(reflectedName) ~= "string" then continue end
152
+
153
+ if
154
+ reflected.Serialized == true
155
+ and reflectedName ~= "Name"
156
+ and reflectedName ~= "Parent"
157
+ and reflectedName ~= "Source"
158
+ then
159
+ propertySet[reflectedName] = true
160
+ end
161
+ end
162
+ end
163
+
164
+ for _, fallbackProperty in ipairs(FALLBACK_SERIALIZABLE_PROPERTIES) do
165
+ propertySet[fallbackProperty] = true
166
+ end
167
+
168
+ local properties = {}
169
+ for propertyName in pairs(propertySet) do
170
+ table.insert(properties, propertyName)
171
+ end
172
+
173
+ table.sort(properties)
174
+
175
+ if #properties == 0 then return FALLBACK_SERIALIZABLE_PROPERTIES end
176
+
177
+ return properties
178
+ end
179
+
180
+ local function getSerializableProperties(): { string }
181
+ local cached = SERIALIZABLE_PROPERTIES_CACHE
182
+ if cached then return cached end
183
+
184
+ local built = buildSerializablePropertiesFromReflection()
185
+ SERIALIZABLE_PROPERTIES_CACHE = built
186
+ return built
187
+ end
188
+
189
+ local writablePropertiesByClass: { [string]: { [string]: boolean } } = {}
190
+ local defaultSerializedPropertiesByClass: { [string]: { [string]: any } } = {}
191
+ local canonicalPropertyByLowerByClass: { [string]: { [string]: string } } = {}
192
+
193
+ local function toSet(items: { string }): { [string]: boolean }
194
+ local set = {}
195
+ for _, item in ipairs(items) do
196
+ set[item] = true
197
+ end
198
+ return set
199
+ end
200
+
201
+ local function getSerializablePropertySet(): { [string]: boolean }
202
+ local cached = SERIALIZABLE_PROPERTY_SET_CACHE
203
+ if cached then return cached end
204
+
205
+ local built = toSet(getSerializableProperties())
206
+ SERIALIZABLE_PROPERTY_SET_CACHE = built
207
+ return built
208
+ end
209
+
210
+ local function getWritableSerializablePropertySetForClass(className: string): { [string]: boolean }
211
+ local cached = writablePropertiesByClass[className]
212
+ if cached then return cached end
213
+
214
+ local serializablePropertySet = getSerializablePropertySet()
215
+ local fallback = {}
216
+ for propertyName in pairs(serializablePropertySet) do
217
+ fallback[propertyName] = true
218
+ end
219
+
220
+ if not ReflectionService then
221
+ writablePropertiesByClass[className] = fallback
222
+ return fallback
223
+ end
224
+
225
+ local ok, reflectedProperties = pcall(function()
226
+ return ReflectionService:GetPropertiesOfClass(className)
227
+ end)
228
+
229
+ if not ok or type(reflectedProperties) ~= "table" then
230
+ writablePropertiesByClass[className] = fallback
231
+ return fallback
232
+ end
233
+
234
+ local filtered = {}
235
+ for _, reflected in ipairs(reflectedProperties) do
236
+ local reflectedName = reflected.Name
237
+ if type(reflectedName) ~= "string" then continue end
238
+
239
+ if not serializablePropertySet[reflectedName] then continue end
240
+
241
+ local includedByFallback = FALLBACK_SERIALIZABLE_PROPERTY_SET[reflectedName] == true
242
+ if reflected.Serialized ~= true and not includedByFallback then continue end
243
+
244
+ filtered[reflectedName] = true
245
+ end
246
+
247
+ if next(filtered) == nil then filtered = fallback end
248
+
249
+ writablePropertiesByClass[className] = filtered
250
+ return filtered
251
+ end
252
+
253
+ local function getCanonicalPropertyByLowerForClass(className: string): { [string]: string }
254
+ local cached = canonicalPropertyByLowerByClass[className]
255
+ if cached then return cached end
256
+
257
+ local allowed = getWritableSerializablePropertySetForClass(className)
258
+ local byLower = {}
259
+ for propertyName in pairs(allowed) do
260
+ byLower[string.lower(propertyName)] = propertyName
261
+ end
262
+
263
+ canonicalPropertyByLowerByClass[className] = byLower
264
+ return byLower
265
+ end
266
+
267
+ local function resolveWritablePropertyName(instance: Instance, propertyName: string): string?
268
+ local allowed = getWritableSerializablePropertySetForClass(instance.ClassName)
269
+ if allowed[propertyName] then return propertyName end
270
+
271
+ local canonicalByLower = getCanonicalPropertyByLowerForClass(instance.ClassName)
272
+ return canonicalByLower[string.lower(propertyName)]
273
+ end
274
+
275
+ local function serializedValuesEqual(left, right): boolean
276
+ if type(left) ~= type(right) then return false end
277
+
278
+ if type(left) ~= "table" then return left == right end
279
+
280
+ for key, value in pairs(left) do
281
+ if not serializedValuesEqual(value, right[key]) then return false end
282
+ end
283
+
284
+ for key in pairs(right) do
285
+ if left[key] == nil then return false end
286
+ end
287
+
288
+ return true
289
+ end
290
+
291
+ local serializeValue
292
+
293
+ local function readAndSerializeProperty(instance: Instance, propertyName: string)
294
+ local ok, value = pcall(function()
295
+ return (instance :: any)[propertyName]
296
+ end)
297
+ if not ok then return nil end
298
+
299
+ return serializeValue(value)
300
+ end
301
+
302
+ local function getDefaultSerializedPropertiesForClass(className: string): { [string]: any }
303
+ local cached = defaultSerializedPropertiesByClass[className]
304
+ if cached then return cached end
305
+
306
+ local defaults = {}
307
+ local ok, tempInstance = pcall(function()
308
+ return Instance.new(className)
309
+ end)
310
+
311
+ if not ok or not tempInstance then
312
+ defaultSerializedPropertiesByClass[className] = defaults
313
+ return defaults
314
+ end
315
+
316
+ local allowedProperties = getWritableSerializablePropertySetForClass(className)
317
+ for propertyName in pairs(allowedProperties) do
318
+ local serialized = readAndSerializeProperty(tempInstance, propertyName)
319
+ if serialized ~= nil then defaults[propertyName] = serialized end
320
+ end
321
+
322
+ tempInstance:Destroy()
323
+ defaultSerializedPropertiesByClass[className] = defaults
324
+ return defaults
325
+ end
326
+
327
+ serializeValue = function(value: any): any
328
+ local kind = typeof(value)
329
+
330
+ if kind == "nil" then return nil end
331
+ if kind == "boolean" or kind == "string" or kind == "number" then return value end
332
+
333
+ if kind == "Color3" then
334
+ local color = value :: Color3
335
+ return { __type = "Color3", r = color.R, g = color.G, b = color.B }
336
+ end
337
+
338
+ if kind == "Vector2" then
339
+ local vector = value :: Vector2
340
+ return { __type = "Vector2", x = vector.X, y = vector.Y }
341
+ end
342
+
343
+ if kind == "Vector3" then
344
+ local vector = value :: Vector3
345
+ return { __type = "Vector3", x = vector.X, y = vector.Y, z = vector.Z }
346
+ end
347
+
348
+ if kind == "UDim" then
349
+ local u = value :: UDim
350
+ return { __type = "UDim", scale = u.Scale, offset = u.Offset }
351
+ end
352
+
353
+ if kind == "UDim2" then
354
+ local u = value :: UDim2
355
+ return {
356
+ __type = "UDim2",
357
+ xScale = u.X.Scale,
358
+ xOffset = u.X.Offset,
359
+ yScale = u.Y.Scale,
360
+ yOffset = u.Y.Offset,
361
+ }
362
+ end
363
+
364
+ if kind == "Rect" then
365
+ local rect = value :: Rect
366
+ return {
367
+ __type = "Rect",
368
+ minX = rect.Min.X,
369
+ minY = rect.Min.Y,
370
+ maxX = rect.Max.X,
371
+ maxY = rect.Max.Y,
372
+ }
373
+ end
374
+
375
+ if kind == "CFrame" then
376
+ local cf = value :: CFrame
377
+ return { __type = "CFrame", components = { cf:GetComponents() } }
378
+ end
379
+
380
+ if kind == "BrickColor" then
381
+ local brick = value :: BrickColor
382
+ return { __type = "BrickColor", number = brick.Number }
383
+ end
384
+
385
+ if kind == "EnumItem" then
386
+ local enumItem = value :: EnumItem
387
+ return {
388
+ __type = "EnumItem",
389
+ enumType = tostring(enumItem.EnumType),
390
+ name = enumItem.Name,
391
+ }
392
+ end
393
+
394
+ if kind == "NumberRange" then
395
+ local range = value :: NumberRange
396
+ return { __type = "NumberRange", min = range.Min, max = range.Max }
397
+ end
398
+
399
+ if kind == "NumberSequence" then
400
+ local sequence = value :: NumberSequence
401
+ local keypoints = {}
402
+ for _, keypoint in ipairs(sequence.Keypoints) do
403
+ table.insert(keypoints, {
404
+ time = keypoint.Time,
405
+ value = keypoint.Value,
406
+ envelope = keypoint.Envelope,
407
+ })
408
+ end
409
+ return { __type = "NumberSequence", keypoints = keypoints }
410
+ end
411
+
412
+ if kind == "ColorSequence" then
413
+ local sequence = value :: ColorSequence
414
+ local keypoints = {}
415
+ for _, keypoint in ipairs(sequence.Keypoints) do
416
+ table.insert(keypoints, {
417
+ time = keypoint.Time,
418
+ color = serializeValue(keypoint.Value),
419
+ })
420
+ end
421
+ return { __type = "ColorSequence", keypoints = keypoints }
422
+ end
423
+
424
+ if kind == "Font" then
425
+ local font = value :: Font
426
+ return {
427
+ __type = "Font",
428
+ family = font.Family,
429
+ weight = font.Weight and font.Weight.Value or nil,
430
+ style = font.Style and font.Style.Value or nil,
431
+ }
432
+ end
433
+
434
+ if kind == "PhysicalProperties" then
435
+ local props = value :: PhysicalProperties
436
+ return {
437
+ __type = "PhysicalProperties",
438
+ density = props.Density,
439
+ friction = props.Friction,
440
+ elasticity = props.Elasticity,
441
+ frictionWeight = props.FrictionWeight,
442
+ elasticityWeight = props.ElasticityWeight,
443
+ }
444
+ end
445
+
446
+ return nil
447
+ end
448
+
449
+ local function deserializeValue(value: any): any
450
+ if type(value) ~= "table" then return value end
451
+
452
+ local record = value :: { [string]: any }
453
+ local valueType = record.__type
454
+ if type(valueType) ~= "string" then return value end
455
+
456
+ if valueType == "Color3" then return Color3.new(record.r, record.g, record.b) end
457
+ if valueType == "Vector2" then return Vector2.new(record.x, record.y) end
458
+ if valueType == "Vector3" then return Vector3.new(record.x, record.y, record.z) end
459
+ if valueType == "UDim" then return UDim.new(record.scale, record.offset) end
460
+ if valueType == "UDim2" then return UDim2.new(record.xScale, record.xOffset, record.yScale, record.yOffset) end
461
+ if valueType == "Rect" then return Rect.new(record.minX, record.minY, record.maxX, record.maxY) end
462
+ if valueType == "CFrame" and type(record.components) == "table" then
463
+ return CFrame.new(unpack(record.components))
464
+ end
465
+ if valueType == "BrickColor" then return BrickColor.new(record.number) end
466
+ if valueType == "EnumItem" then
467
+ if type(record.enumType) ~= "string" or type(record.name) ~= "string" then return nil end
468
+ local enumName = record.enumType:match("Enum%.(.+)") or record.enumType
469
+ local enum = Enum[enumName]
470
+ if enum then return enum[record.name] end
471
+ return nil
472
+ end
473
+ if valueType == "NumberRange" then return NumberRange.new(record.min, record.max) end
474
+ if valueType == "NumberSequence" then
475
+ local keypoints = {}
476
+ for _, keypoint in ipairs(record.keypoints or {}) do
477
+ table.insert(keypoints, NumberSequenceKeypoint.new(keypoint.time, keypoint.value, keypoint.envelope or 0))
478
+ end
479
+ if #keypoints > 0 then return NumberSequence.new(keypoints) end
480
+ return nil
481
+ end
482
+ if valueType == "ColorSequence" then
483
+ local keypoints = {}
484
+ for _, keypoint in ipairs(record.keypoints or {}) do
485
+ local color = deserializeValue(keypoint.color)
486
+ if typeof(color) == "Color3" then
487
+ table.insert(keypoints, ColorSequenceKeypoint.new(keypoint.time, color))
488
+ end
489
+ end
490
+ if #keypoints > 0 then return ColorSequence.new(keypoints) end
491
+ return nil
492
+ end
493
+ if valueType == "Font" then
494
+ local weight = Enum.FontWeight.Regular
495
+ local style = Enum.FontStyle.Normal
496
+ if type(record.weight) == "number" then
497
+ for _, item in ipairs(Enum.FontWeight:GetEnumItems()) do
498
+ if item.Value == record.weight then
499
+ weight = item
500
+ break
501
+ end
502
+ end
503
+ end
504
+ if type(record.style) == "number" then
505
+ for _, item in ipairs(Enum.FontStyle:GetEnumItems()) do
506
+ if item.Value == record.style then
507
+ style = item
508
+ break
509
+ end
510
+ end
511
+ end
512
+ return Font.new(record.family, weight, style)
513
+ end
514
+ if valueType == "PhysicalProperties" then
515
+ return PhysicalProperties.new(
516
+ record.density,
517
+ record.friction,
518
+ record.elasticity,
519
+ record.frictionWeight,
520
+ record.elasticityWeight
521
+ )
522
+ end
523
+
524
+ return nil
525
+ end
526
+
527
+ local function collectSerializedProperties(instance: Instance): { [string]: any }
528
+ local properties = {}
529
+ local className = instance.ClassName
530
+ local allowedProperties = getWritableSerializablePropertySetForClass(className)
531
+ local defaultProperties = getDefaultSerializedPropertiesForClass(className)
532
+ local serializableProperties = getSerializableProperties()
533
+
534
+ for _, propertyName in ipairs(serializableProperties) do
535
+ if not allowedProperties[propertyName] then continue end
536
+
537
+ if propertyName == "Name" or propertyName == "Parent" then continue end
538
+
539
+ local serialized = readAndSerializeProperty(instance, propertyName)
540
+ if serialized ~= nil then
541
+ local defaultSerialized = defaultProperties[propertyName]
542
+ if defaultSerialized == nil or not serializedValuesEqual(serialized, defaultSerialized) then
543
+ properties[propertyName] = serialized
544
+ end
545
+ end
546
+ end
547
+
548
+ return properties
549
+ end
550
+
551
+ local function applySerializedProperties(instance: Instance, properties: { [string]: any }?)
552
+ if type(properties) ~= "table" then return end
553
+
554
+ for propertyName, serialized in pairs(properties) do
555
+ if propertyName ~= "Name" and propertyName ~= "Parent" and propertyName ~= "Source" then
556
+ local resolvedProperty = resolveWritablePropertyName(instance, propertyName)
557
+ if not resolvedProperty then continue end
558
+
559
+ local deserialized = deserializeValue(serialized)
560
+ if deserialized ~= nil then
561
+ pcall(function()
562
+ (instance :: any)[resolvedProperty] = deserialized
563
+ end)
564
+ end
565
+ end
566
+ end
567
+ end
568
+
569
+ local function applySerializedAttributes(instance: Instance, attributes: { [string]: any }?)
570
+ if type(attributes) ~= "table" then return end
571
+
572
+ for key, value in pairs(attributes) do
573
+ pcall(function()
574
+ instance:SetAttribute(key, value)
575
+ end)
576
+ end
577
+ end
578
+
579
+ --[=[
580
+ Utility: Rebuild service set from config
581
+ @param serviceSet { [string]: boolean } - The service set to rebuild
582
+ ]=]
583
+ function AzulService.rebuildServiceSet(serviceSet)
584
+ table.clear(serviceSet)
585
+ for _, name in ipairs(CONFIG.SERVICE_LIST) do
586
+ serviceSet[name] = true
587
+ end
588
+ end
589
+
590
+ --- Utility: Clear all GUID attributes
591
+ --- This is for legacy support; we now use :GetDebugId() for stable GUIDs
592
+ function AzulService.clearAllGUIDAttributes()
593
+ local guidAttributeName = plugin:GetSetting("GUID_ATTRIBUTE") or "AzulSyncGUID"
594
+
595
+ for _, instance in game:GetDescendants() do
596
+ if instance:GetAttribute(guidAttributeName) then instance:SetAttribute(guidAttributeName, nil) end
597
+ end
598
+ infoPrint("Cleared all legacy GUID attributes from instances.")
599
+ end
600
+
601
+ --- Utility: Check if instance should be excluded from sync
602
+ function AzulService.isExcluded(instance: Instance, serviceSet: { [string]: boolean })
603
+ if not instance then return true end
604
+
605
+ local fullName = instance:GetFullName()
606
+ for _, ancestorName in CONFIG.EXCLUDED_PARENTS do
607
+ if fullName:find(ancestorName) then return true end
608
+ end
609
+
610
+ -- Walk up to the service
611
+ local current: Instance? = instance
612
+ while current do
613
+ if current.Parent == game then
614
+ local inList = serviceSet[current.Name] ~= nil
615
+
616
+ if CONFIG.LIST_TYPE == Enums.listType.WHITELIST then
617
+ -- Whitelist: only allow services in the list
618
+ -- debugPrint(`Included {instance} in whitelist`)
619
+ return not inList
620
+ else
621
+ -- Blacklist: exclude services in the list
622
+ -- debugPrint(`Excluded {instance} in blacklist`)
623
+ return inList
624
+ end
625
+ end
626
+ current = current.Parent
627
+ end
628
+
629
+ -- Not under DataModel
630
+ return true
631
+ end
632
+
633
+ --- Utility: Get or create instance by path segments
634
+ function AzulService.getOrCreatePath(pathSegments: { string }): Instance
635
+ local current: Instance = game
636
+ for index, segment in ipairs(pathSegments) do
637
+ local nextNode: Instance? = current:FindFirstChild(segment)
638
+
639
+ if not nextNode then
640
+ if index == 1 then
641
+ local ok, service = pcall(function()
642
+ return game:GetService(segment)
643
+ end)
644
+ if ok and service then nextNode = service end
645
+ end
646
+
647
+ if not nextNode then
648
+ local newFolder = Instance.new("Folder")
649
+ newFolder.Name = segment
650
+ newFolder.Parent = current
651
+ nextNode = newFolder
652
+ end
653
+ end
654
+
655
+ current = nextNode :: Instance
656
+ end
657
+
658
+ return current
659
+ end
660
+
661
+ function AzulService.isProtectedRobloxContainer(instance: Instance?): boolean
662
+ if not instance then return false end
663
+
664
+ -- Top-level services cannot be destroyed or reparented
665
+ local okService = pcall(function()
666
+ return game:GetService(instance.Name)
667
+ end)
668
+ if instance.Parent == game and okService then return true end
669
+
670
+ -- Certain StarterPlayer children are locked (StarterPlayerScripts, StarterCharacterScripts, StarterGear)
671
+ local parent = instance.Parent
672
+ if parent and parent.ClassName == "StarterPlayer" then
673
+ local name = instance.Name
674
+ if name == "StarterPlayerScripts" or name == "StarterCharacterScripts" or name == "StarterGear" then
675
+ return true
676
+ end
677
+ end
678
+
679
+ return false
680
+ end
681
+
682
+ --- Utility: Generate or retrieve GUID for instance (use Roblox debug IDs to avoid attribute churn)
683
+ function AzulService.getOrCreateGUID(
684
+ instance,
685
+ trackedInstances: { [Instance]: string },
686
+ guidMap: { [string]: Instance },
687
+ usedGuids: { [string]: boolean }
688
+ ): string
689
+ local cached = trackedInstances[instance]
690
+ if cached then return cached end
691
+
692
+ local guid = instance:GetDebugId(0)
693
+ trackedInstances[instance] = guid
694
+ guidMap[guid] = instance
695
+ usedGuids[guid] = true
696
+ return guid
697
+ end
698
+
699
+ --- Utility: Get instance path
700
+ function AzulService.getInstancePath(instance: Instance): { string }?
701
+ local path = {}
702
+ local current: Instance? = instance
703
+
704
+ while current and current ~= game do
705
+ table.insert(path, 1, current.Name)
706
+ current = current.Parent
707
+ end
708
+
709
+ -- If current became nil, the instance is no longer under DataModel
710
+ if current ~= game then return nil end
711
+
712
+ return path
713
+ end
714
+
715
+ --- Convert instance to data format
716
+ function AzulService.instanceToData(
717
+ instance,
718
+ trackedInstances: { [Instance]: string },
719
+ guidMap: { [string]: Instance },
720
+ usedGuids: { [string]: boolean },
721
+ options: { includeProperties: boolean? }?
722
+ ): { [string]: any }?
723
+ local guid = AzulService.getOrCreateGUID(instance, trackedInstances, guidMap, usedGuids)
724
+ local path = AzulService.getInstancePath(instance)
725
+ if not path then return nil end
726
+
727
+ local parentGuid: string? = nil
728
+ local parent = instance.Parent
729
+ if parent then
730
+ if parent == game then
731
+ parentGuid = "root"
732
+ else
733
+ parentGuid = AzulService.getOrCreateGUID(parent, trackedInstances, guidMap, usedGuids)
734
+ end
735
+ end
736
+
737
+ local data: { [string]: any } = {
738
+ guid = guid,
739
+ className = instance.ClassName,
740
+ name = instance.Name,
741
+ path = path,
742
+ parentGuid = parentGuid,
743
+ }
744
+
745
+ if isScript(instance) then data.source = (instance :: Script).Source end
746
+
747
+ if options and options.includeProperties then
748
+ local properties = collectSerializedProperties(instance)
749
+ if next(properties) ~= nil then data.properties = properties end
750
+
751
+ local attributes = instance:GetAttributes()
752
+ if next(attributes) ~= nil then data.attributes = attributes end
753
+ end
754
+
755
+ return data
756
+ end
757
+
758
+ -- Utility: set script source safely (handles large sources via ScriptEditorService)
759
+ function AzulService.setScriptSource(scriptInstance: Script | LocalScript | ModuleScript, source: string)
760
+ local ok, err = pcall(function()
761
+ if ScriptEditorService and ScriptEditorService.UpdateSourceAsync then
762
+ ScriptEditorService:UpdateSourceAsync(scriptInstance, function(...)
763
+ return source
764
+ end)
765
+ else
766
+ (scriptInstance :: Script).Source = source
767
+ end
768
+ end)
769
+
770
+ if not ok then warn(`[AzulService]: Failed to set script source for {scriptInstance}`, err) end
771
+
772
+ return ok
773
+ end
774
+
775
+ --- Create/update instance data with dedup tracking (returns data if changed, nil if duplicate)
776
+ function AzulService.instanceToDataDedup(
777
+ instance: Instance,
778
+ trackedInstances: { [Instance]: string },
779
+ guidMap: { [string]: Instance },
780
+ usedGuids: { [string]: boolean },
781
+ lastInstanceUpdate: { [string]: { key: string, t: number } }
782
+ ): { [string]: any }?
783
+ local data = AzulService.instanceToData(instance, trackedInstances, guidMap, usedGuids)
784
+ if not data then return nil end
785
+
786
+ local guid = data.guid :: string
787
+ local parentKey = (data.parentGuid :: string?) or ""
788
+ local key = table.concat(data.path :: { string }, "/")
789
+ .. "|"
790
+ .. (data.className :: string)
791
+ .. "|"
792
+ .. (data.name :: string)
793
+ .. "|"
794
+ .. parentKey
795
+ local now = tick()
796
+ local last = lastInstanceUpdate[guid]
797
+ if last and last.key == key and now - last.t < 0.05 then
798
+ -- Ignore duplicate bursts from multiple property signals firing at once
799
+ return nil
800
+ end
801
+ lastInstanceUpdate[guid] = { key = key, t = now }
802
+
803
+ return data
804
+ end
805
+
806
+ --- Apply snapshot instances to the workspace
807
+ function AzulService.applySnapshotInstances(instances: { any }): (number, number)
808
+ table.sort(instances, function(a, b)
809
+ return #a.path < #b.path
810
+ end)
811
+
812
+ local siblingOrdinalByPathClass: { [string]: number } = {}
813
+
814
+ local lockedContainerParents = {
815
+ StarterPlayerScripts = "StarterPlayer",
816
+ StarterCharacterScripts = "StarterPlayer",
817
+ StarterGear = "StarterPlayer",
818
+ }
819
+
820
+ local created = 0
821
+ local updated = 0
822
+
823
+ for _, item in ipairs(instances) do
824
+ local adjustedPath = {}
825
+ for i, segment in ipairs(item.path) do
826
+ adjustedPath[i] = segment
827
+ end
828
+
829
+ local protectedContainerName: string? = nil
830
+ local first = adjustedPath[1]
831
+ local maybeParent = lockedContainerParents[first]
832
+ if maybeParent and maybeParent ~= first then
833
+ table.insert(adjustedPath, 1, maybeParent)
834
+ protectedContainerName = adjustedPath[2]
835
+ end
836
+
837
+ if not protectedContainerName and #adjustedPath >= 2 then
838
+ local second = adjustedPath[2]
839
+ if lockedContainerParents[second] == adjustedPath[1] then protectedContainerName = second end
840
+ end
841
+
842
+ local serviceCandidate: Instance? = nil
843
+ if #adjustedPath == 1 then
844
+ local okService, service = pcall(function()
845
+ return game:GetService(adjustedPath[1])
846
+ end)
847
+ if okService then serviceCandidate = service end
848
+ end
849
+
850
+ local parentPath = {}
851
+ for i = 1, #adjustedPath - 1 do
852
+ parentPath[i] = adjustedPath[i]
853
+ end
854
+
855
+ local parent = AzulService.getOrCreatePath(parentPath)
856
+ local existing: Instance? = serviceCandidate
857
+ if not existing then
858
+ local targetName = adjustedPath[#adjustedPath]
859
+ local targetClassName = item.className
860
+ local occurrenceKey = table.concat(adjustedPath, "\u{1F}") .. "::" .. tostring(targetClassName)
861
+ local occurrenceOrdinal = (siblingOrdinalByPathClass[occurrenceKey] or 0) + 1
862
+ siblingOrdinalByPathClass[occurrenceKey] = occurrenceOrdinal
863
+
864
+ local sameNameChildren = {}
865
+ for _, child in ipairs(parent:GetChildren()) do
866
+ if child.Name == targetName then table.insert(sameNameChildren, child) end
867
+ end
868
+
869
+ existing = sameNameChildren[occurrenceOrdinal]
870
+ end
871
+ local instance = existing
872
+
873
+ local function isScriptClass(className: string)
874
+ return className == "Script" or className == "LocalScript" or className == "ModuleScript"
875
+ end
876
+
877
+ if existing and existing.ClassName ~= item.className then
878
+ local targetIsScript = isScriptClass(item.className)
879
+ if targetIsScript then
880
+ if AzulService.isProtectedRobloxContainer(existing) then
881
+ instance = existing
882
+ else
883
+ existing:Destroy()
884
+ instance = nil
885
+ end
886
+ else
887
+ instance = existing
888
+ end
889
+ end
890
+
891
+ if not instance and protectedContainerName then
892
+ local protectedParentName = lockedContainerParents[protectedContainerName]
893
+ if protectedParentName then
894
+ local okParent, protectedParent = pcall(function()
895
+ return game:GetService(protectedParentName)
896
+ end)
897
+ if okParent and protectedParent then
898
+ local protectedExisting = protectedParent:FindFirstChild(protectedContainerName)
899
+ if protectedExisting then instance = protectedExisting end
900
+ end
901
+ end
902
+ end
903
+
904
+ if not instance then
905
+ local newInstance
906
+ local okNew, createdInstance = pcall(function()
907
+ return Instance.new(item.className)
908
+ end)
909
+ if okNew and createdInstance then
910
+ newInstance = createdInstance
911
+ else
912
+ newInstance = Instance.new("Folder")
913
+ end
914
+ newInstance.Name = adjustedPath[#adjustedPath]
915
+ newInstance.Parent = parent
916
+ instance = newInstance
917
+ created += 1
918
+ else
919
+ if not serviceCandidate and not AzulService.isProtectedRobloxContainer(instance) then
920
+ instance.Name = adjustedPath[#adjustedPath]
921
+ end
922
+ end
923
+
924
+ if instance then
925
+ applySerializedProperties(instance, item.properties)
926
+ applySerializedAttributes(instance, item.attributes)
927
+ end
928
+
929
+ if isScriptClass(item.className) and item.source and instance then
930
+ AzulService.setScriptSource(instance :: Script, item.source)
931
+ updated += 1
932
+ end
933
+ end
934
+
935
+ return created, updated
936
+ end
937
+
938
+ --- Find the push config ModuleScript
939
+ function AzulService.findPushConfigModule(pushConfigPath: { string }): ModuleScript?
940
+ local current: Instance = game
941
+ for index, segment in ipairs(pushConfigPath) do
942
+ if index == 1 then
943
+ local ok, service = pcall(function()
944
+ return game:GetService(segment)
945
+ end)
946
+ if not ok or not service then return nil end
947
+ current = service
948
+ else
949
+ local nextNode = current:FindFirstChild(segment)
950
+ if not nextNode then return nil end
951
+ current = nextNode
952
+ end
953
+ end
954
+
955
+ if current and current:IsA("ModuleScript") then return current :: ModuleScript end
956
+
957
+ return nil
958
+ end
959
+
960
+ --- Read and validate push config from ModuleScript
961
+ function AzulService.readPushConfig(pushConfigPath: { string }): ({ [string]: any }?, string?)
962
+ local module = AzulService.findPushConfigModule(pushConfigPath)
963
+ if not module then return nil, "Push config ModuleScript not found" end
964
+
965
+ -- Clone module to force require to refresh data
966
+ local moduleClone = module:Clone()
967
+ moduleClone.Parent = module.Parent
968
+ module:Destroy()
969
+ module = moduleClone
970
+ --
971
+
972
+ local ok, data = pcall(require, module)
973
+ if not ok then return nil, `Failed to require push config: {data}` end
974
+
975
+ if type(data) ~= "table" then return nil, "Push config is not a table" end
976
+
977
+ debugPrint(`Push config loaded from {module}: {HttpService:JSONEncode(data)}`)
978
+
979
+ local mappings = {}
980
+ if type(data.pushMappings) == "table" then
981
+ for _, entry in ipairs(data.pushMappings) do
982
+ if type(entry) == "table" and type(entry.source) == "string" and type(entry.destination) == "table" then
983
+ local dest = {}
984
+ for _, seg in ipairs(entry.destination) do
985
+ if type(seg) == "string" and seg ~= "" then table.insert(dest, seg) end
986
+ end
987
+
988
+ if #dest > 0 then
989
+ table.insert(mappings, {
990
+ source = entry.source,
991
+ destination = dest,
992
+ destructive = entry.destructive == true,
993
+ rojoMode = entry.rojoMode == true,
994
+ })
995
+ end
996
+ end
997
+ end
998
+ end
999
+
1000
+ local configPayload = {
1001
+ mappings = mappings,
1002
+ port = data.port,
1003
+ debugMode = data.debugMode,
1004
+ deleteOrphansOnConnect = data.deleteOrphansOnConnect,
1005
+ }
1006
+
1007
+ return configPayload, nil
1008
+ end
1009
+
1010
+ return AzulService