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
package/src/index.ts ADDED
@@ -0,0 +1,450 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import * as http from "http";
5
+ import { IPCServer } from "./ipc/server.js";
6
+ import { HttpPollingServer } from "./ipc/httpPolling.js";
7
+ import { TreeManager, TreeNode } from "./fs/treeManager.js";
8
+ import { FileWriter } from "./fs/fileWriter.js";
9
+ import { FileWatcher } from "./fs/watcher.js";
10
+ import { SourcemapGenerator } from "./sourcemap/generator.js";
11
+ import { log } from "./util/log.js";
12
+ import { config, initializeConfig } from "./config.js";
13
+ import type { StudioMessage } from "./ipc/messages.js";
14
+
15
+ /**
16
+ * Main orchestrator for the Azul daemon
17
+ */
18
+ export class SyncDaemon {
19
+ private ipc: IPCServer;
20
+ private httpPolling: HttpPollingServer;
21
+ private httpServer: http.Server;
22
+ private tree: TreeManager;
23
+ private fileWriter: FileWriter;
24
+ private fileWatcher: FileWatcher;
25
+ private sourcemapGenerator: SourcemapGenerator;
26
+ private batchDepth = 0; // Tracks nested batch processing
27
+ private batchNeedsSourcemapRegen = false; // Defer regen until batch ends
28
+
29
+ constructor() {
30
+ this.tree = new TreeManager();
31
+ this.fileWriter = new FileWriter(config.syncDir);
32
+ this.fileWatcher = new FileWatcher();
33
+ this.sourcemapGenerator = new SourcemapGenerator();
34
+ this.httpPolling = new HttpPollingServer();
35
+
36
+ // Create HTTP server that handles both WebSocket upgrades and HTTP polling
37
+ this.httpServer = http.createServer((req, res) => {
38
+ const handled = this.httpPolling.handleRequest(req, res);
39
+ if (!handled) {
40
+ res.writeHead(404);
41
+ res.end("Not found");
42
+ }
43
+ });
44
+
45
+ this.ipc = new IPCServer(config.port, this.httpServer);
46
+
47
+ this.setupHandlers();
48
+ this.httpServer.listen(config.port);
49
+ }
50
+
51
+ /**
52
+ * Set up all event handlers
53
+ */
54
+ private setupHandlers(): void {
55
+ // Handle messages from Studio (WebSocket)
56
+ this.ipc.onMessage((message) => this.handleStudioMessage(message));
57
+
58
+ // Handle messages from Studio (HTTP polling)
59
+ this.httpPolling.onMessage((message) => this.handleStudioMessage(message));
60
+
61
+ // Handle file changes from filesystem
62
+ this.fileWatcher.onChange((filePath, source) => {
63
+ this.handleFileChange(filePath, source);
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Handle incoming messages from Studio
69
+ */
70
+ private handleStudioMessage(message: StudioMessage): void {
71
+ if (message.type === "batch") {
72
+ this.batchDepth += 1;
73
+ try {
74
+ for (const payload of message.messages) {
75
+ this.handleStudioMessage(payload);
76
+ }
77
+ } finally {
78
+ this.batchDepth -= 1;
79
+
80
+ // If any delete in this batch missed its prune, only regenerate once at the end
81
+ if (this.batchDepth === 0 && this.batchNeedsSourcemapRegen) {
82
+ this.regenerateSourcemap();
83
+ this.batchNeedsSourcemapRegen = false;
84
+ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ switch (message.type) {
90
+ case "fullSnapshot":
91
+ this.handleFullSnapshot(message.data);
92
+ break;
93
+
94
+ case "scriptChanged":
95
+ this.handleScriptChanged(message.data);
96
+ break;
97
+
98
+ case "instanceUpdated":
99
+ this.handleInstanceUpdated(message.data);
100
+ break;
101
+
102
+ case "deleted":
103
+ this.handleDeleted(message.data);
104
+ break;
105
+
106
+ case "ping":
107
+ this.ipc.send({ type: "pong" });
108
+ break;
109
+
110
+ case "clientDisconnect":
111
+ log.info("Studio requested to close the connection");
112
+ this.ipc.close();
113
+ break;
114
+
115
+ default:
116
+ log.warn("Unknown message type:", (message as any).type);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Handle full snapshot from Studio
122
+ */
123
+ private handleFullSnapshot(data: any[]): void {
124
+ log.info("Received full snapshot from Studio");
125
+
126
+ // Update tree
127
+ this.tree.applyFullSnapshot(data);
128
+
129
+ // Write all scripts to filesystem
130
+ this.fileWriter.writeTree(this.tree.getAllNodes());
131
+
132
+ // Remove any pre-existing files that are no longer mapped (optional)
133
+ this.cleanupOrphanFiles();
134
+
135
+ // Start file watching
136
+ this.fileWatcher.watch(this.fileWriter.getBaseDir());
137
+
138
+ // Generate sourcemap
139
+ this.regenerateSourcemap();
140
+
141
+ // Log statistics
142
+ const stats = this.tree.getStats();
143
+ log.success(
144
+ `Sync complete: ${stats.scriptNodes} scripts, ${stats.totalNodes} total nodes`,
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Handle script source change
150
+ */
151
+ private handleScriptChanged(message: {
152
+ guid: string;
153
+ source: string;
154
+ path: string[];
155
+ className: string;
156
+ }): void {
157
+ const { guid, source, path: instancePath, className } = message;
158
+
159
+ // Update tree
160
+ this.tree.updateScriptSource(guid, source);
161
+
162
+ // Get or create node
163
+ let node = this.tree.getNode(guid);
164
+ if (!node) {
165
+ // Create new node if it doesn't exist
166
+ this.tree.updateInstance({
167
+ guid,
168
+ className,
169
+ name: instancePath[instancePath.length - 1],
170
+ path: instancePath,
171
+ source,
172
+ });
173
+ node = this.tree.getNode(guid);
174
+ }
175
+
176
+ if (node) {
177
+ // Precompute path and suppress watcher before writing to avoid race conditions
178
+ const filePath = this.fileWriter.getFilePath(node);
179
+ this.fileWatcher.suppressNextChange(filePath);
180
+
181
+ // Write to filesystem
182
+ this.fileWriter.writeScript(node);
183
+
184
+ // Incrementally update sourcemap entry for this script
185
+ this.sourcemapGenerator.upsertSubtree(
186
+ node,
187
+ this.tree.getAllNodes(),
188
+ this.fileWriter.getAllMappings(),
189
+ config.sourcemapPath,
190
+ undefined,
191
+ false,
192
+ );
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Handle instance update (rename, move, etc.)
198
+ */
199
+ private handleInstanceUpdated(data: any): void {
200
+ const update = this.tree.updateInstance(data);
201
+ const node = update?.node;
202
+
203
+ if (!node) {
204
+ return;
205
+ }
206
+
207
+ const scriptsToUpdate: Map<string, TreeNode> = new Map();
208
+
209
+ if (this.isScriptClass(node.className)) {
210
+ scriptsToUpdate.set(node.guid, node);
211
+ }
212
+
213
+ if (update.pathChanged || update.nameChanged || update.parentChanged) {
214
+ for (const child of this.tree.getDescendantScripts(node.guid)) {
215
+ scriptsToUpdate.set(child.guid, child);
216
+ }
217
+ }
218
+
219
+ for (const scriptNode of scriptsToUpdate.values()) {
220
+ const filePath = this.fileWriter.getFilePath(scriptNode);
221
+ this.fileWatcher.suppressNextChange(filePath);
222
+ this.fileWriter.writeScript(scriptNode);
223
+ }
224
+
225
+ const shouldUpdateSourcemap =
226
+ update.isNew ||
227
+ update.pathChanged ||
228
+ update.nameChanged ||
229
+ update.parentChanged ||
230
+ this.isScriptClass(node.className);
231
+
232
+ if (shouldUpdateSourcemap) {
233
+ this.sourcemapGenerator.upsertSubtree(
234
+ node,
235
+ this.tree.getAllNodes(),
236
+ this.fileWriter.getAllMappings(),
237
+ config.sourcemapPath,
238
+ update.prevPath,
239
+ update.isNew,
240
+ );
241
+ }
242
+
243
+ this.fileWriter.cleanupEmptyDirectories();
244
+ }
245
+
246
+ /**
247
+ * Handle instance deletion
248
+ */
249
+ private handleDeleted(message: { guid: string }): void {
250
+ const { guid } = message;
251
+ const node = this.tree.getNode(guid);
252
+
253
+ // If the node is already gone (e.g., child deletes after parent delete), fall back to full cleanup
254
+ if (!node) {
255
+ log.debug(`Delete ignored for unknown guid: ${guid}`);
256
+ this.fileWriter.deleteScript(guid);
257
+ // this.regenerateSourcemap();
258
+ this.fileWriter.cleanupEmptyDirectories();
259
+ return;
260
+ }
261
+
262
+ // Capture all script descendants (and the node itself if script) before we delete the tree nodes
263
+ const scriptsToDelete: { guid: string; filePath: string | null }[] = [];
264
+ const collectScript = (scriptNode: TreeNode): void => {
265
+ const filePath = this.fileWriter.getFilePath(scriptNode);
266
+ scriptsToDelete.push({ guid: scriptNode.guid, filePath });
267
+ };
268
+
269
+ if (this.isScriptClass(node.className)) {
270
+ collectScript(node);
271
+ }
272
+ for (const child of this.tree.getDescendantScripts(node.guid)) {
273
+ collectScript(child);
274
+ }
275
+
276
+ const pathSegments = node.path;
277
+
278
+ // Delete from tree (removes node and descendants)
279
+ this.tree.deleteInstance(guid);
280
+
281
+ // Delete files for all affected scripts
282
+ for (const entry of scriptsToDelete) {
283
+ const removed = this.fileWriter.deleteScript(entry.guid);
284
+ if (!removed && entry.filePath) {
285
+ this.fileWriter.deleteFilePath(entry.filePath);
286
+ }
287
+ }
288
+
289
+ // Remove subtree from sourcemap
290
+ const outputPath = config.sourcemapPath;
291
+ const pruned = this.sourcemapGenerator.prunePath(
292
+ pathSegments,
293
+ outputPath,
294
+ this.tree.getAllNodes(),
295
+ this.fileWriter.getAllMappings(),
296
+ node.className,
297
+ node.guid,
298
+ );
299
+
300
+ // If prune failed to find the path (e.g., sourcemap drift), rebuild once to stay consistent
301
+ if (!pruned) {
302
+ if (this.batchDepth > 0) {
303
+ // Defer regeneration until the batch completes to avoid repeated full rebuilds
304
+ this.batchNeedsSourcemapRegen = true;
305
+ log.debug("Regenerating sourcemap after batched prune miss");
306
+ } else {
307
+ log.debug("Regenerating sourcemap due to prune miss");
308
+ this.regenerateSourcemap();
309
+ }
310
+ }
311
+
312
+ this.fileWriter.cleanupEmptyDirectories();
313
+ }
314
+
315
+ /**
316
+ * Handle file change from filesystem
317
+ */
318
+ private handleFileChange(filePath: string, source: string): void {
319
+ // Find the GUID for this file
320
+ const guid = this.fileWriter.getGuidByPath(filePath);
321
+
322
+ if (guid) {
323
+ log.info(
324
+ `File changed externally: ${path.relative(this.fileWriter.getBaseDir(), filePath)}.`,
325
+ );
326
+
327
+ // Update tree
328
+ this.tree.updateScriptSource(guid, source);
329
+
330
+ // Send patch to Studio (both WebSocket and HTTP polling clients)
331
+ this.ipc.patchScript(guid, source);
332
+ this.httpPolling.broadcast({ type: "patchScript", guid, source });
333
+ } else {
334
+ log.warn(`No mapping found for file: ${filePath}`);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Regenerate the sourcemap
340
+ */
341
+ private regenerateSourcemap(): void {
342
+ // Write sourcemap into the sync directory so Luau-LSP can find it
343
+ const outputPath = config.sourcemapPath;
344
+ this.sourcemapGenerator.generateAndWrite(
345
+ this.tree.getAllNodes(),
346
+ this.fileWriter.getAllMappings(),
347
+ outputPath,
348
+ );
349
+ }
350
+
351
+ /**
352
+ * Start the daemon
353
+ */
354
+ public start(): void {
355
+ log.info("🚀 Azul daemon starting...");
356
+ log.info(`Sync directory: ${config.syncDir}`);
357
+ log.info(`HTTP/WebSocket port: ${config.port}`);
358
+ log.info("");
359
+ log.success(`Server listening on http://localhost:${config.port}`);
360
+ log.info("Waiting for Studio connection...");
361
+ }
362
+
363
+ /**
364
+ * Stop the daemon
365
+ */
366
+ public async stop(): Promise<void> {
367
+ log.info("Stopping daemon...");
368
+ await this.fileWatcher.stop();
369
+ this.ipc.close();
370
+ this.httpServer.close();
371
+ log.info("Daemon stopped");
372
+ }
373
+
374
+ private isScriptClass(className: string): boolean {
375
+ return (
376
+ className === "Script" ||
377
+ className === "LocalScript" ||
378
+ className === "ModuleScript"
379
+ );
380
+ }
381
+
382
+ /**
383
+ * Delete files under syncDir that are not mapped to any instance (opt-in).
384
+ */
385
+ private cleanupOrphanFiles(): void {
386
+ if (!config.deleteOrphansOnConnect) {
387
+ return;
388
+ }
389
+
390
+ const baseDir = this.fileWriter.getBaseDir();
391
+ const mapped = new Set<string>();
392
+
393
+ for (const mapping of this.fileWriter.getAllMappings().values()) {
394
+ mapped.add(path.resolve(mapping.filePath));
395
+ }
396
+
397
+ let removedFiles: string[] = [];
398
+
399
+ const walk = (dir: string): void => {
400
+ if (!fs.existsSync(dir)) return;
401
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
402
+ for (const entry of entries) {
403
+ const fullPath = path.join(dir, entry.name);
404
+ if (entry.isDirectory()) {
405
+ walk(fullPath);
406
+ } else {
407
+ if (!mapped.has(path.resolve(fullPath))) {
408
+ try {
409
+ fs.unlinkSync(fullPath);
410
+ removedFiles.push(entry.name);
411
+ } catch (error) {
412
+ log.warn("Failed to delete orphan file:", fullPath, error);
413
+ }
414
+ }
415
+ }
416
+ }
417
+ };
418
+
419
+ walk(baseDir);
420
+ if (removedFiles.length > 0) {
421
+ this.fileWriter.cleanupEmptyDirectories();
422
+ log.success(
423
+ `Removed ${removedFiles.length} orphan file(s) from sync directory (${removedFiles.join(", ")})`,
424
+ );
425
+ }
426
+ }
427
+ }
428
+
429
+ // Allow direct execution (`node dist/index.js`) while preventing side effects when imported by the CLI
430
+ const isDirectRun =
431
+ process.argv[1] &&
432
+ fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
433
+
434
+ if (isDirectRun) {
435
+ initializeConfig();
436
+ const daemon = new SyncDaemon();
437
+ daemon.start();
438
+
439
+ // Handle graceful shutdown
440
+ process.on("SIGINT", async () => {
441
+ console.log("\n");
442
+ await daemon.stop();
443
+ process.exit(0);
444
+ });
445
+
446
+ process.on("SIGTERM", async () => {
447
+ await daemon.stop();
448
+ process.exit(0);
449
+ });
450
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * HTTP endpoints for plugin polling compatibility
3
+ */
4
+
5
+ import { IncomingMessage, ServerResponse } from "http";
6
+ import { log } from "../util/log.js";
7
+
8
+ interface PollingClient {
9
+ id: string;
10
+ messageQueue: any[];
11
+ lastPoll: number;
12
+ }
13
+
14
+ /**
15
+ * Manages HTTP polling for Roblox Studio plugin compatibility
16
+ */
17
+ export class HttpPollingServer {
18
+ private clients: Map<string, PollingClient> = new Map();
19
+ private messageHandler: ((message: any, clientId: string) => void) | null =
20
+ null;
21
+
22
+ /**
23
+ * Handle HTTP request
24
+ */
25
+ public handleRequest(req: IncomingMessage, res: ServerResponse): boolean {
26
+ const url = new URL(req.url || "", `http://${req.headers.host}`);
27
+
28
+ // Enable CORS
29
+ res.setHeader("Access-Control-Allow-Origin", "*");
30
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
31
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
32
+
33
+ if (req.method === "OPTIONS") {
34
+ res.writeHead(200);
35
+ res.end();
36
+ return true;
37
+ }
38
+
39
+ switch (url.pathname) {
40
+ case "/connect":
41
+ return this.handleConnect(req, res);
42
+ case "/send":
43
+ return this.handleSend(req, res);
44
+ case "/poll":
45
+ return this.handlePoll(req, res, url);
46
+ case "/disconnect":
47
+ return this.handleDisconnect(req, res);
48
+ default:
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Handle connect request
55
+ */
56
+ private handleConnect(_req: IncomingMessage, res: ServerResponse): boolean {
57
+ const clientId = this.generateClientId();
58
+
59
+ this.clients.set(clientId, {
60
+ id: clientId,
61
+ messageQueue: [],
62
+ lastPoll: Date.now(),
63
+ });
64
+
65
+ log.info(`HTTP client connected: ${clientId}`);
66
+
67
+ res.writeHead(200, { "Content-Type": "application/json" });
68
+ res.end(JSON.stringify({ clientId }));
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Handle send request
74
+ */
75
+ private handleSend(req: IncomingMessage, res: ServerResponse): boolean {
76
+ let body = "";
77
+
78
+ req.on("data", (chunk) => {
79
+ body += chunk.toString();
80
+ });
81
+
82
+ req.on("end", () => {
83
+ try {
84
+ const data = JSON.parse(body);
85
+ const { clientId, message } = data;
86
+
87
+ if (this.messageHandler) {
88
+ this.messageHandler(message, clientId);
89
+ }
90
+
91
+ res.writeHead(200, { "Content-Type": "application/json" });
92
+ res.end(JSON.stringify({ success: true }));
93
+ } catch (error) {
94
+ res.writeHead(400, { "Content-Type": "application/json" });
95
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
96
+ }
97
+ });
98
+
99
+ return true;
100
+ }
101
+
102
+ /**
103
+ * Handle poll request
104
+ */
105
+ private handlePoll(
106
+ _req: IncomingMessage,
107
+ res: ServerResponse,
108
+ url: URL
109
+ ): boolean {
110
+ const clientId = url.searchParams.get("clientId");
111
+
112
+ if (!clientId || !this.clients.has(clientId)) {
113
+ res.writeHead(404, { "Content-Type": "application/json" });
114
+ res.end(JSON.stringify({ error: "Client not found" }));
115
+ return true;
116
+ }
117
+
118
+ const client = this.clients.get(clientId)!;
119
+ client.lastPoll = Date.now();
120
+
121
+ const messages = client.messageQueue.splice(0); // Take all messages
122
+
123
+ res.writeHead(200, { "Content-Type": "application/json" });
124
+ res.end(JSON.stringify(messages));
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * Handle disconnect request
130
+ */
131
+ private handleDisconnect(req: IncomingMessage, res: ServerResponse): boolean {
132
+ let body = "";
133
+
134
+ req.on("data", (chunk) => {
135
+ body += chunk.toString();
136
+ });
137
+
138
+ req.on("end", () => {
139
+ try {
140
+ const data = JSON.parse(body);
141
+ const { clientId } = data;
142
+
143
+ if (clientId && this.clients.has(clientId)) {
144
+ this.clients.delete(clientId);
145
+ log.info(`HTTP client disconnected: ${clientId}`);
146
+ }
147
+
148
+ res.writeHead(200, { "Content-Type": "application/json" });
149
+ res.end(JSON.stringify({ success: true }));
150
+ } catch (error) {
151
+ res.writeHead(400, { "Content-Type": "application/json" });
152
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
153
+ }
154
+ });
155
+
156
+ return true;
157
+ }
158
+
159
+ /**
160
+ * Send message to a specific client
161
+ */
162
+ public sendToClient(clientId: string, message: any): boolean {
163
+ const client = this.clients.get(clientId);
164
+ if (!client) {
165
+ return false;
166
+ }
167
+
168
+ client.messageQueue.push(message);
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Broadcast message to all clients
174
+ */
175
+ public broadcast(message: any): void {
176
+ for (const client of this.clients.values()) {
177
+ client.messageQueue.push(message);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Set message handler
183
+ */
184
+ public onMessage(handler: (message: any, clientId: string) => void): void {
185
+ this.messageHandler = handler;
186
+ }
187
+
188
+ /**
189
+ * Clean up stale clients
190
+ */
191
+ public cleanupStaleClients(timeoutMs: number = 60000): void {
192
+ const now = Date.now();
193
+ for (const [id, client] of this.clients.entries()) {
194
+ if (now - client.lastPoll > timeoutMs) {
195
+ this.clients.delete(id);
196
+ log.info(`Removed stale HTTP client: ${id}`);
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Generate unique client ID
203
+ */
204
+ private generateClientId(): string {
205
+ return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
206
+ }
207
+
208
+ /**
209
+ * Get active client count
210
+ */
211
+ public getClientCount(): number {
212
+ return this.clients.size;
213
+ }
214
+ }