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.
- package/.gitattributes +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/README.md +142 -0
- package/dist/build.d.ts +19 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +92 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +397 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +105 -0
- package/dist/config.js.map +1 -0
- package/dist/fs/fileWriter.d.ts +100 -0
- package/dist/fs/fileWriter.d.ts.map +1 -0
- package/dist/fs/fileWriter.js +342 -0
- package/dist/fs/fileWriter.js.map +1 -0
- package/dist/fs/treeManager.d.ts +84 -0
- package/dist/fs/treeManager.d.ts.map +1 -0
- package/dist/fs/treeManager.js +365 -0
- package/dist/fs/treeManager.js.map +1 -0
- package/dist/fs/watcher.d.ts +39 -0
- package/dist/fs/watcher.d.ts.map +1 -0
- package/dist/fs/watcher.js +120 -0
- package/dist/fs/watcher.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/httpPolling.d.ts +56 -0
- package/dist/ipc/httpPolling.d.ts.map +1 -0
- package/dist/ipc/httpPolling.js +171 -0
- package/dist/ipc/httpPolling.js.map +1 -0
- package/dist/ipc/messages.d.ts +112 -0
- package/dist/ipc/messages.d.ts.map +1 -0
- package/dist/ipc/messages.js +5 -0
- package/dist/ipc/messages.js.map +1 -0
- package/dist/ipc/server.d.ts +50 -0
- package/dist/ipc/server.d.ts.map +1 -0
- package/dist/ipc/server.js +168 -0
- package/dist/ipc/server.js.map +1 -0
- package/dist/pack.d.ts +19 -0
- package/dist/pack.d.ts.map +1 -0
- package/dist/pack.js +225 -0
- package/dist/pack.js.map +1 -0
- package/dist/push.d.ts +43 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +532 -0
- package/dist/push.js.map +1 -0
- package/dist/rojo.d.ts +9 -0
- package/dist/rojo.d.ts.map +1 -0
- package/dist/rojo.js +114 -0
- package/dist/rojo.js.map +1 -0
- package/dist/snapshot/rojo.d.ts +39 -0
- package/dist/snapshot/rojo.d.ts.map +1 -0
- package/dist/snapshot/rojo.js +364 -0
- package/dist/snapshot/rojo.js.map +1 -0
- package/dist/snapshot.d.ts +23 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +132 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/sourcemap/generator.d.ts +78 -0
- package/dist/sourcemap/generator.d.ts.map +1 -0
- package/dist/sourcemap/generator.js +351 -0
- package/dist/sourcemap/generator.js.map +1 -0
- package/dist/sourcemap/propertyLoader.d.ts +19 -0
- package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
- package/dist/sourcemap/propertyLoader.js +131 -0
- package/dist/sourcemap/propertyLoader.js.map +1 -0
- package/dist/util/id.d.ts +9 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +14 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/log.d.ts +13 -0
- package/dist/util/log.d.ts.map +1 -0
- package/dist/util/log.js +51 -0
- package/dist/util/log.js.map +1 -0
- package/docs/assets/azul-logo.pdn +0 -0
- package/docs/assets/logo-200px.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/plugin/toolbox.png +0 -0
- package/docs/assets/synced.png +0 -0
- package/package.json +41 -0
- package/plugin/README.md +54 -0
- package/plugin/sourcemap.json +264 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
- package/src/build.ts +120 -0
- package/src/cli.ts +496 -0
- package/src/config.ts +170 -0
- package/src/fs/fileWriter.ts +414 -0
- package/src/fs/treeManager.ts +458 -0
- package/src/fs/watcher.ts +142 -0
- package/src/index.ts +450 -0
- package/src/ipc/httpPolling.ts +214 -0
- package/src/ipc/messages.ts +159 -0
- package/src/ipc/server.ts +196 -0
- package/src/pack.ts +309 -0
- package/src/push.ts +726 -0
- package/src/snapshot/rojo.ts +467 -0
- package/src/snapshot.ts +161 -0
- package/src/sourcemap/generator.ts +504 -0
- package/src/sourcemap/propertyLoader.ts +195 -0
- package/src/util/id.ts +15 -0
- package/src/util/log.ts +94 -0
- 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
|
+
}
|