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
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for communication protocol between Studio and Daemon
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type InstanceClassName =
|
|
6
|
+
| "Script"
|
|
7
|
+
| "LocalScript"
|
|
8
|
+
| "ModuleScript"
|
|
9
|
+
| "Folder"
|
|
10
|
+
| "Model"
|
|
11
|
+
| "Part"
|
|
12
|
+
| "MeshPart"
|
|
13
|
+
| "Tool"
|
|
14
|
+
| "Configuration"
|
|
15
|
+
| string; // Allow any Roblox class
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Represents a single instance in the DataModel
|
|
19
|
+
*/
|
|
20
|
+
export interface InstanceData {
|
|
21
|
+
guid: string;
|
|
22
|
+
className: InstanceClassName;
|
|
23
|
+
name: string;
|
|
24
|
+
path: string[]; // ["ReplicatedStorage", "Modules", "Foo"]
|
|
25
|
+
parentGuid?: string | null; // parent instance GUID
|
|
26
|
+
source?: string; // Only present for Script/LocalScript/ModuleScript
|
|
27
|
+
properties?: Record<string, unknown>;
|
|
28
|
+
attributes?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SnapshotRequestOptions {
|
|
32
|
+
includeProperties?: boolean;
|
|
33
|
+
scriptsAndDescendantsOnly?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Messages from Studio → Daemon
|
|
38
|
+
*/
|
|
39
|
+
export type StudioPayloadMessage =
|
|
40
|
+
| FullSnapshotMessage
|
|
41
|
+
| InstanceUpdatedMessage
|
|
42
|
+
| ScriptChangedMessage
|
|
43
|
+
| DeletedMessage
|
|
44
|
+
| PingMessage
|
|
45
|
+
| ClientDisconnect
|
|
46
|
+
| PushConfigMessage;
|
|
47
|
+
|
|
48
|
+
export interface BatchMessage {
|
|
49
|
+
type: "batch";
|
|
50
|
+
messages: StudioPayloadMessage[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type StudioMessage = StudioPayloadMessage | BatchMessage;
|
|
54
|
+
|
|
55
|
+
export interface FullSnapshotMessage {
|
|
56
|
+
type: "fullSnapshot";
|
|
57
|
+
data: InstanceData[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface InstanceUpdatedMessage {
|
|
61
|
+
type: "instanceUpdated";
|
|
62
|
+
data: InstanceData;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ScriptChangedMessage {
|
|
66
|
+
type: "scriptChanged";
|
|
67
|
+
data: {
|
|
68
|
+
guid: string;
|
|
69
|
+
path: string[];
|
|
70
|
+
className: InstanceClassName;
|
|
71
|
+
source: string;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DeletedMessage {
|
|
76
|
+
type: "deleted";
|
|
77
|
+
data: {
|
|
78
|
+
guid: string;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PingMessage {
|
|
83
|
+
type: "ping";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ClientDisconnect {
|
|
87
|
+
type: "clientDisconnect";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface PushConfigMessage {
|
|
91
|
+
type: "pushConfig";
|
|
92
|
+
config: PushConfig;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Messages from Daemon → Studio
|
|
97
|
+
*/
|
|
98
|
+
export type DaemonMessage =
|
|
99
|
+
| PatchScriptMessage
|
|
100
|
+
| RequestSnapshotMessage
|
|
101
|
+
| PongMessage
|
|
102
|
+
| ErrorMessage
|
|
103
|
+
| BuildSnapshotMessage
|
|
104
|
+
| RequestPushConfigMessage
|
|
105
|
+
| PushSnapshotMessage;
|
|
106
|
+
|
|
107
|
+
export interface PatchScriptMessage {
|
|
108
|
+
type: "patchScript";
|
|
109
|
+
guid: string;
|
|
110
|
+
source: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface RequestSnapshotMessage {
|
|
114
|
+
type: "requestSnapshot";
|
|
115
|
+
options?: SnapshotRequestOptions;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface PongMessage {
|
|
119
|
+
type: "pong";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ErrorMessage {
|
|
123
|
+
type: "error";
|
|
124
|
+
message: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface BuildSnapshotMessage {
|
|
128
|
+
type: "buildSnapshot";
|
|
129
|
+
data: InstanceData[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface RequestPushConfigMessage {
|
|
133
|
+
type: "requestPushConfig";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface PushSnapshotMessage {
|
|
137
|
+
type: "pushSnapshot";
|
|
138
|
+
mappings: PushSnapshotMapping[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PushSnapshotMapping {
|
|
142
|
+
destination: string[];
|
|
143
|
+
destructive?: boolean;
|
|
144
|
+
instances: InstanceData[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PushConfig {
|
|
148
|
+
mappings: PushConfigMapping[];
|
|
149
|
+
port?: number;
|
|
150
|
+
debugMode?: boolean;
|
|
151
|
+
deleteOrphansOnConnect?: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface PushConfigMapping {
|
|
155
|
+
source: string;
|
|
156
|
+
destination: string[];
|
|
157
|
+
destructive?: boolean;
|
|
158
|
+
rojoMode?: boolean;
|
|
159
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import { log } from "../util/log.js";
|
|
3
|
+
import type { StudioMessage, DaemonMessage } from "./messages.js";
|
|
4
|
+
import type { SnapshotRequestOptions } from "./messages.js";
|
|
5
|
+
import type { Server as HttpServer } from "http";
|
|
6
|
+
|
|
7
|
+
export type MessageHandler = (message: StudioMessage) => void;
|
|
8
|
+
|
|
9
|
+
interface IPCServerOptions {
|
|
10
|
+
requestSnapshotOnConnect?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class IPCServer {
|
|
14
|
+
private wss: WebSocketServer;
|
|
15
|
+
private client: WebSocket | null = null;
|
|
16
|
+
private messageHandler: MessageHandler | null = null;
|
|
17
|
+
private connectionHandler: (() => void) | null = null;
|
|
18
|
+
private requestSnapshotOnConnect: boolean;
|
|
19
|
+
|
|
20
|
+
constructor(port?: number, server?: HttpServer, options?: IPCServerOptions) {
|
|
21
|
+
this.requestSnapshotOnConnect = options?.requestSnapshotOnConnect !== false;
|
|
22
|
+
if (server) {
|
|
23
|
+
// Use existing HTTP server
|
|
24
|
+
this.wss = new WebSocketServer({
|
|
25
|
+
server,
|
|
26
|
+
// perMessageDeflate: false, // Roblox WebSocket client does not negotiate RSV2/RSV3 extensions
|
|
27
|
+
maxPayload: 256 * 1024 * 1024, // 256 MB
|
|
28
|
+
});
|
|
29
|
+
} else {
|
|
30
|
+
// Create standalone WebSocket server
|
|
31
|
+
this.wss = new WebSocketServer({
|
|
32
|
+
port: port || 8080,
|
|
33
|
+
// perMessageDeflate: false, // avoid RSV2/RSV3 bits from compression
|
|
34
|
+
maxPayload: 256 * 1024 * 1024, // 256 MB
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
this.setupServer();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private setupServer(): void {
|
|
41
|
+
this.wss.on("connection", (ws) => {
|
|
42
|
+
log.info("Studio client connected");
|
|
43
|
+
log.info("Waiting for Studio messages...");
|
|
44
|
+
|
|
45
|
+
// Disconnect previous client if exists
|
|
46
|
+
if (this.client) {
|
|
47
|
+
log.warn("Disconnecting previous client");
|
|
48
|
+
this.client.close();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.client = ws;
|
|
52
|
+
|
|
53
|
+
if (this.connectionHandler) {
|
|
54
|
+
this.connectionHandler();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ws.on("message", (data) => {
|
|
58
|
+
try {
|
|
59
|
+
const message: StudioMessage = JSON.parse(data.toString());
|
|
60
|
+
log.debug(`Received: ${message.type}`);
|
|
61
|
+
|
|
62
|
+
if (this.messageHandler) {
|
|
63
|
+
this.messageHandler(message);
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
log.error("Failed to parse message:", error);
|
|
67
|
+
this.sendError("Invalid JSON message");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
ws.on("close", () => {
|
|
72
|
+
log.info("Studio client disconnected");
|
|
73
|
+
this.client = null;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
ws.on("error", (error) => {
|
|
77
|
+
log.error("WebSocket error:", error);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Set up ping/pong to keep connection alive
|
|
81
|
+
ws.on("pong", () => {
|
|
82
|
+
log.debug("Received pong from client");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Send ping every 30 seconds
|
|
86
|
+
const pingInterval = setInterval(() => {
|
|
87
|
+
if (this.client === ws && ws.readyState === WebSocket.OPEN) {
|
|
88
|
+
ws.ping();
|
|
89
|
+
} else {
|
|
90
|
+
clearInterval(pingInterval);
|
|
91
|
+
}
|
|
92
|
+
}, 30000);
|
|
93
|
+
|
|
94
|
+
// Request initial snapshot after a brief delay
|
|
95
|
+
if (this.requestSnapshotOnConnect) {
|
|
96
|
+
setTimeout(() => {
|
|
97
|
+
if (this.client === ws) {
|
|
98
|
+
this.send({ type: "requestSnapshot" });
|
|
99
|
+
}
|
|
100
|
+
}, 100);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.wss.on("listening", () => {
|
|
105
|
+
log.success("WebSocket server ready");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
this.wss.on("error", (error) => {
|
|
109
|
+
log.error("WebSocket server error:", error);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Register a handler for incoming Studio messages
|
|
115
|
+
*/
|
|
116
|
+
public onMessage(handler: MessageHandler): void {
|
|
117
|
+
this.messageHandler = handler;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Register a handler that fires when a Studio client connects
|
|
122
|
+
*/
|
|
123
|
+
public onConnection(handler: () => void): void {
|
|
124
|
+
this.connectionHandler = handler;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Send a message to the connected Studio client
|
|
129
|
+
*/
|
|
130
|
+
public send(message: DaemonMessage): boolean {
|
|
131
|
+
if (!this.client || this.client.readyState !== WebSocket.OPEN) {
|
|
132
|
+
log.warn("Cannot send message: no connected client");
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
this.client.send(JSON.stringify(message));
|
|
138
|
+
log.debug(`Sent: ${message.type}`);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
log.error("Failed to send message:", error);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Send a patch to update a script's source in Studio
|
|
148
|
+
*/
|
|
149
|
+
public patchScript(guid: string, source: string): boolean {
|
|
150
|
+
return this.send({
|
|
151
|
+
type: "patchScript",
|
|
152
|
+
guid,
|
|
153
|
+
source,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Send an error message to Studio
|
|
159
|
+
*/
|
|
160
|
+
public sendError(message: string): boolean {
|
|
161
|
+
return this.send({
|
|
162
|
+
type: "error",
|
|
163
|
+
message,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Request a full snapshot from Studio
|
|
169
|
+
*/
|
|
170
|
+
public requestSnapshot(options?: SnapshotRequestOptions): boolean {
|
|
171
|
+
return this.send({
|
|
172
|
+
type: "requestSnapshot",
|
|
173
|
+
options,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check if a client is connected
|
|
179
|
+
*/
|
|
180
|
+
public isConnected(): boolean {
|
|
181
|
+
return this.client !== null && this.client.readyState === WebSocket.OPEN;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Close the server
|
|
186
|
+
*/
|
|
187
|
+
public close(): void {
|
|
188
|
+
if (this.client) {
|
|
189
|
+
this.client.close();
|
|
190
|
+
}
|
|
191
|
+
this.wss.close();
|
|
192
|
+
log.info("WebSocket server closed.");
|
|
193
|
+
log.info("Exiting...");
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/pack.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { IPCServer } from "./ipc/server.js";
|
|
4
|
+
import { config } from "./config.js";
|
|
5
|
+
import { log } from "./util/log.js";
|
|
6
|
+
import type {
|
|
7
|
+
InstanceData,
|
|
8
|
+
SnapshotRequestOptions,
|
|
9
|
+
StudioMessage,
|
|
10
|
+
} from "./ipc/messages.js";
|
|
11
|
+
|
|
12
|
+
interface PackOptions {
|
|
13
|
+
outputPath?: string;
|
|
14
|
+
scriptsAndDescendantsOnly?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SourcemapNode {
|
|
18
|
+
name: string;
|
|
19
|
+
className: string;
|
|
20
|
+
guid?: string;
|
|
21
|
+
filePaths?: string[];
|
|
22
|
+
children?: SourcemapNode[];
|
|
23
|
+
properties?: Record<string, unknown>;
|
|
24
|
+
attributes?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SourcemapRoot {
|
|
28
|
+
name: string;
|
|
29
|
+
className: string;
|
|
30
|
+
children: SourcemapNode[];
|
|
31
|
+
_azul?: {
|
|
32
|
+
packVersion?: number;
|
|
33
|
+
packedAt?: string;
|
|
34
|
+
mode?: "all" | "scripts-and-descendants";
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PackCommand {
|
|
39
|
+
private ipc: IPCServer;
|
|
40
|
+
private outputPath: string;
|
|
41
|
+
private scriptsAndDescendantsOnly: boolean;
|
|
42
|
+
|
|
43
|
+
constructor(options: PackOptions = {}) {
|
|
44
|
+
this.outputPath = path.resolve(options.outputPath ?? config.sourcemapPath);
|
|
45
|
+
this.scriptsAndDescendantsOnly = Boolean(options.scriptsAndDescendantsOnly);
|
|
46
|
+
this.ipc = new IPCServer(config.port, undefined, {
|
|
47
|
+
requestSnapshotOnConnect: false,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public async run(): Promise<void> {
|
|
52
|
+
log.info(`Waiting for Studio to connect on port ${config.port}...`);
|
|
53
|
+
const snapshot = await this.requestSnapshot({
|
|
54
|
+
includeProperties: true,
|
|
55
|
+
scriptsAndDescendantsOnly: this.scriptsAndDescendantsOnly,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!snapshot) {
|
|
59
|
+
log.error("Failed to receive snapshot from Studio for packing.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const existing = this.readExistingSourcemap();
|
|
64
|
+
const regenerated = this.regenerateSourcemap(snapshot, existing);
|
|
65
|
+
const packedCount = this.packIntoSourcemap(snapshot, regenerated);
|
|
66
|
+
|
|
67
|
+
this.writeSourcemap(regenerated, this.outputPath);
|
|
68
|
+
log.success(`Packed ${packedCount} node(s) into ${this.outputPath}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async requestSnapshot(
|
|
72
|
+
options: SnapshotRequestOptions,
|
|
73
|
+
): Promise<InstanceData[] | null> {
|
|
74
|
+
return new Promise<InstanceData[] | null>((resolve) => {
|
|
75
|
+
let timeoutHandle: NodeJS.Timeout | null = null;
|
|
76
|
+
let resolved = false;
|
|
77
|
+
|
|
78
|
+
const finalize = (result: InstanceData[] | null): void => {
|
|
79
|
+
if (resolved) return;
|
|
80
|
+
resolved = true;
|
|
81
|
+
|
|
82
|
+
if (timeoutHandle) {
|
|
83
|
+
clearTimeout(timeoutHandle);
|
|
84
|
+
timeoutHandle = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
this.ipc.close();
|
|
89
|
+
}, 200);
|
|
90
|
+
|
|
91
|
+
resolve(result);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this.ipc.onMessage((message: StudioMessage) => {
|
|
95
|
+
if (message.type !== "fullSnapshot") return;
|
|
96
|
+
finalize(message.data);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.ipc.onConnection(() => {
|
|
100
|
+
log.info("Studio connected. Requesting snapshot...");
|
|
101
|
+
this.ipc.requestSnapshot(options);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
timeoutHandle = setTimeout(() => {
|
|
105
|
+
log.error("Timed out waiting for Studio snapshot.");
|
|
106
|
+
finalize(null);
|
|
107
|
+
}, 30000);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private readExistingSourcemap(): SourcemapRoot | null {
|
|
112
|
+
if (!fs.existsSync(this.outputPath)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const raw = fs.readFileSync(this.outputPath, "utf8");
|
|
118
|
+
return JSON.parse(raw) as SourcemapRoot;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
log.warn(
|
|
121
|
+
`Failed to read existing sourcemap at ${this.outputPath}: ${error}`,
|
|
122
|
+
);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private regenerateSourcemap(
|
|
128
|
+
snapshot: InstanceData[],
|
|
129
|
+
existing: SourcemapRoot | null,
|
|
130
|
+
): SourcemapRoot {
|
|
131
|
+
const root: SourcemapRoot = {
|
|
132
|
+
name: "Game",
|
|
133
|
+
className: "DataModel",
|
|
134
|
+
children: [],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const guidFilePaths = new Map<string, string[]>();
|
|
138
|
+
const pathClassFilePaths = new Map<string, string[][]>();
|
|
139
|
+
const pathClassCursor = new Map<string, number>();
|
|
140
|
+
|
|
141
|
+
const indexExisting = (
|
|
142
|
+
node: SourcemapNode,
|
|
143
|
+
currentPath: string[],
|
|
144
|
+
): void => {
|
|
145
|
+
const nodePath = [...currentPath, node.name];
|
|
146
|
+
if (node.filePaths && node.filePaths.length > 0) {
|
|
147
|
+
if (node.guid) {
|
|
148
|
+
guidFilePaths.set(node.guid, node.filePaths);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const key = this.pathClassKey(nodePath, node.className);
|
|
152
|
+
const bucket = pathClassFilePaths.get(key) ?? [];
|
|
153
|
+
bucket.push(node.filePaths);
|
|
154
|
+
pathClassFilePaths.set(key, bucket);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const child of node.children ?? []) {
|
|
158
|
+
indexExisting(child, nodePath);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
for (const child of existing?.children ?? []) {
|
|
163
|
+
indexExisting(child, []);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const byGuid = new Map<string, SourcemapNode>();
|
|
167
|
+
byGuid.set("root", root as unknown as SourcemapNode);
|
|
168
|
+
|
|
169
|
+
const sorted = [...snapshot].sort((a, b) => {
|
|
170
|
+
if (a.path.length !== b.path.length) {
|
|
171
|
+
return a.path.length - b.path.length;
|
|
172
|
+
}
|
|
173
|
+
return a.path.join("/").localeCompare(b.path.join("/"));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
for (const item of sorted) {
|
|
177
|
+
const node: SourcemapNode = {
|
|
178
|
+
name: item.name,
|
|
179
|
+
className: item.className,
|
|
180
|
+
guid: item.guid,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const directFilePaths = guidFilePaths.get(item.guid);
|
|
184
|
+
if (directFilePaths && directFilePaths.length > 0) {
|
|
185
|
+
node.filePaths = directFilePaths;
|
|
186
|
+
} else {
|
|
187
|
+
const key = this.pathClassKey(item.path, item.className);
|
|
188
|
+
const bucket = pathClassFilePaths.get(key);
|
|
189
|
+
if (bucket && bucket.length > 0) {
|
|
190
|
+
const cursor = pathClassCursor.get(key) ?? 0;
|
|
191
|
+
const candidate = bucket[cursor];
|
|
192
|
+
if (candidate && candidate.length > 0) {
|
|
193
|
+
node.filePaths = candidate;
|
|
194
|
+
pathClassCursor.set(key, cursor + 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let parentNode = root as SourcemapNode;
|
|
200
|
+
if (item.parentGuid && item.parentGuid !== "root") {
|
|
201
|
+
parentNode = byGuid.get(item.parentGuid) ?? root;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!parentNode.children) {
|
|
205
|
+
parentNode.children = [];
|
|
206
|
+
}
|
|
207
|
+
parentNode.children.push(node);
|
|
208
|
+
byGuid.set(item.guid, node);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return root;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private packIntoSourcemap(
|
|
215
|
+
snapshot: InstanceData[],
|
|
216
|
+
sourcemap: SourcemapRoot,
|
|
217
|
+
): number {
|
|
218
|
+
const byGuid = new Map<string, InstanceData>();
|
|
219
|
+
const byPathClass = new Map<string, InstanceData[]>();
|
|
220
|
+
for (const item of snapshot) {
|
|
221
|
+
byGuid.set(item.guid, item);
|
|
222
|
+
const key = this.pathClassKey(item.path, item.className);
|
|
223
|
+
const bucket = byPathClass.get(key) ?? [];
|
|
224
|
+
bucket.push(item);
|
|
225
|
+
byPathClass.set(key, bucket);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const usedGuids = new Set<string>();
|
|
229
|
+
let packed = 0;
|
|
230
|
+
|
|
231
|
+
const visit = (node: SourcemapNode, currentPath: string[]): void => {
|
|
232
|
+
const nodePath = [...currentPath, node.name];
|
|
233
|
+
let match: InstanceData | undefined;
|
|
234
|
+
|
|
235
|
+
if (node.guid) {
|
|
236
|
+
const direct = byGuid.get(node.guid);
|
|
237
|
+
if (direct) {
|
|
238
|
+
match = direct;
|
|
239
|
+
usedGuids.add(direct.guid);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!match) {
|
|
244
|
+
const key = this.pathClassKey(nodePath, node.className);
|
|
245
|
+
const bucket = byPathClass.get(key);
|
|
246
|
+
if (bucket && bucket.length > 0) {
|
|
247
|
+
match = bucket.find((candidate) => !usedGuids.has(candidate.guid));
|
|
248
|
+
if (match) {
|
|
249
|
+
usedGuids.add(match.guid);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (match) {
|
|
255
|
+
if (match.properties && Object.keys(match.properties).length > 0) {
|
|
256
|
+
node.properties = match.properties;
|
|
257
|
+
} else if (!this.scriptsAndDescendantsOnly) {
|
|
258
|
+
delete node.properties;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (match.attributes && Object.keys(match.attributes).length > 0) {
|
|
262
|
+
node.attributes = match.attributes;
|
|
263
|
+
} else if (!this.scriptsAndDescendantsOnly) {
|
|
264
|
+
delete node.attributes;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (match.properties || match.attributes) {
|
|
268
|
+
packed += 1;
|
|
269
|
+
}
|
|
270
|
+
} else if (!this.scriptsAndDescendantsOnly) {
|
|
271
|
+
delete node.properties;
|
|
272
|
+
delete node.attributes;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const child of node.children ?? []) {
|
|
276
|
+
visit(child, nodePath);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
for (const child of sourcemap.children ?? []) {
|
|
281
|
+
visit(child, []);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
sourcemap._azul = {
|
|
285
|
+
packVersion: 1,
|
|
286
|
+
packedAt: new Date().toISOString(),
|
|
287
|
+
mode: this.scriptsAndDescendantsOnly ? "scripts-and-descendants" : "all",
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
return packed;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private writeSourcemap(sourcemap: SourcemapRoot, outputPath: string): void {
|
|
294
|
+
const dir = path.dirname(outputPath);
|
|
295
|
+
if (dir && dir !== "." && !fs.existsSync(dir)) {
|
|
296
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
fs.writeFileSync(
|
|
300
|
+
outputPath,
|
|
301
|
+
`${JSON.stringify(sourcemap, null, 2)}\n`,
|
|
302
|
+
"utf8",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private pathClassKey(pathSegments: string[], className: string): string {
|
|
307
|
+
return `${pathSegments.join("\u0001")}::${className}`;
|
|
308
|
+
}
|
|
309
|
+
}
|