Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb2365c9a | ||
|
|
259d18eb30 | ||
|
|
04852196ad | ||
|
|
fc1daa7e71 | ||
|
|
e234d3db36 | ||
|
|
7b8127fef8 | ||
|
|
db84b5a6ce | ||
|
|
c79ced28dd | ||
|
|
2408dc8985 | ||
|
|
2953eb17d6 | ||
|
|
c9619397b6 | ||
|
|
c8a6b8094e | ||
|
|
7ee9c60e38 | ||
|
|
4b0a33174d | ||
|
|
db27650b60 | ||
|
|
aadf24db99 | ||
|
|
2496e756f1 | ||
|
|
5ba9f94b7e | ||
|
|
9fa41e37f3 | ||
|
|
d184b9db81 | ||
|
|
08e7434d24 | ||
|
|
0245ccd16c | ||
|
|
deb6842a4d | ||
|
|
ac62f66aa1 | ||
|
|
097827b515 | ||
|
|
9a3edc8c11 | ||
|
|
096f338277 | ||
|
|
db562ce6a0 | ||
|
|
8017497bdd | ||
|
|
3cb9843f23 | ||
|
|
a063cf2db5 | ||
|
|
72357dfd23 | ||
|
|
0df6701326 | ||
|
|
f5e558cb2f | ||
|
|
995f0934ed | ||
|
|
ef74436cde | ||
|
|
51c025c79b | ||
|
|
12767fd58c | ||
|
|
826d89c7dc | ||
|
|
393652c1d9 | ||
|
|
246ce73d94 | ||
|
|
951bad4134 | ||
|
|
ae17b00b6f | ||
|
|
36808b34de | ||
|
|
105f99ced3 | ||
|
|
430a64722e | ||
|
|
7e9604a254 | ||
|
|
5369f283d7 | ||
|
|
654465e4eb | ||
|
|
c5a0c0a873 | ||
|
|
16a466aed0 | ||
|
|
3e836c1dd3 | ||
|
|
ddf15f9b92 | ||
|
|
54897c1447 | ||
|
|
2a221456d9 | ||
|
|
48a57bf16c | ||
|
|
8742b4127c | ||
|
|
e32637503d | ||
|
|
2401fe25fa | ||
|
|
f0b60119cb | ||
|
|
4d5597a1da | ||
|
|
2973485321 | ||
|
|
1e4292876c | ||
|
|
ca6464baeb | ||
|
|
c2cb58d088 | ||
|
|
38c249ec70 | ||
|
|
2e1c1fd3c8 | ||
|
|
4edc7a2949 | ||
|
|
9b5c6f8554 | ||
|
|
0de964ec02 | ||
|
|
7db091d427 | ||
|
|
9cf7dea579 | ||
|
|
9465a488cc | ||
|
|
5e94198216 | ||
|
|
854d2b3c79 | ||
|
|
946847c95d | ||
|
|
ce95f868fd | ||
|
|
e5a63a3508 | ||
|
|
1e4d709a06 | ||
|
|
a93f048814 | ||
|
|
5319ff3907 | ||
|
|
68b8ac6b8c | ||
|
|
a979c0cfac | ||
|
|
3da8c30a64 | ||
|
|
f8c72a1e0a | ||
|
|
4a059efe18 | ||
|
|
dec5467538 | ||
|
|
61cd5bd76c | ||
|
|
1b1524e787 | ||
|
|
488d81f424 | ||
|
|
1ff1e3e5f8 | ||
|
|
decee0ecc2 | ||
|
|
170a3d3cf7 | ||
|
|
601ebba919 | ||
|
|
fd1f3329bd | ||
|
|
fd72cd4fd5 | ||
|
|
200ccf8e06 | ||
|
|
285d71e13d | ||
|
|
8de76b5cfe | ||
|
|
8ecc6d1c83 | ||
|
|
6da7b84757 | ||
|
|
072e95046a | ||
|
|
5aa4d99ea3 | ||
|
|
f1ac22881c | ||
|
|
fb85869d09 | ||
|
|
c69672ec60 | ||
|
|
3e1a41393a | ||
|
|
e9225a4b2c | ||
|
|
df80effbdf | ||
|
|
35a60e798e | ||
|
|
dfd0cb2d36 | ||
|
|
da941a299c | ||
|
|
81e3c9e19b | ||
|
|
b9e8ffa3d1 | ||
|
|
62efc63830 | ||
|
|
e6e5805a49 | ||
|
|
a3d98595e2 | ||
|
|
6af34dc3a6 | ||
|
|
1cf3e39d72 | ||
|
|
b192c82dcc | ||
|
|
9138efca6f | ||
|
|
52238de9b3 | ||
|
|
cc064c2fd1 | ||
|
|
baf7dbfe0d | ||
|
|
0bc6cf1178 | ||
|
|
8638632600 | ||
|
|
6d5d9ced47 | ||
|
|
e825f643e2 | ||
|
|
c4f656996e | ||
|
|
103c0e3820 | ||
|
|
6da349c888 | ||
|
|
15c3bb81a6 | ||
|
|
4a628df108 | ||
|
|
25ae9b9a0d | ||
|
|
6b10f2ab7d | ||
|
|
770e6a41d4 | ||
|
|
375f591fe5 | ||
|
|
fcdbf59f7a | ||
|
|
6c80227103 | ||
|
|
0016c6271a | ||
|
|
c557b7d6b2 | ||
|
|
64c328c53d | ||
|
|
e5994287fb | ||
|
|
7126014619 | ||
|
|
3b8e89ea00 | ||
|
|
e72ee3ecaf | ||
|
|
31cfc45e58 | ||
|
|
834fc8cf98 | ||
|
|
eb3b7df390 | ||
|
|
76882ad1c4 | ||
|
|
15381ac260 | ||
|
|
b7e6acb24e | ||
|
|
b86305db8c | ||
|
|
fe803a99ca | ||
|
|
4612826664 | ||
|
|
a5a99e2ab1 | ||
|
|
56584a25c8 | ||
|
|
03ba7cb68c | ||
|
|
cc605250db | ||
|
|
ae34a86cbf | ||
|
|
86de5ae151 | ||
|
|
bcc25047f2 | ||
|
|
f5ce0a8a8c | ||
|
|
55bbc10986 | ||
|
|
4fef2d8143 | ||
|
|
4432a689bb | ||
|
|
6013fcd528 | ||
|
|
71fb8541d5 | ||
|
|
4398d33fea | ||
|
|
92814aa9b3 | ||
|
|
69d4a883ad | ||
|
|
2e5617c4b5 | ||
|
|
f930ed36a0 | ||
|
|
83b720eade | ||
|
|
c3a1b420ea | ||
|
|
c889d8f325 | ||
|
|
e23d07e965 | ||
|
|
08a8182173 | ||
|
|
9e7a7a602f | ||
|
|
6e5e5f0aeb | ||
|
|
7769c1f89e | ||
|
|
a2a5cf6d4c | ||
|
|
77e9b929dc | ||
|
|
b6306031dc | ||
|
|
0564eee400 | ||
|
|
f548ac2ecc | ||
|
|
c9fd99589b | ||
|
|
23d8a819af | ||
|
|
14b3331c8e | ||
|
|
f266c441db | ||
|
|
ed73e3ad1e | ||
|
|
4f9a8fb45c | ||
|
|
6910737374 | ||
|
|
bdfe8e630e | ||
|
|
66719375af | ||
|
|
ef3d5209eb | ||
|
|
e4eac3f9ad | ||
|
|
107284e796 | ||
|
|
bad86a5ff2 | ||
|
|
034ca4c250 | ||
|
|
2b101d594a | ||
|
|
c40362fa7f | ||
|
|
e8b515c733 | ||
|
|
e5582b5558 | ||
|
|
3b6ebeb653 | ||
|
|
bfec83996a | ||
|
|
4a72c14b74 | ||
|
|
f529457f96 | ||
|
|
642504874b | ||
|
|
d99e160a27 | ||
|
|
714e590496 | ||
|
|
bfcb57002d | ||
|
|
8b59c89322 | ||
|
|
31e9f59d8d | ||
|
|
0c74aef68e | ||
|
|
18e9fc8e7c | ||
|
|
1035389c9e | ||
|
|
8c62213a52 | ||
|
|
e7248bfd98 | ||
|
|
8c0e883b23 | ||
|
|
e3d8aa1a9b | ||
|
|
9aebeeac02 | ||
|
|
049ccee94e | ||
|
|
c59267d5ee | ||
|
|
c9ae964eee | ||
|
|
89cf89670f | ||
|
|
3b659116db | ||
|
|
ddd1bde1b4 | ||
|
|
715ab343bf | ||
|
|
7430212ac8 | ||
|
|
33efb32aa9 | ||
|
|
679eec05bc | ||
|
|
b019d44d0b | ||
|
|
efc71580ea | ||
|
|
90dacd98b2 | ||
|
|
f9a1e28074 | ||
|
|
de3f5da9d4 | ||
|
|
abe2acd0dc | ||
|
|
de48d807f1 | ||
|
|
22032ae4f8 | ||
|
|
3e617a81cf | ||
|
|
9005b6cef9 | ||
|
|
f4635a79f9 | ||
|
|
8a0c1e8a4b | ||
|
|
e3b662717b | ||
|
|
cbfc0adf3d | ||
|
|
4a29c13acb | ||
|
|
60e434e4ac | ||
|
|
d126e7f25a | ||
|
|
ecbce16c99 | ||
|
|
0576308187 | ||
|
|
f2c9cd0c62 | ||
|
|
f72995ec8b | ||
|
|
2ffb848fe6 | ||
|
|
e63f1246da | ||
|
|
dbe623c9a7 | ||
|
|
dbe01c6c23 | ||
|
|
29917fb85a | ||
|
|
1a1b4fb0d2 | ||
|
|
4027a4f310 | ||
|
|
37052e1f68 | ||
|
|
98f801e244 | ||
|
|
f9ef2ebbe1 | ||
|
|
a939e11641 | ||
|
|
a4a667ef06 | ||
|
|
87218e4803 | ||
|
|
40301f59b7 | ||
|
|
5db9cad7e5 | ||
|
|
e4bdf996f7 | ||
|
|
c1d0404f23 | ||
|
|
6bb2c50e5c | ||
|
|
8a85cfc8da | ||
|
|
86bd07666f | ||
|
|
a578719fc5 | ||
|
|
72e0b6a3b1 | ||
|
|
90a400b782 | ||
|
|
80eae94fe1 | ||
|
|
5daa492f68 | ||
|
|
b43a181bf4 | ||
|
|
a31ba31567 | ||
|
|
b03b201c20 | ||
|
|
af995428bc | ||
|
|
d79e30f9be | ||
|
|
f5228e6c2e | ||
|
|
ea77e09058 | ||
|
|
5ab4b95e3c | ||
|
|
8dc1415404 | ||
|
|
122bab303d | ||
|
|
160051fc3c | ||
|
|
500baffb2b | ||
|
|
4c77f5ac31 | ||
|
|
8ad518e7b6 | ||
|
|
1faf6df5a3 | ||
|
|
fc08438164 | ||
|
|
cc562a451e | ||
|
|
782e93f4a1 | ||
|
|
cf1e333191 | ||
|
|
31f1099dc0 | ||
|
|
ddaaf15b2e | ||
|
|
a6cafeb5c6 | ||
|
|
9b34b949a6 | ||
|
|
82c3d27bff | ||
|
|
0e9893fcb6 | ||
|
|
f37c387d52 | ||
|
|
7a49e8aa15 | ||
|
|
c80be802d1 | ||
|
|
ff9b05fc85 | ||
|
|
55f3dac9b6 | ||
|
|
1b082c6a25 | ||
|
|
5b78a86166 | ||
|
|
7d40b086e4 | ||
|
|
59b2338891 | ||
|
|
af525c01ad | ||
|
|
2be91cbd3a | ||
|
|
cc438574ff | ||
|
|
09e4256c18 | ||
|
|
a0f5864324 | ||
|
|
12db0d5a84 | ||
|
|
35f54a0ee8 | ||
|
|
2e314ff098 | ||
|
|
0066a06107 | ||
|
|
544d8deb81 | ||
|
|
e730718671 | ||
|
|
a96fae5ffd | ||
|
|
b53ed7f6ca | ||
|
|
dd216318b0 | ||
|
|
4a8a4c8227 | ||
|
|
177278fee3 | ||
|
|
6ffcb8c451 | ||
|
|
bda103d7a0 | ||
|
|
2eadc3ea10 | ||
|
|
b352e89b48 | ||
|
|
19ebee4697 | ||
|
|
4014eb4ec2 | ||
|
|
e94662350a | ||
|
|
0ae4fa0d2e | ||
|
|
b0067eccc6 | ||
|
|
95b2262cc3 | ||
|
|
7cef5528b3 | ||
|
|
a7d728c3ac | ||
|
|
4e61c62965 | ||
|
|
1c4010a744 | ||
|
|
cbfe6fbc11 | ||
|
|
e9c3c38a53 | ||
|
|
322570f298 | ||
|
|
8b857224dc | ||
|
|
d3ea85d5ac | ||
|
|
f6ab825692 | ||
|
|
67219ede6b | ||
|
|
8b32267858 | ||
|
|
0a469a0524 | ||
|
|
65c6fd592c | ||
|
|
a6ce755743 | ||
|
|
c2e7a02e5a | ||
|
|
ef0218ddfe | ||
|
|
ea645da3cb | ||
|
|
9a42f784ff | ||
|
|
67002e4b71 | ||
|
|
166416339a | ||
|
|
ead2c6d2c1 | ||
|
|
bfea6cb28e | ||
|
|
7ecac539f9 | ||
|
|
e73599a800 | ||
|
|
f46f40ad4a | ||
|
|
058197dc99 | ||
|
|
8fb6784964 | ||
|
|
c16dc74ad9 | ||
|
|
cebe83542f | ||
|
|
6c19145734 | ||
|
|
0a6e26bb2d | ||
|
|
de629d9416 | ||
|
|
48786f1e59 | ||
|
|
3d26371fa9 | ||
|
|
73c713462c | ||
|
|
16d2ab1646 | ||
|
|
f9048d7027 | ||
|
|
d627c5e094 | ||
|
|
6458810571 | ||
|
|
e1549c1b0c | ||
|
|
9542246414 | ||
|
|
a2d56d4a8d | ||
|
|
72d0994ca6 | ||
|
|
f07082b52d | ||
|
|
941639666d | ||
|
|
bd53339532 | ||
|
|
28da8dcc58 | ||
|
|
ceae2b25b5 | ||
|
|
579f6421e4 | ||
|
|
a904a8fb9d | ||
|
|
b70303c437 | ||
|
|
a8e1971483 | ||
|
|
78ce6fc382 | ||
|
|
71804df3e0 | ||
|
|
5ce3fb6cc6 | ||
|
|
6c4e094d44 | ||
|
|
f007dd640d | ||
|
|
e1c7d35ce4 | ||
|
|
a17e9295e7 | ||
|
|
b814d8a400 | ||
|
|
fe9cef5e1e | ||
|
|
415f574e2d | ||
|
|
63f76ce87a | ||
|
|
19259b2226 | ||
|
|
53edd7cce6 | ||
|
|
2069689780 | ||
|
|
d6f09f4c1a | ||
|
|
85a984734b | ||
|
|
b78eafaedb | ||
|
|
641d8e9fc5 | ||
|
|
bdd70e53dc | ||
|
|
7797ee708a | ||
|
|
6c9d3d8893 | ||
|
|
3a3a7597bf | ||
|
|
d0befe5944 | ||
|
|
a3729504e5 | ||
|
|
4efd09de31 | ||
|
|
6a29cd9715 | ||
|
|
9869d92ead | ||
|
|
a248a67d10 | ||
|
|
bec30c9927 | ||
|
|
38b7d0f600 | ||
|
|
0366334e7c | ||
|
|
b538960592 | ||
|
|
ac17b8497a | ||
|
|
2c74a67738 | ||
|
|
77cf4947b6 | ||
|
|
c6a0435751 | ||
|
|
7e9f1562e4 | ||
|
|
1de8b76303 | ||
|
|
b4cbe2355d | ||
|
|
09c3f82226 | ||
|
|
01e442c42e | ||
|
|
75dfc66e9a | ||
|
|
f8153bd784 | ||
|
|
b7c7e79a86 | ||
|
|
207a28cb44 | ||
|
|
9660fa0801 | ||
|
|
4ca092f351 | ||
|
|
99fa6b4b12 | ||
|
|
fb30499d80 | ||
|
|
2a2c188aec | ||
|
|
c101fa7a32 | ||
|
|
40bd3edb32 | ||
|
|
e944364fc2 | ||
|
|
a2c72b7fd1 | ||
|
|
2117271992 | ||
|
|
0e21060ea3 | ||
|
|
dea3a30cae | ||
|
|
b84c5b4293 | ||
|
|
201eb89dcd | ||
|
|
1b901f5d1c | ||
|
|
9243731646 | ||
|
|
06a0255547 | ||
|
|
b89688597a | ||
|
|
3592ff3269 | ||
|
|
a094a06cd1 | ||
|
|
aa3dfdbc0a | ||
|
|
16525ae07c | ||
|
|
e8405cbf77 | ||
|
|
6eef4bd300 | ||
|
|
699e3994df | ||
|
|
b681ea7243 | ||
|
|
29a3c26b44 | ||
|
|
35cb9f8698 | ||
|
|
6204c73ef2 | ||
|
|
95e1b3e349 | ||
|
|
30f345be55 | ||
|
|
231505c6fe | ||
|
|
d87252d52f | ||
|
|
b3e518d570 | ||
|
|
3b13dfdcb5 | ||
|
|
2e5333af29 | ||
|
|
450e27e001 | ||
|
|
71164faa14 | ||
|
|
a89106f4a3 | ||
|
|
d768019e1b | ||
|
|
30b7fd8767 | ||
|
|
faae6ccf97 | ||
|
|
6c11cefb99 | ||
|
|
dd8f9dbfd5 | ||
|
|
93752e671e | ||
|
|
7b75d4f0bb | ||
|
|
1c8a2fef32 | ||
|
|
69221a310c | ||
|
|
49b5b4412f | ||
|
|
fe86041fc4 | ||
|
|
957983d8a5 | ||
|
|
8f176c969b | ||
|
|
79d20260c5 | ||
|
|
0079d8634a | ||
|
|
a08087c5e8 | ||
|
|
cdb048741c | ||
|
|
4d0720ade8 | ||
|
|
e5e1851852 | ||
|
|
0b249b6018 | ||
|
|
c65494f093 | ||
|
|
e82f849118 | ||
|
|
87da492ef8 | ||
|
|
9d2ac2b81f | ||
|
|
c78bf02ce4 | ||
|
|
2f7419cf3b | ||
|
|
10303f979d | ||
|
|
b742291131 | ||
|
|
a06781ebc3 | ||
|
|
f5e28f6815 | ||
|
|
770a6664d4 | ||
|
|
f89e66e3cb | ||
|
|
65a25e2510 | ||
|
|
5b74432ee3 | ||
|
|
49eb966315 | ||
|
|
42e680f09d | ||
|
|
31c0925354 | ||
|
|
4c9b91ab57 | ||
|
|
a92ae95ab1 | ||
|
|
fc8edeba5f | ||
|
|
db1f239a0f | ||
|
|
c8c65d2e92 | ||
|
|
67654fdf85 | ||
|
|
63a656cd61 | ||
|
|
36865cfcf9 | ||
|
|
d493c2d671 | ||
|
|
2a0ae12829 | ||
|
|
6a4875c5ec | ||
|
|
325c08389e | ||
|
|
bcd1290301 | ||
|
|
bd1414dd1c | ||
|
|
d6623f89aa | ||
|
|
0494858886 | ||
|
|
df94ff1d8d | ||
|
|
85111e233e | ||
|
|
5337280eec | ||
|
|
3ce32ba870 | ||
|
|
6cb33a8b63 | ||
|
|
10cebd9e09 | ||
|
|
0c554cc754 | ||
|
|
f3b918e2c2 | ||
|
|
f67f56e6de | ||
|
|
0c0626564a | ||
|
|
019ffc25f0 | ||
|
|
690e566556 | ||
|
|
eee4c69575 | ||
|
|
adbbdef1a8 | ||
|
|
36e5606e25 | ||
|
|
5fb7a33199 | ||
|
|
db0a3fb85e | ||
|
|
1215bbf3ab | ||
|
|
64ac5a181a | ||
|
|
4834c289ff | ||
|
|
5fffc282fe | ||
|
|
6dbf29153e | ||
|
|
542d8a798f | ||
|
|
7d8b55adb6 | ||
|
|
1eca2ec6e6 | ||
|
|
9ee3fe6c37 | ||
|
|
d96fb75703 | ||
|
|
20c7b613f2 | ||
|
|
59436e7f10 | ||
|
|
a614ef8405 | ||
|
|
6d005505ce | ||
|
|
ceade431ef | ||
|
|
8586dfb95f | ||
|
|
00a1f6aea7 | ||
|
|
e5e82ba2fb | ||
|
|
24397b75cc | ||
|
|
fbbaf3dd3a | ||
|
|
a84f2d5eb6 | ||
|
|
ee81fbb7f6 | ||
|
|
65f0845398 | ||
|
|
7968d89345 | ||
|
|
0ae520b5cc | ||
|
|
1b0b245484 | ||
|
|
c5a14db8ea | ||
|
|
7bba6cbde6 | ||
|
|
b2408d54ec | ||
|
|
f06d3509e6 | ||
|
|
18e609f3fe | ||
|
|
2a7bc75929 | ||
|
|
34a0ce4dc3 | ||
|
|
003b035abd | ||
|
|
b82de23876 | ||
|
|
363bb54902 | ||
|
|
dfaf74aac5 | ||
|
|
86b8775df0 | ||
|
|
bccc89fa0b | ||
|
|
03b7d22ba6 | ||
|
|
8dce1dd0fd | ||
|
|
94200433cc | ||
|
|
392a7655c4 | ||
|
|
f1f9b4d020 | ||
|
|
701ff87bcd | ||
|
|
2382f4bbdc | ||
|
|
f318f8826b | ||
|
|
fbf2843f5b | ||
|
|
bca949f7be | ||
|
|
5e410a6ac3 | ||
|
|
0cca9ed22c | ||
|
|
6621e37e23 | ||
|
|
bd8f685a18 | ||
|
|
3485c9ac68 | ||
|
|
bf5a4a8a9c | ||
|
|
198d5c344b | ||
|
|
efa3eff0d5 | ||
|
|
6fde461da5 | ||
|
|
14ddb76a0b | ||
|
|
3753c303dd | ||
|
|
08ec542907 | ||
|
|
a8b8232d73 | ||
|
|
4f86be1b7f | ||
|
|
84450b83b4 | ||
|
|
f08774bd4a | ||
|
|
5e5a47ce02 | ||
|
|
9761fbb71c | ||
|
|
3c55188a87 | ||
|
|
a40c78d85d | ||
|
|
0b790357f9 | ||
|
|
2dcc5c9d47 | ||
|
|
6d8d926268 | ||
|
|
c59c30b840 | ||
|
|
fd307e58c2 | ||
|
|
da1cbc16f7 | ||
|
|
9b870fde64 | ||
|
|
0802c5b29d | ||
|
|
781e40428f | ||
|
|
999386c088 | ||
|
|
8c6130adba | ||
|
|
9458155a1b | ||
|
|
0969e07d79 | ||
|
|
9c9b3ac625 | ||
|
|
7814656241 | ||
|
|
e9f5cae4f0 | ||
|
|
8cb21c5fa8 | ||
|
|
3e4f0b6b5e | ||
|
|
703fda43ad | ||
|
|
3dba92a03d | ||
|
|
b254e4189d | ||
|
|
29f066a191 | ||
|
|
34aaeee354 | ||
|
|
efd759bec5 | ||
|
|
922c6af632 | ||
|
|
cf6f2cb70a | ||
|
|
2acf57d23d | ||
|
|
07cff0ab9a | ||
|
|
3dfed4d2ad | ||
|
|
c3179f0423 | ||
|
|
37bff10382 | ||
|
|
931ad4816f | ||
|
|
5b043524b4 | ||
|
|
8c9daff0bb | ||
|
|
67ca8dcc4d | ||
|
|
fc3686b865 | ||
|
|
01ea301a8c | ||
|
|
36cf14074e | ||
|
|
f3d4cfdcf5 | ||
|
|
f8597f500c | ||
|
|
2b20c943bb | ||
|
|
1bddea8cec | ||
|
|
e2255ddb7c | ||
|
|
453226f246 | ||
|
|
260f976fa3 | ||
|
|
d1b851d890 | ||
|
|
2e24d6c8ae | ||
|
|
203f2d2a14 | ||
|
|
87d14252fc | ||
|
|
3211fbe985 | ||
|
|
a8e29fdc56 | ||
|
|
a194508f52 | ||
|
|
ca2839594e | ||
|
|
1b427af5f0 | ||
|
|
04ee3d3f96 | ||
|
|
c87538ca58 | ||
|
|
c35b6de70e | ||
|
|
b8b8c44083 | ||
|
|
1beffb1bfd | ||
|
|
b151dd8469 | ||
|
|
7a9b6592e6 | ||
|
|
5c1214ad38 | ||
|
|
1629eaf225 | ||
|
|
4060c75142 | ||
|
|
f3c13eab21 | ||
|
|
e9fab3cc10 | ||
|
|
446333f4aa | ||
|
|
f8817ecf0c | ||
|
|
c601c53044 | ||
|
|
3603b16901 | ||
|
|
f4ca8366e2 | ||
|
|
b6585e6364 | ||
|
|
d2fb33f8b9 | ||
|
|
568cf81204 | ||
|
|
15ad64e356 | ||
|
|
ef6441ca46 | ||
|
|
534408856c | ||
|
|
430aa6d207 | ||
|
|
00d1d304d2 | ||
|
|
38c392ff6d | ||
|
|
4f463a8180 | ||
|
|
2c855a5846 | ||
|
|
61d92a6910 | ||
|
|
9507608e59 | ||
|
|
47ea8f5477 | ||
|
|
e7d1039942 | ||
|
|
fc04fd9ebc | ||
|
|
3dc4bdaab6 | ||
|
|
59111cc844 | ||
|
|
d537e5cf6b | ||
|
|
47c221f6f3 | ||
|
|
56488b2c2d | ||
|
|
e908c5069b | ||
|
|
139585f8c6 | ||
|
|
6bc07ad1b3 | ||
|
|
56ee1fe955 | ||
|
|
62e38199bd | ||
|
|
407e3d2aed | ||
|
|
fcfad08260 | ||
|
|
112cad8a49 | ||
|
|
ccb07d6eab | ||
|
|
621c834126 |
No files matched your search
@@ -0,0 +1,23 @@
|
||||
# No need to invalidate cache just because we try speeding up the build
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose*
|
||||
|
||||
# Install dependencies during the build
|
||||
node_modules
|
||||
packages/**/node_modules
|
||||
plugins/**/node_modules
|
||||
|
||||
# Download factorio during the build
|
||||
factorio
|
||||
factorio.tar.gz
|
||||
|
||||
# We build internal mods during build, mods from local test env should not be applied
|
||||
sharedMods
|
||||
sharedModsDisabled
|
||||
|
||||
# These files should never be required during the build
|
||||
.git
|
||||
.github
|
||||
dist
|
||||
temp
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,353 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
"plugins": [
|
||||
"node",
|
||||
],
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es2022": true,
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
"env": {
|
||||
"mocha": "true",
|
||||
},
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": ["packages/web_ui/src/**/*.jsx", "packages/lib/browser.js", "plugins/*/web/**/*.jsx"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "off",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
"rules": {
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"array-callback-return": "error",
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
"arrow-spacing": ["error", { "after": true, "before": true }],
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
},
|
||||
],
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": "error",
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "error",
|
||||
"dot-location": ["error", "property"],
|
||||
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
"func-style": ["error", "declaration"],
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": ["error", { "before": false, "after": true }],
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
"indent": ["error", "tab", { "SwitchCase": 1 }],
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "error",
|
||||
"keyword-spacing": "error",
|
||||
"line-comment-position": "off",
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "off",
|
||||
"lines-between-class-members": ["error", "always"],
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "error",
|
||||
"max-len": ["error", { "code": 120 }],
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"max-statements-per-line": "off",
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
"new-cap": "error",
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-alert": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-await-in-loop": "off",
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "error",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-empty-function": "off",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-extra-parens": "off",
|
||||
"no-floating-decimal": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inline-comments": "off",
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
"no-mixed-operators": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-native-reassign": "error",
|
||||
"no-negated-condition": "off",
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-return-await": "off",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": ["error", { "allow": ["yargs"] }],
|
||||
"no-spaced-func": "error",
|
||||
"no-tabs": ["error", { "allowIndentationTabs": true }],
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-undefined": "off",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-use-before-define": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-void": ["error", { "allowAsStatement": true }],
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
"object-curly-newline": "error",
|
||||
"object-curly-spacing": "off",
|
||||
"object-shorthand": "off",
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
},
|
||||
}],
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-const": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-named-capture-group": "off",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-reflect": "off",
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"rest-spread-spacing": ["error", "never"],
|
||||
"semi": "error",
|
||||
"semi-spacing": "error",
|
||||
"semi-style": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "error",
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
"spaced-comment": ["error", "always"],
|
||||
"strict": "error",
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
"valid-jsdoc": ["error", { "requireReturn": false }],
|
||||
"vars-on-top": "error",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "off",
|
||||
"yield-star-spacing": "error",
|
||||
"yoda": ["error", "never", { "exceptRange": true }],
|
||||
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "error",
|
||||
"node/handle-callback-err": "error",
|
||||
"node/no-callback-literal": "error",
|
||||
"node/no-deprecated-api": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"node/no-extraneous-import": "error",
|
||||
"node/no-extraneous-require": "error",
|
||||
"node/no-missing-import": "error",
|
||||
"node/no-missing-require": "error",
|
||||
"node/no-mixed-requires": "error",
|
||||
"node/no-new-require": "error",
|
||||
"node/no-path-concat": "error",
|
||||
"node/no-process-env": "error",
|
||||
"node/no-process-exit": "error",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "error",
|
||||
"node/no-unpublished-import": "error",
|
||||
"node/no-unpublished-require": [
|
||||
"error",
|
||||
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
|
||||
],
|
||||
"node/no-unsupported-features/es-builtins": "error",
|
||||
"node/no-unsupported-features/es-syntax": "error",
|
||||
"node/no-unsupported-features/node-builtins": "error",
|
||||
"node/prefer-global/buffer": "error",
|
||||
"node/prefer-global/console": "error",
|
||||
"node/prefer-global/process": "error",
|
||||
"node/prefer-global/text-decoder": "error",
|
||||
"node/prefer-global/text-encoder": "error",
|
||||
"node/prefer-global/url-search-params": "error",
|
||||
"node/prefer-global/url": "error",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "error",
|
||||
"node/shebang": "error",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
package-lock.json -diff
|
||||
@@ -0,0 +1,54 @@
|
||||
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Node.js CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x, 14.x, 16.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 6
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: pnpm i --no-optional --no-frozen-lockfile
|
||||
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- run: node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
|
||||
- run: wget -q -O temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
|
||||
- name: Run tests
|
||||
if: ${{ matrix.node-version != '12.x' }}
|
||||
run: pnpm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.node-version == '12.x' }}
|
||||
run: pnpm run ci-cover
|
||||
- name : Upload coverage to Codecov
|
||||
if: ${{ matrix.node-version == '12.x' }}
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 6
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16.x
|
||||
- run: pnpm i --no-optional --no-frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
@@ -1,7 +1,6 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
*debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
@@ -28,35 +27,23 @@ dist/
|
||||
# Autogenerated docs output
|
||||
/docs/api
|
||||
|
||||
node_modules/*
|
||||
node_modules
|
||||
|
||||
Factorio*
|
||||
/factorio
|
||||
/instances
|
||||
/sharedMods
|
||||
/database
|
||||
/plugin-list.json
|
||||
/config-master.json
|
||||
/config-control.json
|
||||
/config-slave.json
|
||||
package-lock.json
|
||||
secret-api-token.txt
|
||||
pnpm-lock.yaml
|
||||
mod_cache.json
|
||||
static/pictures/spritesheet.js
|
||||
static/pictures/spritesheet.png
|
||||
static/external
|
||||
/static/
|
||||
saves
|
||||
/temp/
|
||||
|
||||
# weird pattern to ignore sharedPlugins except not ignore some core modules
|
||||
!sharedPlugins/
|
||||
sharedPlugins/*
|
||||
!sharedPlugins/inventoryImports/
|
||||
!sharedPlugins/researchSync/
|
||||
!sharedPlugins/clusterioMod/
|
||||
|
||||
# File used for marking plugins as disabled
|
||||
sharedPlugins/*/DISABLED
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vs/
|
||||
@@ -1,11 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 10
|
||||
before_install:
|
||||
- curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64
|
||||
- tar -xf factorio.tar.gz
|
||||
- npm install -g codecov
|
||||
script:
|
||||
- npm run-script ci-cover
|
||||
- cat temp/test/integration/log.txt
|
||||
- codecov -f coverage/lcov.info
|
||||
@@ -1,234 +1,193 @@
|
||||
Changelog
|
||||
=========
|
||||
# Changelog
|
||||
|
||||
Version 2.0.0
|
||||
-------------
|
||||
## Version 2.0.0
|
||||
|
||||
### Major Features
|
||||
|
||||
- Replaced Hotpatch with save patching. Removed the Hotpatch scenario and
|
||||
the depency on it for getting code into the game. Added a save patcher than
|
||||
runs before starting up Factorio that patches in lua modules based on the
|
||||
event_loader lib into the savegame. Regular freeplay games can now be
|
||||
used with Clusterio and will be compatible without having to convert them
|
||||
to Hotpatch.
|
||||
- Daemonized slaves. Slaves now have the ability to run multiple Factorio
|
||||
instances, starting and stopping them individually. To manage this the
|
||||
local command line interface has been replaced with a remote interface on
|
||||
the master server that can be accessed through the clusterctl cli tool.
|
||||
- Rewritten the communication between slaves and the master from scratch. The
|
||||
new system is based on a WebSocket connection between the slaves and the
|
||||
master server and provides efficient validated bi-directonal communication.
|
||||
- Rewritten the plugin system from scratch. Plugins now inherit from
|
||||
a base class and use the same WebSocket connection Clusterio uses to
|
||||
communicate.
|
||||
- New configuration system with support for initializing configs when needed,
|
||||
modifying config entries using built-in commands, and updating instance
|
||||
configuration remotely.
|
||||
- Replaced Hotpatch with save patching.
|
||||
Removed the Hotpatch scenario and the depency on it for getting code into the game.
|
||||
Added a save patcher than runs before starting up Factorio that patches in lua modules based on the event_loader lib into the savegame.
|
||||
Regular freeplay games can now be used with Clusterio and will be compatible without having to convert them to Hotpatch.
|
||||
- Daemonized slaves.
|
||||
Slaves now have the ability to run multiple Factorio instances, starting and stopping them individually.
|
||||
To manage this the local command line interface has been replaced with a remote interface on the master server that can be accessed through the clusterioctl cli tool.
|
||||
- Rewritten the communication between slaves and the master from scratch.
|
||||
The new system is based on a WebSocket connection between the slaves and the master server and provides efficient validated bi-directonal communication.
|
||||
- Rewritten the plugin system from scratch.
|
||||
Plugins now inherit from a base class and use the same WebSocket connection Clusterio uses to communicate.
|
||||
- New configuration system with support for initializing configs when needed, modifying config entries using built-in commands, and updating instance configuration remotely.
|
||||
- Packaged and uploaded to npm again, greatly simplifying distribution and installation of both Clusterio and plugins.
|
||||
- Added centralized logging for the cluster.
|
||||
All logs from both Clusterio and Factorio is stored in a shared log which can be inspected and queried.
|
||||
- Added remote save management with the ability to list, create, rename, copy, upload, download, transfer, delete and load saves for instances.
|
||||
|
||||
### Features
|
||||
|
||||
- Added export of pollution statitics.
|
||||
- Reconnection logic that esures no data is dropped talking to the master
|
||||
server provided the session can be resumed.
|
||||
- Added support for having multiple Factorio installs and selecting which
|
||||
version to use on a per instance basis.
|
||||
- Added config to specify which path the master interface is accessed under,
|
||||
allowing it to be proxied behind web-server.
|
||||
- Reconnection logic that esures no data is dropped talking to the master server provided the session can be resumed.
|
||||
- Added support for having multiple Factorio installs and selecting which version to use on a per instance basis.
|
||||
- Added config to specify which path the master interface is accessed under, allowing it to be proxied behind web-server.
|
||||
- Added WebSocket usage statistics.
|
||||
- Added commands sent statistic.
|
||||
- Added Factorio server CPU and memory usage statistic.
|
||||
- Added Factorio autosave size statistic.
|
||||
- Added support for extracting locale and item icons from Factorio and mods.
|
||||
- Added integrated user management for controlling access and storing per player data.
|
||||
- Added synchronization of in-game adminlist, banlist and whitelist to Clusterio core.
|
||||
Previously this was handled by the playerManager plugin.
|
||||
- Added stripping of long paths in the Factorio server log.
|
||||
- Added option to configure maximum number of commands to send in parallel.
|
||||
- Added option to auto start instances on slave startup.
|
||||
- Added metric mapping of slave and instance ids to their names, allowing them to be displayed by name in queries that join with the mapping.
|
||||
- Added option to configure timeout for exported Prometheus metrics.
|
||||
- Added option to set address to bind HTTP(S) port to.
|
||||
- Added an overview of the installed plugins to the web interface.
|
||||
- Added clusterioctl command to list plugins on the master server.
|
||||
- Added Player Auth plugin for logging in to the web interface by proving the ability to log in to a server as a given user.
|
||||
- Added Node.js based installer to simplify setting up a cluster.
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed sslCert and sslPrivKey entries being ignored in the config.
|
||||
- Changed ssl key creation to be done at startup instead of on npm install.
|
||||
- Added error handling during master startup.
|
||||
- Factorio game and rcon port now defaults to a random port above 49151.
|
||||
- Removed unimplemented mods update command.
|
||||
- Fixed rcon password being generated with Math.random().
|
||||
- Added plugins directory to the views path. This makes it possible for
|
||||
plugins to render their own ejs views or pages in their own folders by
|
||||
using paths of the format "pluginName/path/to/page-or-view".
|
||||
- Replaced the per instance copy of the shared Factorio mods with
|
||||
symlinks. On Windows hard links are used instead due to the
|
||||
privileges requirements of symlinks.
|
||||
- Changed the per instance scenario folder to be linked instead of
|
||||
copied on instance creation.
|
||||
- Instance id is no longer derived from the rcon password, instead it's
|
||||
generated upon instance creation and stored in the instance config.
|
||||
- The game port, RCON port and RCON password instance config entries are are
|
||||
now null by default, indicating that a random one will be generated every
|
||||
time the instance is started.
|
||||
- Removed the broken client download command as this would download
|
||||
pre-releases.
|
||||
- Moved the item database and HTTP definitions for /api/place, /api/remove,
|
||||
/api/inventory, /api/inventoryAsObject and the web interface view for the
|
||||
storage page into the clusterioMod plugin. If you disable this plugin
|
||||
then these things will not be available.
|
||||
- Added plugins directory to the views path.
|
||||
This makes it possible for plugins to render their own ejs views or pages in their own folders by using paths of the format "pluginName/path/to/page-or-view".
|
||||
- Replaced the per instance copy of the shared Factorio mods with symlinks.
|
||||
On Windows hard links are used instead due to the privileges requirements of symlinks.
|
||||
- Changed the per instance scenario folder to be linked instead of copied on instance creation.
|
||||
- Instance id is no longer derived from the rcon password, instead it's generated upon instance creation and stored in the instance config.
|
||||
- The game port, RCON port and RCON password instance config entries are are now null by default, indicating that a random one will be generated every time the instance is started.
|
||||
- Removed the broken client download command as this would download pre-releases.
|
||||
- Moved the item database and HTTP definitions for /api/place, /api/remove, /api/inventory, /api/inventoryAsObject and the web interface view for the storage page into the clusterioMod plugin.
|
||||
If you disable this plugin then these things will not be available.
|
||||
- Removed undocumented --port and --rcon-port arguments to client.
|
||||
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable
|
||||
handling in client.
|
||||
- Removed undocumented FACTORIOPORT and RCONPORT enviroment variable handling in client.
|
||||
- Removed redundant call to /api/slaves in globalChat plugin.
|
||||
- Removed broken serverManager plugin.
|
||||
- Removed factorio_version from config. The version installed is auto
|
||||
detected and used instead.
|
||||
- Removed factorio_version from config.
|
||||
The version installed is auto detected and used instead.
|
||||
- Removed the playerManager specific command CLI tools/delete_player.js.
|
||||
- Creating an instance, assigning it to a slave, creating a save for an
|
||||
instance and starting an instance is now four separate commands.
|
||||
- Creating an instance, assigning it to a slave, creating a save for an instance and starting an instance is now four separate commands.
|
||||
- Removed oddball limits to slaves.json size.
|
||||
- Moved slave specific and instance specific configuration into their own
|
||||
configuration files.
|
||||
- Moved slave specific and instance specific configuration into their own configuration files.
|
||||
- Removed unused binary option from plugin config.
|
||||
- Removed info and shout command from globalChat plugin
|
||||
- Removed mirrorAllChat and enableCrossServerShout configuration options for
|
||||
globalChat plugin.
|
||||
- Removed UPSdisplay plugin. UPS statistics is exported by the statistics
|
||||
exporter plugin.
|
||||
- Removed info and shout command from globalChat plugin - Removed mirrorAllChat and enableCrossServerShout configuration options for globalChat plugin.
|
||||
- Removed UPSdisplay plugin.
|
||||
UPS statistics is exported by the statistics exporter plugin.
|
||||
- Master server now defaults to hosting on https on port 8443.
|
||||
- Renamed client to slave.
|
||||
- Removed rotation of factorio-current.log files.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- lib/authenticate no longer requires config to be passed to it. Breaks
|
||||
playerManager.
|
||||
- Removed lib/authenticate.
|
||||
Breaks playerManager.
|
||||
- The masterIP and masterPort config entries has been merged into masterURL.
|
||||
Breaks discordChat, playerManager, serverSelect, trainTeleports and
|
||||
clusterioModel.
|
||||
- Instance names can no longer be invalid Windows file names.
|
||||
Breaks discordChat, playerManager, serverSelect, trainTeleports and clusterioModel.
|
||||
- Removed config management from the command line and the server manager.
|
||||
- Moved ejs templates into views folder and changed their extension to
|
||||
.ejs. Breaks playerManager.
|
||||
- Moved ejs templates into views folder and changed their extension to .ejs.
|
||||
Breaks playerManager.
|
||||
- Mods are no longer copied from the per instance instanceMods directory.
|
||||
If you need per instance mods you can now place them directly in the mods
|
||||
directory inside the instance folder.
|
||||
If you need per instance mods you can now place them directly in the mods directory inside the instance folder.
|
||||
- Hotpatch scenarios and code loading is no longer compatible with Clusterio.
|
||||
Breaks playerManager, serverSelect, and tranTeleports.
|
||||
- Removed getLua and getCommand from lib/clusterTools. If you need to run
|
||||
more than the most trivial of code in commands use the save patcher and
|
||||
add in a remote interface.
|
||||
- Removed mod uploading and distributing from the HTTP interface. Breaks
|
||||
the old unmaintained and no longer needed factorioClusterioClient.
|
||||
- Removed the remoteCommands plugin and the old runCommand interface. Breaks
|
||||
playerManager and external tools sending commands.
|
||||
- Removed getLua and getCommand from lib/clusterTools.
|
||||
If you need to run more than the most trivial of code in commands use the save patcher and add in a remote interface.
|
||||
- Removed mod uploading and distributing from the HTTP interface.
|
||||
Breaks the old unmaintained and no longer needed factorioClusterioClient.
|
||||
- Removed the remoteCommands plugin and the old runCommand interface.
|
||||
Breaks playerManager and external tools sending commands.
|
||||
- Removed broken serverManager plugin.
|
||||
- Removed fields info, time, rconPort, rconPassword, serverPort, unique,
|
||||
mods, instanceName, playerCount, mac, and meta from the slaves in the
|
||||
slave database.
|
||||
- Removed getInstanceName and lib/clusterTools. Breaks playerManager,
|
||||
and discordChat.
|
||||
- Removed the /api/rconPasswords, and /api/slaves endpoints. Breaks web
|
||||
interface, trainTeleports, and discordChat.
|
||||
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints. Breaks
|
||||
researchSync, and UPSdisplay.
|
||||
- Removed the hello event from the socket connection handshake. Breaks
|
||||
playerManager, trainTeleports, serverSelect, and discordChat
|
||||
- Removed fields info, time, rconPort, rconPassword, serverPort, unique, mods, instanceName, playerCount, mac, and meta from the slaves in the slave database.
|
||||
- Removed getInstanceName and lib/clusterTools.
|
||||
Breaks playerManager, and discordChat.
|
||||
- Removed the /api/rconPasswords, and /api/slaves endpoints.
|
||||
Breaks web interface, trainTeleports, and discordChat.
|
||||
- Removed the /api/getSlaveMeta and /api/editSlaveMeta endpoints.
|
||||
Breaks researchSync, and UPSdisplay.
|
||||
- Removed the hello event from the socket connection handshake.
|
||||
Breaks playerManager, trainTeleports, serverSelect, and discordChat
|
||||
- Changed the format of database/slaves.json.
|
||||
- Removed the output file subscription system. Breaks inventoryImports,
|
||||
playerManager, trainTeleports, serverSelect and researchSync.
|
||||
- Removed the factorioOutput hook from instance plugins. The onOutput
|
||||
hook provides parsed output instead.
|
||||
- Removed the output file subscription system.
|
||||
Breaks inventoryImports, playerManager, trainTeleports, serverSelect and researchSync.
|
||||
- Removed the factorioOutput hook from instance plugins.
|
||||
The onOutput hook provides parsed output instead.
|
||||
- Removed the onLoadFinish hook from master plugins.
|
||||
- Moved plugins from the sharedPlugins directory to plugins directory.
|
||||
- Implemented a new plugin system that replaces the old. Breaks all plugins.
|
||||
- Implemented a new plugin system that replaces the old.
|
||||
Breaks all plugins.
|
||||
- Removed express metric http_request_duration_milliseconds metric.
|
||||
- Removed socket.io metrics socket_io_connected, socket_io_connect_total,
|
||||
socket_io_disconnect_total, socket_io_events_received_total,
|
||||
socket_io_events_sent_total, socket_io_recieve_bytes, and
|
||||
socket_io_transmit_bytes,
|
||||
- Removed clusterio_connected_instaces_gauge and added
|
||||
clusterio_master_connected_clients_count in its place.
|
||||
- Renamed clusterio_player_count_gauge to
|
||||
clusterio_statistics_exporter_instance_player_count
|
||||
- Removed clusterio_UPS_gauge and added
|
||||
clusterio_statistics_exporter_instance_game_ticks_total in its place.
|
||||
- Removed socket.io metrics socket_io_connected, socket_io_connect_total, socket_io_disconnect_total, socket_io_events_received_total, socket_io_events_sent_total, socket_io_recieve_bytes, and socket_io_transmit_bytes,
|
||||
- Removed clusterio_connected_instaces_gauge and added clusterio_master_connected_clients_count in its place.
|
||||
- Renamed clusterio_player_count_gauge to clusterio_statistics_exporter_instance_player_count
|
||||
- Removed clusterio_UPS_gauge and added clusterio_statistics_exporter_instance_game_ticks_total in its place.
|
||||
- Renamed clusterio_endpoint_hit_gauge to clusterio_http_enpoint_hits_total
|
||||
- Renamed clusterio_statistics_gauge to
|
||||
clusterio_statistics_exporter_instance_force_flows
|
||||
- Renamed clusterio_statistics_gauge to clusterio_statistics_exporter_instance_force_flows
|
||||
- Renamed clusterio_nn_dole_gauge to clusterio_subspace_storage_nn_dole_gauge.
|
||||
- Renamed clusterio_dole_factor_gauge to
|
||||
clusterio_subspace_storage_dole_factor_gauge.
|
||||
- Renamed clusterio_dole_factor_gauge to clusterio_subspace_storage_dole_factor_gauge.
|
||||
- Renamed clusterio_import_gauge to clusterio_subspace_storage_import_total.
|
||||
- Renamed clusterio_export_gauge to clusterio_subspace_storage_export_total.
|
||||
- Renamed clusterio_master_inventory_gauge to
|
||||
clusterio_subspace_storage_master_inventory.
|
||||
- Removed clusterioMod plugin specific config options logItemTransfers,
|
||||
disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and
|
||||
disableImportsOfEverythingExceptElectricity from the master config.
|
||||
- Removed msBetweenCommands config option. The RCON is instead limited to 5
|
||||
concurrent commands.
|
||||
- Removed allowRemoteCommandExecution config option. Remote commands are
|
||||
always allowed with the move to master managing slaves/instances.
|
||||
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line
|
||||
arguments from the master server.
|
||||
- Implemented a new config system that replaces the old. Breaks all plugins.
|
||||
- Renamed clusterio_master_inventory_gauge to clusterio_subspace_storage_master_inventory.
|
||||
- Removed clusterioMod plugin specific config options logItemTransfers, disableFairItemDistribution useNeuralNetDoleDivider, autosaveInterval, and disableImportsOfEverythingExceptElectricity from the master config.
|
||||
- Removed msBetweenCommands config option.
|
||||
The RCON is instead limited to 5 concurrent commands.
|
||||
- Removed allowRemoteCommandExecution config option.
|
||||
Remote commands are always allowed with the move to master managing slaves/instances.
|
||||
- Removed `--databaseDirectory`, `--masterPort`, and `--sslPort` command line arguments from the master server.
|
||||
- Implemented a new config system that replaces the old.
|
||||
Breaks all plugins.
|
||||
- Removed usage of socket.io entirely in favor of a plain WebSocket connection.
|
||||
- Renamed clusterioMod plugin to subspace_storage.
|
||||
- Master no longer creates secret-api-token.txt.
|
||||
- Removed automatic creation of self-signed TLS certificate.
|
||||
- Node.js versions below 12 are no longer supported.
|
||||
|
||||
|
||||
Version 1.2.4
|
||||
-------------
|
||||
## Version 1.2.4
|
||||
|
||||
- Removed broken remote combinator signaling.
|
||||
- Fixed research sync endlessly updating already researched technologies
|
||||
- Removed obsolete item/fluid statistics from clusterioMod
|
||||
- Removed per mod upload logging when config.uploadModsToMaster disabled
|
||||
- Fixed bcrypt failing to install on windows due to the new version not
|
||||
having Windows binaries.
|
||||
- Fixed bcrypt failing to install on windows due to the new version not having Windows binaries.
|
||||
|
||||
|
||||
Version 1.2.3
|
||||
-------------
|
||||
## Version 1.2.3
|
||||
|
||||
- Disabled uploading mods to the master server by default as this is mostly
|
||||
just a waste of bandwidth with the mod portal being integrated into the
|
||||
game now.
|
||||
- Fixed researchSync breaking with heavily modded games due to the tech tree
|
||||
exceeding the default 100kb limit on JSON payloads.
|
||||
- Fixed the command inventoryImport sends referencing player instead of
|
||||
character, and trying to count the non-existant quick bar slots.
|
||||
- Fixed inventoryImport never receiving the script output after the default
|
||||
mode for reading script output changed to tail mode.
|
||||
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting
|
||||
out of sync when plugins update their scenario mod code. To fix existing
|
||||
games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua
|
||||
over the existing mod-tools.lua in the save's hotpatch folder.
|
||||
- Disabled uploading mods to the master server by default as this is mostly just a waste of bandwidth with the mod portal being integrated into the game now.
|
||||
- Fixed researchSync breaking with heavily modded games due to the tech tree exceeding the default 100kb limit on JSON payloads.
|
||||
- Fixed the command inventoryImport sends referencing player instead of character, and trying to count the non-existant quick bar slots.
|
||||
- Fixed inventoryImport never receiving the script output after the default mode for reading script output changed to tail mode.
|
||||
- Fixed desync caused by the mods and loaded mods arrays in Hotpatch getting out of sync when plugins update their scenario mod code.
|
||||
To fix existing games you will need copy lib/scenarios/Hotpatch/hotpatch/mod-tools.lua over the existing mod-tools.lua in the save's hotpatch folder.
|
||||
- Updated the Windows install instructions.
|
||||
|
||||
|
||||
Version 1.2.2
|
||||
-------------
|
||||
## Version 1.2.2
|
||||
|
||||
- Fixed possible crash with modded technologies named the same as a built-in
|
||||
Object prototype property in researchSync.
|
||||
- Fixed progress of a current infinite tech carrying over to the next one
|
||||
when researching it and another node completes it in researchSync.
|
||||
- Fixed progress of a previous infinite tech from another node being applied
|
||||
to the current one in researchSync.
|
||||
- Fixed crash in researchSync when modded technologies are present only on some
|
||||
nodes.
|
||||
- Fixed install failing due to bcrypt version less than 3 not being supported
|
||||
on node v10.
|
||||
- Reordered install instructions to avoid problem with npm creating files owned
|
||||
by root in the home directory.
|
||||
- Swapped curl out with wget in the install instructions as the latter comes
|
||||
pre-installed on Ubuntu.
|
||||
- Fixed possible crash with modded technologies named the same as a built-in Object prototype property in researchSync.
|
||||
- Fixed progress of a current infinite tech carrying over to the next one when researching it and another node completes it in researchSync.
|
||||
- Fixed progress of a previous infinite tech from another node being applied to the current one in researchSync.
|
||||
- Fixed crash in researchSync when modded technologies are present only on some nodes.
|
||||
- Fixed install failing due to bcrypt version less than 3 not being supported on node v10.
|
||||
- Reordered install instructions to avoid problem with npm creating files owned by root in the home directory.
|
||||
- Swapped curl out with wget in the install instructions as the latter comes pre-installed on Ubuntu.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Node.js versions below 10 are no longer supported.
|
||||
|
||||
|
||||
Version 1.2.1
|
||||
-------------
|
||||
## Version 1.2.1
|
||||
|
||||
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking
|
||||
([#229][#229].)
|
||||
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C
|
||||
on Linux ([#217][#217].)
|
||||
- Updated node-factorio-api to v0.3.8 to fix mod downloads randomly breaking ([#229][#229]).
|
||||
- Fixed SIGINT being sent twice to Factorio server when interrupted by CTRL+C on Linux ([#217][#217]).
|
||||
- Fixed package.json incorrectly reporting the license as ISC.
|
||||
|
||||
[#217]: https://github.com/clusterio/factorioClusterio/issues/217
|
||||
[#229]: https://github.com/clusterio/factorioClusterio/issues/229
|
||||
[#217]: https://github.com/clusterio/clusterio/issues/217
|
||||
[#229]: https://github.com/clusterio/clusterio/issues/229
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
# Warning: This is currently broken as a result of
|
||||
# changes done in the 2.0 refactoring.
|
||||
FROM node:12
|
||||
RUN apt-get update && apt install git curl tar -y
|
||||
RUN mkdir factorioClusterio
|
||||
FROM node:12 as subspace_storage_builder
|
||||
RUN apt update && apt install -y git
|
||||
WORKDIR /
|
||||
RUN git clone https://github.com/clusterio/subspace_storage.git
|
||||
WORKDIR /subspace_storage
|
||||
RUN npm install \
|
||||
&& node build
|
||||
|
||||
RUN git clone -b master https://github.com/clusterio/factorioClusterio.git && cd factorioClusterio && npm install --only=production
|
||||
RUN cd factorioClusterio && curl -o factorio.tar.gz -L https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz
|
||||
FROM node:12 as clusterio_builder
|
||||
RUN apt update \
|
||||
&& apt install -y wget \
|
||||
&& mkdir /clusterio
|
||||
WORKDIR /clusterio
|
||||
RUN wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
# Copy project files into the container
|
||||
COPY . .
|
||||
|
||||
WORKDIR factorioClusterio
|
||||
RUN mkdir instances sharedMods
|
||||
RUN cp config.json.dist config.json
|
||||
RUN pnpm install
|
||||
|
||||
RUN node client manage shared mods add clusterio
|
||||
# Install plugins. This is intended as a reasonable default, enabling plugins to make for fun gameplay.
|
||||
# If you want a different set of plugins, consider using this as the base image for your own.
|
||||
#RUN pnpm install @clusterio/plugin-subspace_storage
|
||||
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
LABEL maintainer "Sir3lit@gmail.com"
|
||||
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
|
||||
|
||||
EXPOSE 8080 34167
|
||||
VOLUME /factorioClusterio/instances
|
||||
VOLUME /factorioClusterio/sharedMods
|
||||
VOLUME /factorioClusterio/sharedPlugins
|
||||
# Build Lua library mod
|
||||
RUN node packages/lib/build_mod --build --source-dir packages/slave/lua/clusterio_lib \
|
||||
&& mv dist/* sharedMods/ \
|
||||
&& mkdir temp \
|
||||
&& mkdir temp/test \
|
||||
&& cp sharedMods/ temp/test/ -r \
|
||||
&& ls sharedMods
|
||||
|
||||
CMD MODE="$MODE" node $MODE start $INSTANCE
|
||||
# Remove node_modules
|
||||
RUN find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \;
|
||||
|
||||
FROM frolvlad/alpine-glibc AS clusterio_final
|
||||
|
||||
RUN apk add --update bash nodejs npm
|
||||
|
||||
COPY --from=clusterio_builder /clusterio /clusterio
|
||||
WORKDIR /clusterio
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN pnpm install --production
|
||||
LABEL maintainer "danielv@danielv.no"
|
||||
|
||||
FROM frolvlad/alpine-glibc AS clusterio_testing
|
||||
|
||||
RUN apk add --update bash nodejs npm
|
||||
|
||||
COPY --from=clusterio_builder /clusterio /clusterio
|
||||
WORKDIR /clusterio
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN pnpm install
|
||||
RUN pnpm install chalk semver
|
||||
@@ -19,8 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
The icons for angels modded items located in /static/pictures/ are licensed
|
||||
by https://mods.factorio.com/user/Arch666Angel under CC BY-NC-ND 4.0
|
||||
https://creativecommons.org/licenses/by-nc-nd/4.0/
|
||||
|
||||
@@ -4,270 +4,273 @@
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
# factorioClusterio
|
||||
# Clusterio
|
||||
|
||||
Discord for development/support/play: https://discord.gg/5XuDkje
|
||||
|
||||
## Important notice
|
||||
|
||||
**WARNING: The master branch is currently broken, do not use it!**
|
||||
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
|
||||
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
|
||||
If you are starting a new cluster it's highly recommended to use the 2.0 alpha.
|
||||
|
||||
This is the development branch for factorioClusterio 2.0 which is currently undergoing heavy
|
||||
restructuring and refactoring. Expect plugins and existing installations to frequently break when
|
||||
using this branch. If you don't want to be an alpha tester for 2.0 please use the stable
|
||||
[1.2.x branch][1.2.x] or
|
||||
[latest stable release](https://github.com/clusterio/factorioClusterio/releases/latest).
|
||||
|
||||
Installation instructions below are for the unstable master branch. Go to the page for
|
||||
the [1.2.x branch][1.2.x] for instructions on how to install the stable version.
|
||||
|
||||
[1.2.x]: https://github.com/clusterio/factorioClusterio/tree/1.2.x
|
||||
|
||||
### Ways to support me/the project:
|
||||
|
||||
* Contribute with code/documentation. See [Contributing](docs/contributing.md) for how to make pull
|
||||
requests. Always nice to move the project forward.
|
||||
* Contribute with code/documentation.
|
||||
See [Contributing](docs/contributing.md) for how to make pull requests.
|
||||
Always nice to move the project forward.
|
||||
|
||||
* Support me monetarily on [patreon](https://www.patreon.com/danielv123) or paypal: danielv@live.no
|
||||
|
||||
### Table of contents
|
||||
|
||||
* [Introduction & methodology](#introduction)
|
||||
* [Ubuntu setup](#ubuntu-setup)
|
||||
* [Windows setup](#windows-setup)
|
||||
* [Introduction](#introduction)
|
||||
* [Features](#features)
|
||||
* [Plugins](#plugins)
|
||||
* [Installation](#installation)
|
||||
* [Ubuntu setup](#ubuntu-setup)
|
||||
* [Windows setup](#windows-setup)
|
||||
* [MacOS setup](#macos-setup)
|
||||
* [Installing Plugins](#installing-plugins)
|
||||
* [Configure Master Server](#configure-master-server)
|
||||
* [Managing Factorio mods](#managing-factorio-mods)
|
||||
* [Setting up shared storage](#setting-up-shared-storage)
|
||||
* [Running Clusterio](#running-clusterio)
|
||||
* [Master Server](#master-server)
|
||||
* [Slaves](#slaves)
|
||||
* [Instances](#instances)
|
||||
* [Optional plugins](#Plugins)
|
||||
* [Setting up remote slaves](#setting-up-remote-slaves)
|
||||
* [Setting up clusterioctl](#setting-up-clusterioctl)
|
||||
* [Common problems](#Common-problems)
|
||||
|
||||
|
||||
## Introduction
|
||||
|
||||
Features:
|
||||
Clusterio is a clustered Factorio server manager that provides the tooling for implementing cross server interactions in Factorio.
|
||||
It was previously best known for implementing cross server transfer and cloud storage of items via teleporter chests.
|
||||
But this functionality has been pulled out of Clusterio into its own plugin for Clusterio named [Subspace Storage](https://github.com/clusterio/subspace_storage).
|
||||
|
||||
- Entities to send/recieve items
|
||||
|
||||
- Cross dimensional storage
|
||||
|
||||
- Sending of liquids
|
||||
|
||||
- Sending of circuit network signals
|
||||
|
||||
- Inventory combinator to display item levels in the cluster (and epoch time)
|
||||
|
||||
- Reporting of graphs and UPS on master interface (Also has extensive Prometheus reporting)
|
||||
|
||||
Optional extras (see [Plugins](#Plugins))
|
||||
|
||||
- Have your inventory synchronize across servers
|
||||
|
||||
- Teleport trans from the border of one world to te next
|
||||
|
||||
- Show in-game chat in discord
|
||||
By itself Clusterio doesn't change the gameplay in any way, you could even use Clusterio to manage completely vanilla Factorio servers.
|
||||
Plugins do the work of modding in the visible changes into the game, see the [Plugins section](#plugins) for ready made plugins you can install into a Clusterio cluster.
|
||||
|
||||
|
||||
Connection diagram:
|
||||
## Features
|
||||
|
||||

|
||||
- Clustered Factorio server management allowing you manage the running of Factorio servers across a fleet of physical servers from both a web interface and a command line interface.
|
||||
|
||||
There can be any number of clients connected to each slave, and any number of slaves connected to a master but there can only be one master server in each cluster.
|
||||
- User list management for synchronizing in-game admins, whitelisted users, and bans to all the servers in the cluster.
|
||||
|
||||
**How does it work?**
|
||||
- Integrated support for exporting statistics for the whole cluster to Prometheus via a single metrics endpoint.
|
||||
|
||||
Traditional factorio mods have always been limited by the games deterministic design. This gives us a very bug free and predictable game, but doesn't allow us cool stuff such as internet communication.
|
||||
Clusterio exploits one of the games logging features, game.write_file and RCON to communicate between servers. Sending an item from one server to another takes this path:
|
||||
- Extensive plugin support for adding your own cross server features to Factorio using Clusterio's communication backbone.
|
||||
|
||||
1. server1: Chest has stuff in it, write the contents to a file and delete them from the game world
|
||||
|
||||
2. client.js on server1: File has stuff in it, parse and send to the master for storage
|
||||
## Plugins
|
||||
|
||||
3. master: server1 gave us stuff, store it in the storage and write some statistics
|
||||
The heart of Clusterio is its plugin system.
|
||||
Plugins add functionality to Factorio servers, Clusterio itself or both.
|
||||
These are the plugins supported and maintained by the Clusterio developers:
|
||||
|
||||
4. server2: get-chest is empty, write a request to file
|
||||
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
|
||||
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers.
|
||||
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the master server.
|
||||
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
|
||||
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
|
||||
- [Inventory Sync](/plugins/inventory_sync/README.md): Synchronizes your inventory between servers.
|
||||
|
||||
5. client.js on server2: Request file has stuff in it, parse and send a request to master for more items of that type
|
||||
There's also plugins developed and maintained by the community:
|
||||
|
||||
6. master: server2 asked for stuff, check if we have enough and how much demand there is, then send however much is appropriate back
|
||||
- [Discord Bridge](https://github.com/Hornwitser/discord_bridge) (@hornwitser/discord_bridge): Bridges chat between instances and Discord.
|
||||
By Hornwitser.
|
||||
- [Server Select](https://github.com/Hornwitser/server_select/tree/clusterio-2.0) (@hornwitser/server_select): In-game GUI for connecting to other server in the cluster.
|
||||
Originally by Godmave, ported to 2.0 by Hornwitser.
|
||||
|
||||
7. client.js on server2: We were allowed to import x of item y, run command /c remote.call("clusterio", "importMany", "{'copper-plate':120}")
|
||||
Want to make your own plugin?
|
||||
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
|
||||
|
||||
This process works the same for both items and liquids, independent on what mods are used. Yes, modded items are fully supported.
|
||||
|
||||
Clusterio can also do a few other neat things, such as giving you access to epoch time, syncing player inventories between servers, keeping track of playtime (playerManager plugin), teleporting trains between servers (trainTeleports) and exporting tons of factorio related statistics to Prometheus for graphing in grafana.
|
||||
## Installation
|
||||
|
||||
## Ubuntu setup
|
||||
Clusterio runs on Node.js v12 and up, it's distributed via npm and comes with a guided install script.
|
||||
If you alread have a recent Node.js installed you can set it up in a new directory with.
|
||||
|
||||
**Warning**: These instructions are for the unstable master version and is not
|
||||
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
|
||||
npm init "@clusterio"
|
||||
|
||||
Clusterio runs on Node.js v12 and up, v11.13.0+ and v10.16.0+. Node.js itself is not
|
||||
supported on EOL Ubuntu releases so make sure you're on a recent release of Ubuntu.
|
||||
Otherwise see below for OS specific instructions.
|
||||
|
||||
Master and all slaves:
|
||||
|
||||
wget -qO - https://deb.nodesource.com/setup_12.x | sudo -E bash -
|
||||
sudo apt install -y nodejs python-dev git build-essential
|
||||
git clone -b master https://github.com/clusterio/factorioClusterio.git
|
||||
cd factorioClusterio
|
||||
wget -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64
|
||||
tar -xf factorio.tar.gz
|
||||
npm install --only=production
|
||||
### Ubuntu setup
|
||||
|
||||
1. Install Node.js v12 or higher.
|
||||
For 20.04 LTS or below the version of Node.js provided by the Ubuntu repos are too old and you will have to use the nodesource PPA, otherwise you may skip the first line.
|
||||
|
||||
wget -qO - https://deb.nodesource.com/setup_14.x | sudo -E bash -
|
||||
sudo apt install nodejs
|
||||
|
||||
2. Log in as the user you wish to install Clusterio as (do not use root), and then create a new directory and run the Clusterio installer:
|
||||
|
||||
mkdir clusterio
|
||||
cd clusterio
|
||||
npm init "@clusterio"
|
||||
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
3. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
|
||||
|
||||
downloads and installs nodejs, git and clusterio. To specify a version, change "latest" in the link to a version number like 0.14.21.
|
||||
|
||||
**Ubuntu with Docker**
|
||||
|
||||
The Docker support for Clusterio is curently broken. If you need it
|
||||
open an issue about it.
|
||||
The Docker support for Clusterio is currently broken.
|
||||
If you need it open an issue about it.
|
||||
|
||||
<!--
|
||||
Clusterio has *very* limited support for using docker.
|
||||
|
||||
sudo docker build -t clusterio --no-cache --force-rm factorioClusterio
|
||||
sudo docker build -t clusterio --no-cache --force-rm clusterio
|
||||
|
||||
sudo docker run --name master -e MODE=master -p 1234:8080 -d -it --restart=unless-stopped danielvestol/clusterio
|
||||
|
||||
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/factorioClusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
|
||||
sudo docker run --name slave -e MODE=slave -e INSTANCE=world1 -v /srv/clusterio/instances:/clusterio/instances -p 1235:34167 -it --restart=unless-stopped danielvestol/clusterio
|
||||
|
||||
The -v flag is used to specify the instance directory. Your instances (save files etc) will be stored there.
|
||||
The -v flag is used to specify the instance directory.
|
||||
Your instances (save files etc) will be stored there.
|
||||
-->
|
||||
|
||||
## Windows setup
|
||||
### Windows setup
|
||||
|
||||
**Warning**: These instructions are for the unstable master version and is not
|
||||
recommended for use, see [the 1.2.x branch][1.2.x] for how to install the stable version.
|
||||
1. Download and install the latest LTS release from http://nodejs.org.
|
||||
|
||||
Clusterio is built up of multiple parts. Here is a quick guide:
|
||||
2. Create a new empty directory for the installation and navigate into it.
|
||||
Open a PowerShell window in this new directory by Shift+Right clicking inside it and choosing "Open PowerShell window here" and then run the following command.
|
||||
|
||||
Master = master.js
|
||||
npm init "@clusterio"
|
||||
|
||||
Server host (Slave) = slave.js + factorio server
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
Game Client = The people connecting to the server
|
||||
3. If you chose to use local factorio directory for the Factorio installation then download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
|
||||
|
||||
**Requirements**
|
||||
|
||||
download and install nodeJS 12 from http://nodejs.org. Clusterio runs on Node.js v12 and
|
||||
up, v11.13.0+ and v10.16.0+.
|
||||
### MacOS Setup
|
||||
|
||||
download and install git from https://git-scm.com/
|
||||
1. Install the latest Node.js LTS release from http://nodejs.org or use brew (`brew install node`).
|
||||
|
||||
reboot when you are done, then proceed to the next steps. *reboots matter*
|
||||
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
|
||||
|
||||
**Master**
|
||||
mkdir clusterio
|
||||
cd clusterio
|
||||
npm init "@clusterio"
|
||||
|
||||
1. Open PowerShell or Command prompt in the directory you want to install to and run the following commands.
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
git clone -b master https://github.com/clusterio/factorioClusterio
|
||||
cd factorioClusterio
|
||||
npm install --only=production
|
||||
3. If you chose to use local factorio directory for the Factorio installation you will need to obtain and copy a mac version of Factorio and unpack it to to the `factorio` folder in your clusterio installation folder.
|
||||
|
||||
2. Obtain Factorio by either of these two methods:
|
||||
|
||||
- Via the stand alone version on from their website
|
||||
### Installing Plugins
|
||||
|
||||
1. Create a new folder named "factorio" in the the factorioClusterio
|
||||
folder.
|
||||
For well known plugins you can select them during installation and no further steps are necessary to make them work.
|
||||
Installing plugins that are not offered by the installer consists of two steps.
|
||||
First install the package the plugin is provided by via npm, for example
|
||||
|
||||
2. Download the MS Windows (64-bit zip package) from
|
||||
https://www.factorio.com/download .
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
|
||||
3. Open the zip file and drag the folder called "Factorio_0.x.y" into
|
||||
the factorio folder created in step 1.
|
||||
Then tell clusterio that this plugin exists by adding it as a plugin.
|
||||
|
||||
- Via steam installation
|
||||
npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
1. Create a new folder named "factorio" in the the factorioClusterio
|
||||
folder.
|
||||
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by master, slave and ctl.
|
||||
If you have slaves or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
|
||||
The clusteriomaster, clusterioslave and clusterioctl commands has the plugin sub-command so you do not need to install clusteriomaster to add plugins, instead use the clusterio command you have available.
|
||||
|
||||
2. Locate the game files by right clicking the game in steam,
|
||||
selecting properties, then Local Files, then Browse local files.
|
||||
For development purposes the `plugin add` command supports adding plugins by the absolute path to them, or a relative path that must start with either . or .. (which will then be resolved to an absolute path).
|
||||
|
||||
3. Go to the parent folder of the folder that Steam opened and copy
|
||||
the Factorio folder into the factorio folder created in step 1.
|
||||
|
||||
## Configure Master Server
|
||||
|
||||
By default the master server will listen for HTTP on port 8080.
|
||||
You can change the port used with the command
|
||||
|
||||
npx clusteriomaster config set master.http_port 1234
|
||||
|
||||
When changing the port you will also need to change the address slaves connect with.
|
||||
For the standalone installation mode you can use
|
||||
|
||||
npx clusterioslave config set slave.master_url http://localhost:1234/
|
||||
|
||||
If you plan to make your cluster available externally set the address
|
||||
that it will be accessible under with, for example
|
||||
|
||||
npx clusteriomaster config set master.external_address http://203.0.113.4:1234/
|
||||
|
||||
Change the url to reflect the IP, protocol, and port the master server is accessible under, dns names are also supported.
|
||||
If you're planning on making the master server accessible on the internet it's recommended to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md) document for more details.
|
||||
|
||||
You can list the config of the master server with the `npx clusteriomaster config list` command.
|
||||
See the [readme for @clusterio/master](/packages/master/README.md) for more information.
|
||||
|
||||
|
||||
## Managing Factorio mods
|
||||
|
||||
Clusterio currently only provides a manual method for running mods on servers by copying them either to each instance's individual `mods` folder, or by copying them into a slave installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that slave if the file doesn't already exist in the `mods` folder for that instance.
|
||||
Mods are copied from `sharedMods` on Windows and symlinked on Linux, this means that when mods are removed from `sharedMods` the mods will also be removed from the instance's `mods` folder on Linux if it got there by being linked by Clusterio, but not on Windows.
|
||||
|
||||
For managing the mod settings the `mod-settings.dat` and `mod-list.json` files will also be linked/copied over if present in `sharedMods`, but since Factorio creates these files automatically it might be necessary to delete them from each instance's `mods` folder for changes to them in `sharedMods` to take effect.
|
||||
|
||||
Better remote management of mods is a planned feature.
|
||||
|
||||
|
||||
## Setting up shared storage
|
||||
|
||||
The chests teleporting items to and from the shared storage on the master server has been moved into the Subspace Storage mod and plugin.
|
||||
To get this to work you will need to install the Subspace Storage plugin and copy the [Subspace Storage mod](https://mods.factorio.com/mod/subspace_storage) and the [Clusterio Lib mod](https://mods.factorio.com/mod/clusterio_lib) into the `sharedMods` folder on each slave installation.
|
||||
|
||||
Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), this was for the old 1.2.x version of Clusterio and is not compatible with Clusterio 2.0.
|
||||
|
||||
|
||||
## Running Clusterio
|
||||
|
||||
After following the installation instructions you can use the following
|
||||
commands to run Clusterio.
|
||||
After completing the installation start up the master server and at least one slave separately.
|
||||
The installer provides the `run-master` and `run-slave` scripts to make this simple.
|
||||
Once the master process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
|
||||
|
||||
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a slave and then click start.
|
||||
|
||||
### Running via Systemd
|
||||
|
||||
The install script creates systemd service scripts for clusteriomaster and clusterioslave (if applicable) to start up as the user than ran the installer.
|
||||
If you have copied these files over to `/etc/systemd/system/` then you can startup clusterio as a background service using:
|
||||
|
||||
#### To start as a service run:
|
||||
|
||||
sudo systemctl start clusteriomaster # for the master server
|
||||
sudo systemctl start clusterioslave # for each server hosting a slave
|
||||
|
||||
#### To automatically get it to start on boot:
|
||||
|
||||
sudo systemctl enable clusteriomaster # for the master server
|
||||
sudo systemctl enable clusterioslave # for each server hosting a slave
|
||||
|
||||
|
||||
### Master Server
|
||||
## Setting up remote slaves
|
||||
|
||||
It's necessary to run the master server in order for anything to work.
|
||||
Once you've completed the setup run the following command to start it
|
||||
up:
|
||||
|
||||
node master run
|
||||
Run the installer as described in the installation section and choose "Slave only" as the operating mode to install, it'll ask for a master URL and an authentication token.
|
||||
The URL is the same as what is needed to connect to the web interface, and the athentication token can be generated on the Slaves page in the web interface.
|
||||
Once you start up the slave it should show up in the Slaves list and be available for assigning and running instances on.
|
||||
|
||||
|
||||
### Slaves
|
||||
## Setting up clusterioctl
|
||||
|
||||
Slaves connect to the master server and are managed remotely from the
|
||||
master server. In order for slaves to connect to the master server they
|
||||
need an authentication token from the master server. This token is
|
||||
written to secret-api-token.txt on the master server when it is started
|
||||
up.
|
||||
There's a command line interface available for Clusterio which is installed separately with the same installer as for the master and slave.
|
||||
Run the installer as described in the installation section and choose "Ctl only" as the operating mode to install, you can do this in the same directory as you have installed other clusterio component(s) to.
|
||||
The installer will ask for a master URL and an authentication token, these are the same as you would use to connect to the web interface.
|
||||
If you want to use a different user for the command line interface you can generate an authentication token for an existing user with
|
||||
|
||||
To set up the configuration for a new local slave run the following.
|
||||
npx clusteriomaster bootstrap generate-user-token <username>
|
||||
|
||||
node slave config set slave.name "Local"
|
||||
node slave config set slave.master_token "<token>"
|
||||
|
||||
This will write a new `config-slave.json` file in the current directory
|
||||
(you can change the location with the `--config` option) with the name
|
||||
and token provided. If you are connecting to a remote master server you
|
||||
will also need to set the `slave.master_url` option to that url.
|
||||
|
||||
You can list the config of a slave with the `node slave config list`
|
||||
command. Use `node slave config --help` for more information.
|
||||
|
||||
Once the config is set up run the slave with
|
||||
|
||||
node slave run
|
||||
|
||||
|
||||
### Instances
|
||||
|
||||
Instances are created, managed and started from the master server. For
|
||||
now the only interface available is the `clusterctl` command line tool
|
||||
included in Clusterio. You can run this tool from any slave, or the
|
||||
master server without having to set up a config, if you want to manage
|
||||
the cluster from somewhere else you will need to set the
|
||||
`control.master_url` and `control.master_token` options with the `node
|
||||
clusterctl control-config set` command:
|
||||
|
||||
The basic operations to start a new instance is the following
|
||||
|
||||
node clusterctl create-instance --name "My instance"
|
||||
node clusterctl assign-instance --instance "My instance" --slave "Local"
|
||||
node clusterctl create-save --instance "My instance"
|
||||
node clusterctl start-instance --instance "My instance"
|
||||
|
||||
The first line creates the instance configuration on the master server.
|
||||
The second assigns the instance to a slave which creates the instance
|
||||
directory and files needed to run the instance on the given slave. The
|
||||
third line creates a new savegame for the instance. You could also
|
||||
upload your own save to the instance directory instead. And finally the
|
||||
fourth line starts the instance.
|
||||
|
||||
There are many more commands available with clusterctl. See
|
||||
`node clusterctl --help` for a full list of them.
|
||||
|
||||
|
||||
## Plugins
|
||||
Here are the known Clusterio plugins in the wild:
|
||||
1. [Player Manager](https://github.com/Danielv123/playerManager) - Adds player management to the Web UI and shared inventory handling (beta)
|
||||
2. [DiscordChat](https://github.com/jakedraddy/ClusterioDiscordChat) - Logs in-game chat/joins/leave messages on all instances to a Discord webhook.
|
||||
3. [TrainTeleports](https://github.com/Godmave/clusterioTrainTeleports) - Allows you to teleport cargotrains between servers.
|
||||
|
||||
## Common problems
|
||||
|
||||
### EACCESS [...] LISTEN 443
|
||||
|
||||
Some systems don't let non root processes listen to ports below 1000. Either run with `sudo` or change config.json to use higher port numbers.
|
||||
Some systems don't let non root processes listen to ports below 1000.
|
||||
Either run with `sudo` or change config.json to use higher port numbers.
|
||||
|
||||
According to [this link](https://askubuntu.com/questions/839520/open-port-443-for-a-node-web-app) if you manually installed node.js following the above instructions, you may need to run the following command to fix this issue:
|
||||
|
||||
@@ -275,7 +278,11 @@ According to [this link](https://askubuntu.com/questions/839520/open-port-443-fo
|
||||
|
||||
### Portforwarding doesn't work on the master server when running under WSL
|
||||
|
||||
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip. This is also true when you correctly port-forwarded the correct ports. Even when routing this server through nginx in WSL, the issue persists. Then, on a hunch, I tried to run nginx from windows itself and found that this DID work. It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
|
||||
If you follow the ubuntu guide on WSL (Windows Subsystem for Linux, Bash on Ubuntu on Windows specifically), you will find that the website works on localhost and on your local ip, but not on the global ip.
|
||||
This is also true when you correctly port-forwarded the correct ports.
|
||||
Even when routing this server through nginx in WSL, the issue persists.
|
||||
Then, on a hunch, I tried to run nginx from windows itself and found that this DID work.
|
||||
It came to me that the only usage difference between the 2 versions of nginx is that I got a Windows Firewall popup.
|
||||
|
||||
TLDR: the tested fix is:
|
||||
|
||||
@@ -288,7 +295,3 @@ TLDR: the tested fix is:
|
||||
- select TCP and select specific local ports and type in the ports that you want to open (comma separated) and click next > 3 times
|
||||
|
||||
- give the rule a name (like 'web server' or something), give it a description (optionally) and click finish
|
||||
|
||||
### Other fixes for other potential problems:
|
||||
|
||||
Sometimes the install fails. Try `node ./lib/npmPostinstall` to complete it.
|
||||
@@ -1,92 +0,0 @@
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const JSZip = require("jszip");
|
||||
const klaw = require("klaw");
|
||||
const path = require("path");
|
||||
const yargs = require("yargs");
|
||||
|
||||
|
||||
// Location of mod source files
|
||||
const sourceDir = "mod";
|
||||
|
||||
// Paths to additional files to put into the mod's folder.
|
||||
const additionalFiles = [
|
||||
path.join("modules", "clusterio", "serialize.lua"),
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const args = yargs
|
||||
.scriptName("build")
|
||||
.options({
|
||||
'clean': { describe: "Remove previous builds", type: 'boolean', default: false },
|
||||
'build': { describe: "Build mod", type: 'boolean', default: true },
|
||||
'pack': { describe: "Pack into zip file", type: 'boolean', default: true },
|
||||
'output-dir': { describe: "Path to output built mod", nargs: 1, type: 'string', default: "dist" },
|
||||
})
|
||||
.argv
|
||||
;
|
||||
|
||||
let info = JSON.parse(await fs.readFile(path.join(sourceDir, "info.json")));
|
||||
|
||||
if (args.clean) {
|
||||
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/
|
||||
for (let entry of await fs.readdir(args.outputDir)) {
|
||||
let match = splitter.exec(entry);
|
||||
if (match) {
|
||||
let [, name, version] = match;
|
||||
if (name === info.name) {
|
||||
let modPath = path.join(args.outputDir, entry);
|
||||
console.log(`Removing ${modPath}`);
|
||||
await fs.remove(modPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.build) {
|
||||
await fs.ensureDir(args.outputDir);
|
||||
let modName = `${info.name}_${info.version}`;
|
||||
|
||||
if (args.pack) {
|
||||
let zip = new JSZip();
|
||||
let walker = klaw(sourceDir)
|
||||
.on('data', item => {
|
||||
if (item.stats.isFile()) {
|
||||
// On Windows the path created uses backslashes as the directory sepparator
|
||||
// but the zip file needs to use forward slashes. We can't use the posix
|
||||
// version of relative here as it doesn't work with Windows style paths.
|
||||
let basePath = path.relative(sourceDir, item.path).replace(/\\/g, "/");
|
||||
zip.file(path.posix.join(modName, basePath), fs.createReadStream(item.path));
|
||||
}
|
||||
})
|
||||
;
|
||||
await events.once(walker, 'end');
|
||||
|
||||
for (let filePath of additionalFiles) {
|
||||
zip.file(path.posix.join(modName, path.basename(filePath)), fs.createReadStream(filePath));
|
||||
}
|
||||
|
||||
let modPath = path.join(args.outputDir, `${modName}.zip`);
|
||||
console.log(`Writing ${modPath}`);
|
||||
let writeStream = zip.generateNodeStream().pipe(fs.createWriteStream(modPath));
|
||||
await events.once(writeStream, 'finish');
|
||||
|
||||
} else {
|
||||
let modDir = path.join(args.outputDir, modName);
|
||||
if (await fs.exists(modDir)) {
|
||||
console.log(`Removing existing build ${modDir}`);
|
||||
await fs.remove(modDir);
|
||||
}
|
||||
console.log(`Building ${modDir}`);
|
||||
await fs.copy(sourceDir, modDir);
|
||||
for (let filePath of additionalFiles) {
|
||||
await fs.copy(filePath, path.join(modDir, path.basename(filePath)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
main().catch(err => { console.log(err) });
|
||||
}
|
||||
@@ -1,566 +0,0 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
const version = require("./package").version;
|
||||
const asTable = require("as-table").configure({delimiter: ' | '});
|
||||
const chalk = require("chalk");
|
||||
const events = require("events");
|
||||
|
||||
const link = require("lib/link");
|
||||
const errors = require("lib/errors");
|
||||
const config = require("lib/config");
|
||||
|
||||
|
||||
/**
|
||||
* Format a parsed Factorio output message with colors
|
||||
*
|
||||
* Formats a parsed Factorio output from lib/factorio into a readable
|
||||
* colorized output using terminal escape codes that can be printed.
|
||||
*/
|
||||
function formatOutputColored(output) {
|
||||
let time = "";
|
||||
if (output.format === 'seconds') {
|
||||
time = chalk.yellow(output.time.padStart(8)) + ' ';
|
||||
} else if (output.format === 'date') {
|
||||
time = chalk.yellow(output.time) + ' ';
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (output.type === 'log') {
|
||||
let level = output.level;
|
||||
if (level === "Info") {
|
||||
level = chalk.bold.blueBright(level);
|
||||
} else if (output.level === "Warning") {
|
||||
level = chalk.bold.yellowBright(level);
|
||||
} else if (output.level === "Error") {
|
||||
level = chalk.bold.redBright(level);
|
||||
}
|
||||
|
||||
info = level + ' ' + chalk.gray(output.file) + ': ';
|
||||
|
||||
} else if (output.type === 'action') {
|
||||
info = '[' + chalk.yellow(output.action) + '] ';
|
||||
}
|
||||
|
||||
return time + info + output.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a command that can be runned by clusterctl
|
||||
*/
|
||||
class Command {
|
||||
constructor({ definition, handler }) {
|
||||
this.name = definition[0];
|
||||
this._definition = definition;
|
||||
this._handler = handler;
|
||||
}
|
||||
|
||||
register(yargs) {
|
||||
yargs.command(...this._definition);
|
||||
}
|
||||
|
||||
async run(args, control) {
|
||||
await this._handler.call(this, args, control);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a string to an instance ID
|
||||
*
|
||||
* Resolves a string with either an instance name or an id into an integer
|
||||
* with the instance ID.
|
||||
*
|
||||
* @param client - link to master server to query instance on.
|
||||
* @param instanceName - string with name or id of instance.
|
||||
* @returns {number} instance ID.
|
||||
*/
|
||||
async function resolveInstance(client, instanceName) {
|
||||
let instanceId;
|
||||
if (/^-?\d+$/.test(instanceName)) {
|
||||
instanceId = parseInt(instanceName, 10);
|
||||
} else {
|
||||
let response = await link.messages.listInstances.send(client);
|
||||
for (let instance of response.list) {
|
||||
if (instance.name === instanceName) {
|
||||
instanceId = instance.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceId === undefined) {
|
||||
throw new errors.CommandError(`No instance named ${instanceName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a string into a slave ID
|
||||
*
|
||||
* Resolves a string with either an slave name or an id into an integer with
|
||||
* the slave ID.
|
||||
*
|
||||
* @param client - link to master server to query slave on.
|
||||
* @param slaveName - string with name or id of slave.
|
||||
* @returns {number} slave ID.
|
||||
*/
|
||||
async function resolveSlave(client, slaveName) {
|
||||
let slaveId;
|
||||
if (/^-?\d+$/.test(slaveName)) {
|
||||
slaveId = parseInt(slaveName, 10);
|
||||
} else {
|
||||
let response = await link.messages.listSlaves.send(client);
|
||||
for (let slave of response.list) {
|
||||
if (slave.name === slaveName) {
|
||||
slaveId = slave.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (slaveId === undefined) {
|
||||
throw new errors.CommandError(`No slave named ${slaveName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return slaveId;
|
||||
}
|
||||
|
||||
let commands = [];
|
||||
commands.push(new Command({
|
||||
definition: ["list-slaves", "List slaves connected to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await link.messages.listSlaves.send(control);
|
||||
console.log(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ["list-instances", "List instances known to the master"],
|
||||
handler: async function(args, control) {
|
||||
let response = await link.messages.listInstances.send(control);
|
||||
console.log(asTable(response.list));
|
||||
}
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ['create-instance', "Create an instance", (yargs) => {
|
||||
// XXX TODO: set any specific options?
|
||||
yargs.option('name', {
|
||||
type: "string",
|
||||
nargs: 1,
|
||||
describe: "Instance name",
|
||||
group: "Instance Config:",
|
||||
});
|
||||
yargs.option('id', {
|
||||
type: "number",
|
||||
nargs: 1,
|
||||
describe: "Instance ID",
|
||||
group: "Instance Config:",
|
||||
});
|
||||
yargs.option('base', {
|
||||
type: "boolean",
|
||||
nargs: 0,
|
||||
describe: "Specify this is a base config",
|
||||
group: "Instance Config:"
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceConfig = new config.InstanceConfig();
|
||||
await instanceConfig.init();
|
||||
if (args.id) {
|
||||
instanceConfig.set("instance.id", args.id);
|
||||
}
|
||||
if (args.name) {
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
}
|
||||
let serialized_config = instanceConfig.serialize();
|
||||
let response = await link.messages.createInstance.send(control, { serialized_config });
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ["list-instance-config", "List configuration for an instance", (yargs) => {
|
||||
yargs.option({
|
||||
"instance": { describe: "Instance to list config for", nargs: 1, type: "string", demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await resolveInstance(control, args.instance);
|
||||
let response = await link.messages.getInstanceConfig.send(control, { instance_id: instanceId });
|
||||
|
||||
for (let group of response.serialized_config.groups) {
|
||||
for (let [name, value] of Object.entries(group.fields)) {
|
||||
console.log(`${group.name}.${name} ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ["set-instance-config", "Set field in instance config", (yargs) => {
|
||||
yargs.option({
|
||||
"instance": { describe: "Instance to set config on", nargs: 1, type: "string", demandOption: true },
|
||||
"field": { describe: "Field to set", nargs: 1, type: "string", demandOption: true },
|
||||
"value": { describe: "Value to set", nargs: 1, type: "string", demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await resolveInstance(control, args.instance);
|
||||
await link.messages.setInstanceConfigField.send(control, {
|
||||
instance_id: instanceId,
|
||||
field: args.field,
|
||||
value: args.value,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ['assign-instance', "Assign instance to a slave", (yargs) => {
|
||||
yargs.options({
|
||||
'instance': { describe: "Instance to assign", nargs: 1, type: 'string', demandOption: true },
|
||||
'slave': { describe: "Slave to assign to", nargs: 1, type: 'string', demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await resolveInstance(control, args.instance);
|
||||
let slaveId = await resolveSlave(control, args.slave);
|
||||
await link.messages.assignInstanceCommand.send(control, {
|
||||
instance_id: instanceId,
|
||||
slave_id: slaveId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ['create-save', "Create a new save on an instance", (yargs) => {
|
||||
yargs.options({
|
||||
'instance': { describe: "Instance to create on", nargs: 1, type: 'string', demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await resolveInstance(control, args.instance);
|
||||
await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await link.messages.createSave.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ['start-instance', "Start instance", (yargs) => {
|
||||
yargs.options({
|
||||
'instance': { describe: "Instance to start", nargs: 1, type: 'string', demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await resolveInstance(control, args.instance);
|
||||
await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await link.messages.startInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ['stop-instance', "Stop instance", (yargs) => {
|
||||
yargs.options({
|
||||
'instance': { describe: "Instance to stop", nargs: 1, type: 'string', demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await resolveInstance(control, args.instance);
|
||||
await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instanceId] });
|
||||
let response = await link.messages.stopInstance.send(control, {
|
||||
instance_id: instanceId,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ['delete-instance', "Delete instance", (yargs) => {
|
||||
yargs.options({
|
||||
'instance': { describe: "Instance to delete", nargs: 1, type: 'string', demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await link.messages.deleteInstance.send(control, {
|
||||
instance_id: await resolveInstance(control, args.instance),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
commands.push(new Command({
|
||||
definition: ['send-rcon', "Send RCON command", (yargs) => {
|
||||
yargs.options({
|
||||
'instance': { describe: "Instance to sent to", nargs: 1, type: 'string', demandOption: true },
|
||||
'command': { describe: "command to send", nargs: 1, type: 'string', demandOption: true },
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let response = await link.messages.sendRcon.send(control, {
|
||||
instance_id: await resolveInstance(control, args.instance),
|
||||
command: args.command
|
||||
})
|
||||
|
||||
// Factorio includes a newline in it's response output.
|
||||
process.stdout.write(response.result);
|
||||
},
|
||||
}));
|
||||
|
||||
// Convert to mapping from name to command instance
|
||||
commands = new Map([...commands.map(command => [command.name, command])]);
|
||||
|
||||
|
||||
/**
|
||||
* Connector for control connection to master server
|
||||
*/
|
||||
class ControlConnector extends link.WebSocketClientConnector {
|
||||
constructor(url, reconnectDelay, token) {
|
||||
super(url, reconnectDelay);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
register() {
|
||||
console.log("SOCKET | registering control");
|
||||
this.sendHandshake("register_control", {
|
||||
token: this._token,
|
||||
agent: "clusterctl",
|
||||
version: version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the master server over WebSocket and sends commands to it.
|
||||
*/
|
||||
class Control extends link.Link {
|
||||
|
||||
// I don't like God classes, but the alternative of putting all this state
|
||||
// into global variables is not much better.
|
||||
constructor(connector) {
|
||||
super('control', 'master', connector);
|
||||
link.attachAllMessages(this);
|
||||
}
|
||||
|
||||
async instanceOutputEventHandler(message) {
|
||||
let { instance_id, output } = message.data;
|
||||
console.log(formatOutputColored(output));
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
await link.messages.shutdownConnection.send(this);
|
||||
this.connector.close(1001, "Control Quit")
|
||||
await events.once(this.connector, "close");
|
||||
}
|
||||
}
|
||||
|
||||
async function findCredentials(controlConfig) {
|
||||
// Try looking in the default master config location
|
||||
try {
|
||||
let masterConfig = new config.MasterConfig();
|
||||
await masterConfig.load(JSON.parse(await fs.readFile("config-master.json")));
|
||||
|
||||
let token = jwt.sign({ id: "api" }, masterConfig.get("master.auth_secret"), { expiresIn: 600 });
|
||||
controlConfig.set("control.master_token", token);
|
||||
|
||||
let url;
|
||||
if (masterConfig.get("master.https_port")) {
|
||||
url = `https://localhost:${masterConfig.get("master.https_port")}`;
|
||||
} else {
|
||||
url = `http://localhost:${masterConfig.get("master.http_port")}`;
|
||||
}
|
||||
controlConfig.set("control.master_url", url);
|
||||
|
||||
return;
|
||||
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Try looking in the default slave config location
|
||||
// In the future it's likely that tokens will be assigned more granular
|
||||
// authorizations, meaning that you cannot use a slave token to control
|
||||
// the cluster.
|
||||
try {
|
||||
let slaveConfig = new config.SlaveConfig();
|
||||
await slaveConfig.load(JSON.parse(await fs.readFile("config-slave.json")));
|
||||
|
||||
controlConfig.set("control.master_token", slaveConfig.get("slave.master_token"));
|
||||
controlConfig.set("control.master_url", slaveConfig.get("slave.master_url"));
|
||||
return;
|
||||
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new errors.StartupError("Couldn't find credentials to connect to the master server with");
|
||||
}
|
||||
|
||||
|
||||
class ControlGroup extends config.ConfigGroup {}
|
||||
ControlGroup.groupName = "control";
|
||||
ControlGroup.define({
|
||||
name: "master_url",
|
||||
description: "URL to connect to the master server at",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "master_token",
|
||||
description: "Token to authenticate to master server with.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "reconnect_delay",
|
||||
title: "Reconnect Delay",
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initial_value: 2,
|
||||
});
|
||||
ControlGroup.finalize();
|
||||
|
||||
class ControlConfig extends config.Config { }
|
||||
ControlConfig.registerGroup(ControlGroup);
|
||||
ControlConfig.finalize();
|
||||
|
||||
|
||||
async function startControl() {
|
||||
yargs
|
||||
.scriptName("clusterctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option('config', {
|
||||
nargs: 1,
|
||||
describe: "config file to get credentails from",
|
||||
default: "config-control.json",
|
||||
defaultDescription: "auto",
|
||||
type: 'string',
|
||||
})
|
||||
.command("control-config", "Manage Control config", config.configCommand)
|
||||
.wrap(yargs.terminalWidth())
|
||||
;
|
||||
|
||||
for (let command of commands.values()) {
|
||||
command.register(yargs);
|
||||
}
|
||||
|
||||
const args = yargs.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
.argv
|
||||
;
|
||||
|
||||
config.finalizeConfigs();
|
||||
|
||||
console.log(`Loading config from ${args.config}`);
|
||||
let controlConfig = new ControlConfig();
|
||||
try {
|
||||
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
console.log("Config not found, initializing new config");
|
||||
await controlConfig.init();
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let commandName = args._[0];
|
||||
if (commandName === "control-config") {
|
||||
await config.handleConfigCommand(args, controlConfig, args.config);
|
||||
return;
|
||||
}
|
||||
|
||||
// The remaining commands require connecting to the master server.
|
||||
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
|
||||
await findCredentials(controlConfig);
|
||||
}
|
||||
|
||||
let controlConnector = new ControlConnector(
|
||||
controlConfig.get("control.master_url"),
|
||||
controlConfig.get("control.reconnect_delay"),
|
||||
controlConfig.get("control.master_token")
|
||||
);
|
||||
let control = new Control(controlConnector);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch(err) {
|
||||
if (err instanceof errors.AuthenticationFailed) {
|
||||
throw new errors.StartupError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (commands.has(commandName)) {
|
||||
command = commands.get(commandName);
|
||||
|
||||
try {
|
||||
await command.run(args, control)
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof errors.CommandError) {
|
||||
console.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof errors.RequestError) {
|
||||
console.error(`Error sending request: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
} finally {
|
||||
await control.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
//XXX control.close("done");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// for testing only
|
||||
_formatOutputColored: formatOutputColored,
|
||||
_resolveInstance: resolveInstance,
|
||||
_Control: Control,
|
||||
|
||||
_commands: commands,
|
||||
}
|
||||
|
||||
|
||||
if (module === require.main) {
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startControl().catch(err => {
|
||||
if (!(err instanceof errors.StartupError)) {
|
||||
console.error(`
|
||||
+----------------------------------------------------------------+
|
||||
| Unexpected error occured while starting control, please report |
|
||||
| it to https://github.com/clusterio/factorioClusterio/issues |
|
||||
+----------------------------------------------------------------+`
|
||||
);
|
||||
} else {
|
||||
console.error(`
|
||||
+-------------------------------+
|
||||
| Unable to to start clusterctl |
|
||||
+-------------------------------+`
|
||||
);
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
unit_test:
|
||||
image: clusterio
|
||||
build: .
|
||||
command: npm run test
|
||||
@@ -0,0 +1,9 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
master:
|
||||
build: .
|
||||
command: npx clusteriomaster run
|
||||
slave:
|
||||
build: .
|
||||
command: npx clusterioslave run
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="1000px" height="840px" viewBox="-0.5 -0.5 1000 840" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="40" y="550" width="320" height="250" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 547px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="42" y="547" fill="#000000" font-family="Helvetica" font-size="12px">connected</text></switch></g><rect x="780" y="40" width="180" height="150" fill="#cedbee" stroke="none" pointer-events="all"/><path d="M 300 750 L 300 730 L 440 730 L 440 140 L 376.37 140" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 371.12 140 L 378.12 136.5 L 376.37 140 L 378.12 143.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 723px; margin-left: 346px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">yes</div></div></div></foreignObject><text x="346" y="726" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">yes</text></switch></g><rect x="520" y="490" width="440" height="310" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 438px; height: 1px; padding-top: 487px; margin-left: 522px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="522" y="487" fill="#000000" font-family="Helvetica" font-size="12px">resuming</text></switch></g><rect x="40" y="210" width="380" height="300" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 378px; height: 1px; padding-top: 207px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connecting</div></div></div></foreignObject><text x="42" y="207" fill="#000000" font-family="Helvetica" font-size="12px">connecting</text></switch></g><path d="M 120 150 L 120 223.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 120 228.88 L 116.5 221.88 L 120 223.63 L 123.5 221.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 191px; margin-left: 151px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; coLine truncated
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="919px" height="620px" viewBox="-0.5 -0.5 919 620" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="40" y="260" width="320" height="300" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 257px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="42" y="257" fill="#000000" font-family="Helvetica" font-size="12px">connected</text></switch></g><rect x="700" y="40" width="180" height="150" fill="#cedbee" stroke="none" pointer-events="all"/><path d="M 280 390 L 280 370 L 380 370 L 380 130 L 346.37 130" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 341.12 130 L 348.12 126.5 L 346.37 130 L 348.12 133.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 360px; margin-left: 291px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">yes</div></div></div></foreignObject><text x="291" y="363" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">yes</text></switch></g><rect x="420" y="190" width="160" height="160" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 158px; height: 1px; padding-top: 187px; margin-left: 422px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="422" y="187" fill="#000000" font-family="Helvetica" font-size="12px">resuming</text></switch></g><path d="M 120 140 L 120 273.63" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 120 278.88 L 116.5 271.88 L 120 273.63 L 123.5 271.88 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 194px; margin-left: 151px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">ready()</div></div></div></foreignObject><text x="151" y="197" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">ready()</text></switch></g><rect x="40" y="80" width="320" height="80" fill="none" stroke="#000000" stroke-dasharray="3 3" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-end; justify-content: unsafe flex-start; width: 318px; height: 1px; padding-top: 77px; margin-left: 42px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #00Line truncated
|
||||
|
After Width: | Height: | Size: 45 KiB |
@@ -1,20 +0,0 @@
|
||||
digraph "Client Connection Flow" {
|
||||
node [shape=box];
|
||||
graph [newrank=true];
|
||||
|
||||
start [label="Start"];
|
||||
wait_hello [label="Wait for hello"];
|
||||
new_session [label="New session"];
|
||||
wait_ready [label="Wait for ready"];
|
||||
connected [label="Connected"];
|
||||
abort [label="Abort"];
|
||||
lost_connection [label="Connection lost or\nunexpectedly closed\nwhile connecting" style="dotted"];
|
||||
|
||||
|
||||
lost_connection -> start [label="Wait random delay"];
|
||||
start -> wait_hello [label="Connect to /api/socket"];
|
||||
wait_hello -> new_session [label="Receive hello"];
|
||||
new_session -> wait_ready [label="Send register_*"];
|
||||
wait_ready -> connected [label="Receive ready"];
|
||||
wait_ready -> abort [label="Connection closed with 4003"];
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<mxfile host="Electron" modified="2021-08-30T18:05:06.237Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/14.9.6 Chrome/89.0.4389.128 Electron/12.0.16 Safari/537.36" etag="uzcLt7Oh4qtDsMY9WSdT" compressed="false" version="14.9.6" type="device">
|
||||
<diagram id="GSbHU_PhJe-l3NQpc0X3" name="Connection Flow">
|
||||
<mxGraphModel dx="710" dy="734" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="760" pageHeight="630" background="#EEF3FA" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="GTJXd52-XhRZD3rAr2nm-0" />
|
||||
<mxCell id="GTJXd52-XhRZD3rAr2nm-1" parent="GTJXd52-XhRZD3rAr2nm-0" />
|
||||
<mxCell id="2r3fOvGVNr9_K5ZBoRis-18" value="Client" style="text;html=1;align=left;verticalAlign=middle;resizable=1;points=[];autosize=0;strokeColor=none;fontStyle=1" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="440" y="80" width="145" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-0">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="260" y="320.02" />
|
||||
<mxPoint x="260" y="320.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-5" value="receive<br>resume" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-1">
|
||||
<mxGeometry x="0.086" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="-6" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-2">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="180" y="320.02" />
|
||||
<mxPoint x="180" y="320.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-26" value="receive<br>register" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-3">
|
||||
<mxGeometry x="-0.2268" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="2" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="2r3fOvGVNr9_K5ZBoRis-28" target="IKjVovVhkWe_WipwIJL3-13">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="40" y="285.02" />
|
||||
<mxPoint x="40" y="580.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-27" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-14">
|
||||
<mxGeometry x="-0.6341" y="-2" relative="1" as="geometry">
|
||||
<mxPoint x="28" y="17" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="2r3fOvGVNr9_K5ZBoRis-28" value="Wait for handshake" style="rounded=0;whiteSpace=wrap;html=1;strokeWidth=2;" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="160" y="270.02000000000004" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="HutjLCx0ygpoBEJLSh69-0" value="Server" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fontStyle=1" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="40" y="80" width="50" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_PUvpjEMIo1z1dIbIUzw-0" value="<p><span>Connection Flow</span><br></p>" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontStyle=0;fontSize=20;" parent="GTJXd52-XhRZD3rAr2nm-1" vertex="1">
|
||||
<mxGeometry x="40" y="30" width="230" height="20" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="fGsjKHM69yN9R0Hf5M05-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="fGsjKHM69yN9R0Hf5M05-0" target="IKjVovVhkWe_WipwIJL3-7">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="220" y="180" />
|
||||
<mxPoint x="180" y="180" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="fGsjKHM69yN9R0Hf5M05-0" value="WebSocket Open" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="160" y="119.98" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-0" target="IKjVovVhkWe_WipwIJL3-9">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="300" y="300.02" />
|
||||
<mxPoint x="300" y="300.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-6" value="bad session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingLeft=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-4">
|
||||
<mxGeometry x="-0.429" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="29" y="-4" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-0" target="IKjVovVhkWe_WipwIJL3-19">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-29" value="valid" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-20">
|
||||
<mxGeometry x="-0.1944" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="18" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-0" value="Validate resume" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="230" y="340.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-2" target="IKjVovVhkWe_WipwIJL3-18">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-28" value="valid" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-21">
|
||||
<mxGeometry x="-0.2778" relative="1" as="geometry">
|
||||
<mxPoint x="20" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-2" target="IKjVovVhkWe_WipwIJL3-13">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="60" y="355.02" />
|
||||
<mxPoint x="60" y="560.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-30" value="bad token" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-24">
|
||||
<mxGeometry x="-0.8093" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="29" y="23" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-2" value="Validate register" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="90" y="340.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-7" target="2r3fOvGVNr9_K5ZBoRis-28">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="180" y="260.02" />
|
||||
<mxPoint x="180" y="260.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-7" value="Send hello" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="90" y="199.96" width="120" height="30.02" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-9" target="2r3fOvGVNr9_K5ZBoRis-28">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="260" y="260.02" />
|
||||
<mxPoint x="260" y="260.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-9" value="Send invalidate" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="230" y="199.96" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-13" value="Close connection" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="160" y="550.02" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-15" value="Connected" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="160" y="480.02" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-18" target="IKjVovVhkWe_WipwIJL3-15">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="180" y="470" />
|
||||
<mxPoint x="180" y="470" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-18" value="Send ready" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="90" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-19" target="IKjVovVhkWe_WipwIJL3-15">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="260" y="470" />
|
||||
<mxPoint x="260" y="470" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-19" value="Send continue" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="230" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-44">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="630" y="240.02" />
|
||||
<mxPoint x="630" y="240.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-32" value="previous<br>session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=8;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-31">
|
||||
<mxGeometry x="0.086" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="-2" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-33" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-49">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="550" y="250.02" />
|
||||
<mxPoint x="550" y="250.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-34" value="no<br>session" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;spacingRight=8;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-33">
|
||||
<mxGeometry x="-0.2268" relative="1" as="geometry">
|
||||
<mxPoint x="-20" y="5" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-35" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-37" target="IKjVovVhkWe_WipwIJL3-54">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="440" y="215.02" />
|
||||
<mxPoint x="440" y="570.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-36" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-35">
|
||||
<mxGeometry x="-0.6341" y="-2" relative="1" as="geometry">
|
||||
<mxPoint x="35" y="-23" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-37" value="Wait for hello" style="rounded=0;whiteSpace=wrap;html=1;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="199.96000000000006" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-60" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-39" target="IKjVovVhkWe_WipwIJL3-37">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-39" value="WebSocket Open" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="119.98" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-65" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-44" target="IKjVovVhkWe_WipwIJL3-61">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="660" y="360.02" />
|
||||
<mxPoint x="660" y="360.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-44" value="Send resume" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="600" y="270.0199999999999" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-68" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-49" target="IKjVovVhkWe_WipwIJL3-67">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-49" value="Send register" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="460" y="270.0199999999999" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-54" value="Close connection" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="550.02" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-55" value="Connected" style="rounded=1;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="480.0199999999999" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-64" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-62">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="630" y="390.02" />
|
||||
<mxPoint x="630" y="390.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-73" value="receive<br>invalidate" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-64">
|
||||
<mxGeometry x="0.2133" relative="1" as="geometry">
|
||||
<mxPoint x="-30" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-70" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-54">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="700" y="570.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-74" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;rotation=90;spacingTop=7;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-70">
|
||||
<mxGeometry x="-0.7644" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-12" y="29" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-71" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-61" target="IKjVovVhkWe_WipwIJL3-55">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="630" y="450.02" />
|
||||
<mxPoint x="630" y="450.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-75" value="receive<br>continue" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];spacingRight=4;labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-71">
|
||||
<mxGeometry x="-0.3167" y="-1" relative="1" as="geometry">
|
||||
<mxPoint x="-19" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-61" value="Wait for continue" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="600" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-63" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-62" target="IKjVovVhkWe_WipwIJL3-49">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="550" y="320.02" />
|
||||
<mxPoint x="550" y="320.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-62" value="Invalidate session" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="530" y="340.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-69" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-67" target="IKjVovVhkWe_WipwIJL3-54">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="470" y="560.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-77" value="bad handshake<br>timeout" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];rotation=90;labelBackgroundColor=none;spacingBottom=4;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-69">
|
||||
<mxGeometry x="-0.8148" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="12" y="33" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-72" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="GTJXd52-XhRZD3rAr2nm-1" source="IKjVovVhkWe_WipwIJL3-67" target="IKjVovVhkWe_WipwIJL3-55">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="550" y="470.02" />
|
||||
<mxPoint x="550" y="470.02" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-76" value="receive<br>ready" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];labelBackgroundColor=none;" vertex="1" connectable="0" parent="IKjVovVhkWe_WipwIJL3-72">
|
||||
<mxGeometry x="-0.2389" y="2" relative="1" as="geometry">
|
||||
<mxPoint x="-22" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="IKjVovVhkWe_WipwIJL3-67" value="Wait for ready" style="rounded=0;whiteSpace=wrap;html=1;labelBackgroundColor=none;strokeWidth=2;" vertex="1" parent="GTJXd52-XhRZD3rAr2nm-1">
|
||||
<mxGeometry x="460" y="410.02" width="120" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@@ -1,99 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.3 (0)
|
||||
-->
|
||||
<!-- Title: Client Connection Flow Pages: 1 -->
|
||||
<svg width="371pt" height="496pt"
|
||||
viewBox="0.00 0.00 371.00 496.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 492)">
|
||||
<title>Client Connection Flow</title>
|
||||
<polygon fill="white" stroke="transparent" points="-4,4 -4,-492 367,-492 367,4 -4,4"/>
|
||||
<!-- start -->
|
||||
<g id="node1" class="node">
|
||||
<title>start</title>
|
||||
<polygon fill="none" stroke="black" points="135,-384 81,-384 81,-348 135,-348 135,-384"/>
|
||||
<text text-anchor="middle" x="108" y="-362.3" font-family="Times,serif" font-size="14.00">Start</text>
|
||||
</g>
|
||||
<!-- wait_hello -->
|
||||
<g id="node2" class="node">
|
||||
<title>wait_hello</title>
|
||||
<polygon fill="none" stroke="black" points="164.5,-297 51.5,-297 51.5,-261 164.5,-261 164.5,-297"/>
|
||||
<text text-anchor="middle" x="108" y="-275.3" font-family="Times,serif" font-size="14.00">Wait for hello</text>
|
||||
</g>
|
||||
<!-- start->wait_hello -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>start->wait_hello</title>
|
||||
<path fill="none" stroke="black" d="M108,-347.8C108,-336.16 108,-320.55 108,-307.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-307.18 108,-297.18 104.5,-307.18 111.5,-307.18"/>
|
||||
<text text-anchor="middle" x="187.5" y="-318.8" font-family="Times,serif" font-size="14.00">Connect to /api/socket</text>
|
||||
</g>
|
||||
<!-- new_session -->
|
||||
<g id="node3" class="node">
|
||||
<title>new_session</title>
|
||||
<polygon fill="none" stroke="black" points="161,-210 55,-210 55,-174 161,-174 161,-210"/>
|
||||
<text text-anchor="middle" x="108" y="-188.3" font-family="Times,serif" font-size="14.00">New session</text>
|
||||
</g>
|
||||
<!-- wait_hello->new_session -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>wait_hello->new_session</title>
|
||||
<path fill="none" stroke="black" d="M108,-260.8C108,-249.16 108,-233.55 108,-220.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-220.18 108,-210.18 104.5,-220.18 111.5,-220.18"/>
|
||||
<text text-anchor="middle" x="155.5" y="-231.8" font-family="Times,serif" font-size="14.00">Receive hello</text>
|
||||
</g>
|
||||
<!-- wait_ready -->
|
||||
<g id="node4" class="node">
|
||||
<title>wait_ready</title>
|
||||
<polygon fill="none" stroke="black" points="167.5,-123 48.5,-123 48.5,-87 167.5,-87 167.5,-123"/>
|
||||
<text text-anchor="middle" x="108" y="-101.3" font-family="Times,serif" font-size="14.00">Wait for ready</text>
|
||||
</g>
|
||||
<!-- new_session->wait_ready -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>new_session->wait_ready</title>
|
||||
<path fill="none" stroke="black" d="M108,-173.8C108,-162.16 108,-146.55 108,-133.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-133.18 108,-123.18 104.5,-133.18 111.5,-133.18"/>
|
||||
<text text-anchor="middle" x="164" y="-144.8" font-family="Times,serif" font-size="14.00">Send register_*</text>
|
||||
</g>
|
||||
<!-- connected -->
|
||||
<g id="node5" class="node">
|
||||
<title>connected</title>
|
||||
<polygon fill="none" stroke="black" points="92,-36 0,-36 0,0 92,0 92,-36"/>
|
||||
<text text-anchor="middle" x="46" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
|
||||
</g>
|
||||
<!-- wait_ready->connected -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>wait_ready->connected</title>
|
||||
<path fill="none" stroke="black" d="M64.78,-86.86C57.71,-82.13 51.29,-76.25 47,-69 43.04,-62.32 41.73,-54.22 41.69,-46.49"/>
|
||||
<polygon fill="black" stroke="black" points="45.19,-46.55 42.31,-36.36 38.21,-46.13 45.19,-46.55"/>
|
||||
<text text-anchor="middle" x="97.5" y="-57.8" font-family="Times,serif" font-size="14.00">Receive ready</text>
|
||||
</g>
|
||||
<!-- abort -->
|
||||
<g id="node6" class="node">
|
||||
<title>abort</title>
|
||||
<polygon fill="none" stroke="black" points="198,-36 142,-36 142,0 198,0 198,-36"/>
|
||||
<text text-anchor="middle" x="170" y="-14.3" font-family="Times,serif" font-size="14.00">Abort</text>
|
||||
</g>
|
||||
<!-- wait_ready->abort -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>wait_ready->abort</title>
|
||||
<path fill="none" stroke="black" d="M131.28,-86.66C137.25,-81.45 143.3,-75.4 148,-69 153.19,-61.93 157.5,-53.45 160.88,-45.5"/>
|
||||
<polygon fill="black" stroke="black" points="164.14,-46.78 164.56,-36.19 157.63,-44.21 164.14,-46.78"/>
|
||||
<text text-anchor="middle" x="259.5" y="-57.8" font-family="Times,serif" font-size="14.00">Connection closed with 4003</text>
|
||||
</g>
|
||||
<!-- lost_connection -->
|
||||
<g id="node7" class="node">
|
||||
<title>lost_connection</title>
|
||||
<polygon fill="none" stroke="black" stroke-dasharray="1,5" points="188.5,-488 27.5,-488 27.5,-435 188.5,-435 188.5,-488"/>
|
||||
<text text-anchor="middle" x="108" y="-472.8" font-family="Times,serif" font-size="14.00">Connection lost or</text>
|
||||
<text text-anchor="middle" x="108" y="-457.8" font-family="Times,serif" font-size="14.00">unexpectedly closed</text>
|
||||
<text text-anchor="middle" x="108" y="-442.8" font-family="Times,serif" font-size="14.00">while connecting</text>
|
||||
</g>
|
||||
<!-- lost_connection->start -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>lost_connection->start</title>
|
||||
<path fill="none" stroke="black" d="M108,-434.95C108,-422.36 108,-407.17 108,-394.35"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-394.24 108,-384.24 104.5,-394.24 111.5,-394.24"/>
|
||||
<text text-anchor="middle" x="175.5" y="-405.8" font-family="Times,serif" font-size="14.00">Wait random delay</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="760px" height="630px" viewBox="-0.5 -0.5 760 630" style="background-color: rgb(238, 243, 250);"><defs/><g><rect x="440" y="80" width="145" height="20" fill="none" stroke="none" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe flex-start; width: 1px; height: 1px; padding-top: 90px; margin-left: 442px;"><div style="box-sizing: border-box; font-size: 0; text-align: left; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; font-weight: bold; white-space: nowrap; ">Client</div></div></div></foreignObject><text x="442" y="94" fill="#000000" font-family="Helvetica" font-size="12px" font-weight="bold">Client</text></switch></g><path d="M 260 300.02 L 260 320 L 260 333.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 260 338.9 L 256.5 331.9 L 260 333.65 L 263.5 331.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 316px; margin-left: 238px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">receive<br />resume</div></div></div></foreignObject><text x="238" y="319" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">receive...</text></switch></g><path d="M 180 300.02 L 180 320 L 180 333.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 180 338.9 L 176.5 331.9 L 180 333.65 L 183.5 331.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 318px; margin-left: 158px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">receive<br />register</div></div></div></foreignObject><text x="158" y="322" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">receive...</text></switch></g><path d="M 160 285 L 40 285 L 40 580 L 153.63 580" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 158.88 580 L 151.88 583.5 L 153.63 580 L 151.88 576.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 300px; margin-left: 91px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: nowrap; ">bad handshake<br />timeout</div></div></div></foreignObject><text x="91" y="304" fill="#000000" font-family="Helvetica" font-size="11px" text-anchor="middle">bad handshake...</text></switch></g><rect x="160" y="270.02" width="120" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 285px; margin-left: 161px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "Line truncated
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="800px" height="440px" viewBox="-0.5 -0.5 800 440" style="background-color: rgb(238, 243, 250);"><defs/><g><path d="M 170 365.02 L 170 385 L 290 385 L 290 371.39" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 290 366.14 L 293.5 373.14 L 290 371.39 L 286.5 373.14 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 110 350 L 40 350 L 40 130 L 103.63 130" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 108.88 130 L 101.88 133.5 L 103.63 130 L 101.88 126.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="110" y="335.02" width="100" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 350px; margin-left: 111px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">connected</div></div></div></foreignObject><text x="160" y="354" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">connected</text></switch></g><path d="M 290 335.02 L 290 315 L 180 315 L 180 328.65" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 180 333.9 L 176.5 326.9 L 180 328.65 L 183.5 326.9 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 350 335.02 L 350 135 L 216.37 135" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 211.12 135 L 218.12 131.5 L 216.37 135 L 218.12 138.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 320 335.02 L 320 235 L 216.37 235" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 211.12 235 L 218.12 231.5 L 216.37 235 L 218.12 238.5 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="270" y="335.02" width="100" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 98px; height: 1px; padding-top: 350px; margin-left: 271px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">resuming</div></div></div></foreignObject><text x="320" y="354" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">resuming</text></switch></g><path d="M 500 149.96 L 500 223.59" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 500 228.84 L 496.5 221.84 L 500 223.59 L 503.5 221.84 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><rect x="460" y="119.96" width="80" height="30" fill="#ffffff" stroke="#000000" stroke-width="2" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 78px; height: 1px; padding-top: 135px; margin-left: 461px;"><div style="box-sizing: border-box; font-size: 0; text-align: center; "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: #000000; line-height: 1.2; pointer-events: all; white-space: normal; word-wrap: normal; ">closed</div></div></div></foreignObject><text x="500" y="139" fill="#000000" font-family="Helvetica" font-size="12px" text-anchor="middle">closed</text></switch></g><path d="M 520 259.96 L 520 280 L 630 280 L 630 266.33" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 630 261.08 L 633.5 268.08 L 630 266.33 L 626.5 268.08 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="all"/><path d="M 480 229.96 L 480 190 L 480 156.33" fill="noneLine truncated
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -1,4 +0,0 @@
|
||||
for x in $(dirname $0)/*.dot; do
|
||||
echo $x
|
||||
dot $x -Tsvg -o ${x/.dot/.svg}
|
||||
done
|
||||
@@ -1,23 +0,0 @@
|
||||
digraph "Client Reconnection Flow" {
|
||||
node [shape=box];
|
||||
graph [newrank=true];
|
||||
|
||||
reconnect [label="Reconnect"];
|
||||
wait_hello [label="Wait for hello"];
|
||||
resume_session [label="Resume session"];
|
||||
new_session [label="New session" style="dashed"];
|
||||
wait_continue [label="Wait for continue"];
|
||||
connected [label="Connected"];
|
||||
lost_connection [label="Connection lost or\nunexpectedly closed\nwhile reconnecting" style="dotted"];
|
||||
abort [label="Abort"];
|
||||
|
||||
{ rank="same"; wait_continue; new_session };
|
||||
|
||||
lost_connection -> reconnect [label="Wait random delay"];
|
||||
reconnect -> wait_hello [label="Connect to /api/socket"];
|
||||
wait_hello -> resume_session [label="Receive hello"];
|
||||
resume_session -> wait_continue [label="Send resume"];
|
||||
wait_continue -> abort [label="Connection closed with 4003"];
|
||||
wait_continue -> new_session [label="Receive invalidate"];
|
||||
wait_continue -> connected [label="Receive ready"];
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.3 (0)
|
||||
-->
|
||||
<!-- Title: Client Reconnection Flow Pages: 1 -->
|
||||
<svg width="441pt" height="496pt"
|
||||
viewBox="0.00 0.00 441.00 496.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 492)">
|
||||
<title>Client Reconnection Flow</title>
|
||||
<polygon fill="white" stroke="transparent" points="-4,4 -4,-492 437,-492 437,4 -4,4"/>
|
||||
<!-- reconnect -->
|
||||
<g id="node1" class="node">
|
||||
<title>reconnect</title>
|
||||
<polygon fill="none" stroke="black" points="153.5,-384 62.5,-384 62.5,-348 153.5,-348 153.5,-384"/>
|
||||
<text text-anchor="middle" x="108" y="-362.3" font-family="Times,serif" font-size="14.00">Reconnect</text>
|
||||
</g>
|
||||
<!-- wait_hello -->
|
||||
<g id="node2" class="node">
|
||||
<title>wait_hello</title>
|
||||
<polygon fill="none" stroke="black" points="164.5,-297 51.5,-297 51.5,-261 164.5,-261 164.5,-297"/>
|
||||
<text text-anchor="middle" x="108" y="-275.3" font-family="Times,serif" font-size="14.00">Wait for hello</text>
|
||||
</g>
|
||||
<!-- reconnect->wait_hello -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>reconnect->wait_hello</title>
|
||||
<path fill="none" stroke="black" d="M108,-347.8C108,-336.16 108,-320.55 108,-307.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-307.18 108,-297.18 104.5,-307.18 111.5,-307.18"/>
|
||||
<text text-anchor="middle" x="187.5" y="-318.8" font-family="Times,serif" font-size="14.00">Connect to /api/socket</text>
|
||||
</g>
|
||||
<!-- resume_session -->
|
||||
<g id="node3" class="node">
|
||||
<title>resume_session</title>
|
||||
<polygon fill="none" stroke="black" points="173.5,-210 42.5,-210 42.5,-174 173.5,-174 173.5,-210"/>
|
||||
<text text-anchor="middle" x="108" y="-188.3" font-family="Times,serif" font-size="14.00">Resume session</text>
|
||||
</g>
|
||||
<!-- wait_hello->resume_session -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>wait_hello->resume_session</title>
|
||||
<path fill="none" stroke="black" d="M108,-260.8C108,-249.16 108,-233.55 108,-220.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-220.18 108,-210.18 104.5,-220.18 111.5,-220.18"/>
|
||||
<text text-anchor="middle" x="155.5" y="-231.8" font-family="Times,serif" font-size="14.00">Receive hello</text>
|
||||
</g>
|
||||
<!-- wait_continue -->
|
||||
<g id="node5" class="node">
|
||||
<title>wait_continue</title>
|
||||
<polygon fill="none" stroke="black" points="178,-123 38,-123 38,-87 178,-87 178,-123"/>
|
||||
<text text-anchor="middle" x="108" y="-101.3" font-family="Times,serif" font-size="14.00">Wait for continue</text>
|
||||
</g>
|
||||
<!-- resume_session->wait_continue -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>resume_session->wait_continue</title>
|
||||
<path fill="none" stroke="black" d="M108,-173.8C108,-162.16 108,-146.55 108,-133.24"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-133.18 108,-123.18 104.5,-133.18 111.5,-133.18"/>
|
||||
<text text-anchor="middle" x="155" y="-144.8" font-family="Times,serif" font-size="14.00">Send resume</text>
|
||||
</g>
|
||||
<!-- new_session -->
|
||||
<g id="node4" class="node">
|
||||
<title>new_session</title>
|
||||
<polygon fill="none" stroke="black" stroke-dasharray="5,2" points="433,-123 327,-123 327,-87 433,-87 433,-123"/>
|
||||
<text text-anchor="middle" x="380" y="-101.3" font-family="Times,serif" font-size="14.00">New session</text>
|
||||
</g>
|
||||
<!-- wait_continue->new_session -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>wait_continue->new_session</title>
|
||||
<path fill="none" stroke="black" d="M178.05,-105C220.94,-105 275.39,-105 316.94,-105"/>
|
||||
<polygon fill="black" stroke="black" points="316.94,-108.5 326.94,-105 316.94,-101.5 316.94,-108.5"/>
|
||||
<text text-anchor="middle" x="252.5" y="-111.8" font-family="Times,serif" font-size="14.00">Receive invalidate</text>
|
||||
</g>
|
||||
<!-- connected -->
|
||||
<g id="node6" class="node">
|
||||
<title>connected</title>
|
||||
<polygon fill="none" stroke="black" points="92,-36 0,-36 0,0 92,0 92,-36"/>
|
||||
<text text-anchor="middle" x="46" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
|
||||
</g>
|
||||
<!-- wait_continue->connected -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>wait_continue->connected</title>
|
||||
<path fill="none" stroke="black" d="M61.23,-86.99C53.95,-82.28 47.4,-76.37 43,-69 39.01,-62.33 38.18,-54.22 38.78,-46.49"/>
|
||||
<polygon fill="black" stroke="black" points="42.27,-46.77 40.32,-36.36 35.35,-45.72 42.27,-46.77"/>
|
||||
<text text-anchor="middle" x="93.5" y="-57.8" font-family="Times,serif" font-size="14.00">Receive ready</text>
|
||||
</g>
|
||||
<!-- abort -->
|
||||
<g id="node8" class="node">
|
||||
<title>abort</title>
|
||||
<polygon fill="none" stroke="black" points="198,-36 142,-36 142,0 198,0 198,-36"/>
|
||||
<text text-anchor="middle" x="170" y="-14.3" font-family="Times,serif" font-size="14.00">Abort</text>
|
||||
</g>
|
||||
<!-- wait_continue->abort -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>wait_continue->abort</title>
|
||||
<path fill="none" stroke="black" d="M131.28,-86.66C137.25,-81.45 143.3,-75.4 148,-69 153.19,-61.93 157.5,-53.45 160.88,-45.5"/>
|
||||
<polygon fill="black" stroke="black" points="164.14,-46.78 164.56,-36.19 157.63,-44.21 164.14,-46.78"/>
|
||||
<text text-anchor="middle" x="260.5" y="-57.8" font-family="Times,serif" font-size="14.00">Connection closed with 4003</text>
|
||||
</g>
|
||||
<!-- lost_connection -->
|
||||
<g id="node7" class="node">
|
||||
<title>lost_connection</title>
|
||||
<polygon fill="none" stroke="black" stroke-dasharray="1,5" points="188.5,-488 27.5,-488 27.5,-435 188.5,-435 188.5,-488"/>
|
||||
<text text-anchor="middle" x="108" y="-472.8" font-family="Times,serif" font-size="14.00">Connection lost or</text>
|
||||
<text text-anchor="middle" x="108" y="-457.8" font-family="Times,serif" font-size="14.00">unexpectedly closed</text>
|
||||
<text text-anchor="middle" x="108" y="-442.8" font-family="Times,serif" font-size="14.00">while reconnecting</text>
|
||||
</g>
|
||||
<!-- lost_connection->reconnect -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>lost_connection->reconnect</title>
|
||||
<path fill="none" stroke="black" d="M108,-434.95C108,-422.36 108,-407.17 108,-394.35"/>
|
||||
<polygon fill="black" stroke="black" points="111.5,-394.24 108,-384.24 104.5,-394.24 111.5,-394.24"/>
|
||||
<text text-anchor="middle" x="175.5" y="-405.8" font-family="Times,serif" font-size="14.00">Wait random delay</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -1,21 +0,0 @@
|
||||
digraph "Server Connection Flow" {
|
||||
node [shape=box];
|
||||
graph [newrank=true];
|
||||
|
||||
new_connection [label="New connection"];
|
||||
wait_handshake [label="Wait for handshake"];
|
||||
validate_register [label="Validate register"];
|
||||
validate_resume [label="Validate resume"];
|
||||
connected [label="Connected"];
|
||||
close [label="Close connection"];
|
||||
|
||||
new_connection -> wait_handshake [label="Send hello"];
|
||||
wait_handshake -> validate_register [label="Receive register_*"];
|
||||
wait_handshake -> validate_resume [label="Receive resume"];
|
||||
wait_handshake -> close [label="Bad handshake\nTimeout"];
|
||||
validate_register -> close [label="Bad token\nBad register"];
|
||||
validate_register -> connected [label="Valid register:\nSend ready"];
|
||||
validate_resume -> close [label="Bad token\nBad resume"];
|
||||
validate_resume -> wait_handshake [label="Bad session:\nsend invalidate"];
|
||||
validate_resume -> connected [label="Valid resume:\nSend continue"];
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.42.3 (0)
|
||||
-->
|
||||
<!-- Title: Server Connection Flow Pages: 1 -->
|
||||
<svg width="603pt" height="335pt"
|
||||
viewBox="0.00 0.00 603.00 335.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 331)">
|
||||
<title>Server Connection Flow</title>
|
||||
<polygon fill="white" stroke="transparent" points="-4,4 -4,-331 599,-331 599,4 -4,4"/>
|
||||
<!-- new_connection -->
|
||||
<g id="node1" class="node">
|
||||
<title>new_connection</title>
|
||||
<polygon fill="none" stroke="black" points="355.5,-327 224.5,-327 224.5,-291 355.5,-291 355.5,-327"/>
|
||||
<text text-anchor="middle" x="290" y="-305.3" font-family="Times,serif" font-size="14.00">New connection</text>
|
||||
</g>
|
||||
<!-- wait_handshake -->
|
||||
<g id="node2" class="node">
|
||||
<title>wait_handshake</title>
|
||||
<polygon fill="none" stroke="black" points="367.5,-240 212.5,-240 212.5,-204 367.5,-204 367.5,-240"/>
|
||||
<text text-anchor="middle" x="290" y="-218.3" font-family="Times,serif" font-size="14.00">Wait for handshake</text>
|
||||
</g>
|
||||
<!-- new_connection->wait_handshake -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>new_connection->wait_handshake</title>
|
||||
<path fill="none" stroke="black" d="M290,-290.8C290,-279.16 290,-263.55 290,-250.24"/>
|
||||
<polygon fill="black" stroke="black" points="293.5,-250.18 290,-240.18 286.5,-250.18 293.5,-250.18"/>
|
||||
<text text-anchor="middle" x="328" y="-261.8" font-family="Times,serif" font-size="14.00">Send hello</text>
|
||||
</g>
|
||||
<!-- validate_register -->
|
||||
<g id="node3" class="node">
|
||||
<title>validate_register</title>
|
||||
<polygon fill="none" stroke="black" points="136,-138 0,-138 0,-102 136,-102 136,-138"/>
|
||||
<text text-anchor="middle" x="68" y="-116.3" font-family="Times,serif" font-size="14.00">Validate register</text>
|
||||
</g>
|
||||
<!-- wait_handshake->validate_register -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>wait_handshake->validate_register</title>
|
||||
<path fill="none" stroke="black" d="M212.45,-216.73C161.73,-212.32 101.57,-203.5 83,-186 72.82,-176.41 68.91,-161.41 67.62,-148.23"/>
|
||||
<polygon fill="black" stroke="black" points="71.11,-147.99 67.07,-138.2 64.12,-148.37 71.11,-147.99"/>
|
||||
<text text-anchor="middle" x="148.5" y="-167.3" font-family="Times,serif" font-size="14.00">Receive register_*</text>
|
||||
</g>
|
||||
<!-- validate_resume -->
|
||||
<g id="node4" class="node">
|
||||
<title>validate_resume</title>
|
||||
<polygon fill="none" stroke="black" points="356,-138 224,-138 224,-102 356,-102 356,-138"/>
|
||||
<text text-anchor="middle" x="290" y="-116.3" font-family="Times,serif" font-size="14.00">Validate resume</text>
|
||||
</g>
|
||||
<!-- wait_handshake->validate_resume -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>wait_handshake->validate_resume</title>
|
||||
<path fill="none" stroke="black" d="M241.15,-203.81C233.91,-199.14 227.42,-193.3 223,-186 216.09,-174.6 216.09,-167.4 223,-156 225.69,-151.55 229.16,-147.65 233.09,-144.22"/>
|
||||
<polygon fill="black" stroke="black" points="235.23,-146.98 241.15,-138.19 231.04,-141.38 235.23,-146.98"/>
|
||||
<text text-anchor="middle" x="280" y="-167.3" font-family="Times,serif" font-size="14.00">Receive resume</text>
|
||||
</g>
|
||||
<!-- close -->
|
||||
<g id="node6" class="node">
|
||||
<title>close</title>
|
||||
<polygon fill="none" stroke="black" points="450.5,-36 313.5,-36 313.5,0 450.5,0 450.5,-36"/>
|
||||
<text text-anchor="middle" x="382" y="-14.3" font-family="Times,serif" font-size="14.00">Close connection</text>
|
||||
</g>
|
||||
<!-- wait_handshake->close -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>wait_handshake->close</title>
|
||||
<path fill="none" stroke="black" d="M367.54,-213.17C403.99,-207.82 442.06,-199.24 454,-186 493.43,-142.29 497.55,-103.05 465,-54 461.92,-49.36 458.09,-45.33 453.78,-41.84"/>
|
||||
<polygon fill="black" stroke="black" points="455.76,-38.95 445.55,-36.1 451.76,-44.69 455.76,-38.95"/>
|
||||
<text text-anchor="middle" x="540.5" y="-123.8" font-family="Times,serif" font-size="14.00">Bad handshake</text>
|
||||
<text text-anchor="middle" x="540.5" y="-108.8" font-family="Times,serif" font-size="14.00">Timeout</text>
|
||||
</g>
|
||||
<!-- connected -->
|
||||
<g id="node5" class="node">
|
||||
<title>connected</title>
|
||||
<polygon fill="none" stroke="black" points="127,-36 35,-36 35,0 127,0 127,-36"/>
|
||||
<text text-anchor="middle" x="81" y="-14.3" font-family="Times,serif" font-size="14.00">Connected</text>
|
||||
</g>
|
||||
<!-- validate_register->connected -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>validate_register->connected</title>
|
||||
<path fill="none" stroke="black" d="M34.78,-101.9C28.43,-97.02 22.64,-91.06 19,-84 12.9,-72.14 12.28,-65.51 19,-54 21.52,-49.69 24.78,-45.87 28.5,-42.49"/>
|
||||
<polygon fill="black" stroke="black" points="30.9,-45.05 36.66,-36.16 26.61,-39.52 30.9,-45.05"/>
|
||||
<text text-anchor="middle" x="70" y="-72.8" font-family="Times,serif" font-size="14.00">Valid register:</text>
|
||||
<text text-anchor="middle" x="70" y="-57.8" font-family="Times,serif" font-size="14.00">Send ready</text>
|
||||
</g>
|
||||
<!-- validate_register->close -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>validate_register->close</title>
|
||||
<path fill="none" stroke="black" d="M136,-111.34C178.72,-105.47 229.03,-96.29 247,-84 260.03,-75.09 253.43,-63.55 266,-54 277.12,-45.55 290.27,-39.13 303.66,-34.26"/>
|
||||
<polygon fill="black" stroke="black" points="305.06,-37.48 313.42,-30.97 302.83,-30.84 305.06,-37.48"/>
|
||||
<text text-anchor="middle" x="311" y="-72.8" font-family="Times,serif" font-size="14.00">Bad token</text>
|
||||
<text text-anchor="middle" x="311" y="-57.8" font-family="Times,serif" font-size="14.00">Bad register</text>
|
||||
</g>
|
||||
<!-- validate_resume->wait_handshake -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>validate_resume->wait_handshake</title>
|
||||
<path fill="none" stroke="black" d="M321.89,-138.33C327.98,-143.2 333.53,-149.09 337,-156 342.98,-167.92 342.98,-174.08 337,-186 335.05,-189.89 332.44,-193.45 329.44,-196.69"/>
|
||||
<polygon fill="black" stroke="black" points="326.86,-194.32 321.89,-203.67 331.61,-199.46 326.86,-194.32"/>
|
||||
<text text-anchor="middle" x="395.5" y="-174.8" font-family="Times,serif" font-size="14.00">Bad session:</text>
|
||||
<text text-anchor="middle" x="395.5" y="-159.8" font-family="Times,serif" font-size="14.00">send invalidate</text>
|
||||
</g>
|
||||
<!-- validate_resume->connected -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>validate_resume->connected</title>
|
||||
<path fill="none" stroke="black" d="M223.7,-112.81C197.28,-107.96 167.61,-99.33 144,-84 129.91,-74.85 132.49,-66.26 121,-54 117.46,-50.23 113.56,-46.43 109.61,-42.79"/>
|
||||
<polygon fill="black" stroke="black" points="111.86,-40.11 102.07,-36.06 107.2,-45.33 111.86,-40.11"/>
|
||||
<text text-anchor="middle" x="195.5" y="-72.8" font-family="Times,serif" font-size="14.00">Valid resume:</text>
|
||||
<text text-anchor="middle" x="195.5" y="-57.8" font-family="Times,serif" font-size="14.00">Send continue</text>
|
||||
</g>
|
||||
<!-- validate_resume->close -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>validate_resume->close</title>
|
||||
<path fill="none" stroke="black" d="M336.9,-101.98C345.46,-97.2 353.66,-91.27 360,-84 369.17,-73.47 374.56,-58.96 377.7,-46.32"/>
|
||||
<polygon fill="black" stroke="black" points="381.18,-46.78 379.85,-36.27 374.33,-45.32 381.18,-46.78"/>
|
||||
<text text-anchor="middle" x="418" y="-72.8" font-family="Times,serif" font-size="14.00">Bad token</text>
|
||||
<text text-anchor="middle" x="418" y="-57.8" font-family="Times,serif" font-size="14.00">Bad resume</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,58 +1,36 @@
|
||||
Communication Layer
|
||||
===================
|
||||
# Communication Layer
|
||||
|
||||
<sub>This document describes the communication layer in Clusterio and
|
||||
unless you're developing Clusterio it's probably not going to be very
|
||||
useful for you.</sub>
|
||||
<sub>This document describes the communication layer in Clusterio and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
||||
|
||||
Communication is facilitated by the lib/link library and is based on
|
||||
three core abstractions: links, connectors and messages. Links
|
||||
represents one side of a two way communication pipe, connectors deal
|
||||
with connecting and establishing communication, and messages are the
|
||||
content sent over the link.
|
||||
Communication is facilitated by the lib/link library and is based on three core abstractions: links, connectors and messages.
|
||||
Links represents one side of a two way communication pipe, connectors deal with connecting and establishing communication, and messages are the content sent over the link.
|
||||
|
||||
|
||||
Link
|
||||
----
|
||||
## Link
|
||||
|
||||
The Link class represents one side of a two way communication channel
|
||||
where the actual communication is done by a connector associated with
|
||||
the link. The link also knows what the endpoint types it's connected to
|
||||
are via the source and target attributes.
|
||||
The Link class represents one side of a two way communication channel where the actual communication is done by a connector associated with the link.
|
||||
The link also knows what the endpoint types it's connected to are via the source and target attributes.
|
||||
|
||||
The endpoint types currently implemented are master, slave, instance,
|
||||
and control. Although slave and instances run in the same Node.js
|
||||
program they use virtual links to communicate between them in order to
|
||||
simplify the communication architecture.
|
||||
The endpoint types currently implemented are master, slave, instance, and control.
|
||||
Although slave and instances run in the same Node.js program they use virtual links to communicate between them in order to simplify the communication architecture.
|
||||
|
||||
Upon creation the link will register with the connector in order to
|
||||
receive and process messages from it. The messages received are
|
||||
validated using the validator registered for the type of the message. A
|
||||
message for which there is no validator for is considered an error.
|
||||
Upon creation the link will register with the connector in order to receive and process messages from it.
|
||||
The messages received are validated using the validator registered for the type of the message.
|
||||
A message for which there is no validator for is considered an error.
|
||||
|
||||
|
||||
Connector
|
||||
---------
|
||||
## Connector
|
||||
|
||||
The connector used with a link is responsible for establishing and/or
|
||||
maintain the connection that messages are sent over. Reconnect logic is
|
||||
expected to be implemented in the connector, though this has not yet
|
||||
been done.
|
||||
The connector used with a link is responsible for establishing and/or maintain the connection that messages are sent over.
|
||||
Reconnect logic is expected to be implemented in the connector, though this has not yet been done.
|
||||
|
||||
There's also the virtual connector which lets two links in the same
|
||||
program be connected to each other without having to establish a
|
||||
network connection.
|
||||
There's also the virtual connector which lets two links in the same program be connected to each other without having to establish a network connection.
|
||||
|
||||
|
||||
Message
|
||||
-------
|
||||
## Message
|
||||
|
||||
There's two types of messages currently implemented apart from the
|
||||
Link builtin close message and that's Request and Event. The builtin
|
||||
messages are defined in [lib/link/messages.js](../lib/link/messages.js)
|
||||
and are attached to links via the attachAllMessages function, which is
|
||||
usally called in the Link subclass constructor.
|
||||
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
|
||||
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
|
||||
|
||||
The messages define which links they are valid on and only attaches
|
||||
handlers for those links. There's also a forwarding mechanism for when
|
||||
a request or event is to be forwarded another hop.
|
||||
The messages define which links they are valid on and only attaches handlers for those links.
|
||||
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
|
||||
@@ -1,45 +1,34 @@
|
||||
Configuration System
|
||||
====================
|
||||
# Configuration System
|
||||
|
||||
<sub>This document describes the implementation and inner working of the
|
||||
configuration system in Clusterio, and unless you're developing
|
||||
Clusterio it's probably not going to be very useful for you.</sub>
|
||||
<sub>This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
|
||||
|
||||
Clusterio uses a group based configuration system to manage settings for
|
||||
the master server, instances, slaves, and the clusterctl utility.
|
||||
Plugins get their own config group for the master server and instances
|
||||
which they can add their own fields to. The fields are defined early in
|
||||
the startup, and the groups and their fields for disabled plugins will
|
||||
still be created.
|
||||
Clusterio uses a group based configuration system to manage settings for the master server, instances, slaves, and the clusterioctl utility.
|
||||
Plugins get their own config group for the master server and instances which they can add their own fields to.
|
||||
The fields are defined early in the startup, and the groups and their fields for disabled plugins will still be created.
|
||||
|
||||
The built-in config groups and their fields are defined in
|
||||
[/lib/config/definitions.js](lib/config/definitions.js) while plugins
|
||||
can add their own config groups to the `MasterConfigGroup` and
|
||||
`InstanceConfigGroup` properties of the `info.js` export using
|
||||
subclasses of `PluginConfigGroup`. The plugin group has the enabled
|
||||
field pre-defined on it, and it's used to enable/disable plugins.
|
||||
The built-in config groups and their fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config groups to the `MasterConfigGroup` and `InstanceConfigGroup` properties of the `info.js` export using subclasses of `PluginConfigGroup`.
|
||||
The plugin group has the enabled field pre-defined on it, and it's used to enable/disable plugins.
|
||||
|
||||
|
||||
Defing Fields
|
||||
-------------
|
||||
## Defing Fields
|
||||
|
||||
Fields are defined using the `define` class method on group classes
|
||||
after the groupName have been set and before the class has been
|
||||
finalized. The `define` method takes an object as argument with the
|
||||
following properties:
|
||||
Fields are defined using the `define` class method on group classes after the groupName have been set and before the class has been finalized.
|
||||
The `define` method takes an object as argument with the following properties:
|
||||
|
||||
**access**:
|
||||
The locations this config field can be read and modified from.
|
||||
This is an array of strings which can contain the values `"master"`, `"slave"` and `"control"`.
|
||||
If a party is missing in this array then that party will not receive the value of this field when the config is shared, and will be unable to modify the field.
|
||||
Takes the value of `defaultAccess` set on the config group class if not passed.
|
||||
Note that instance config groups should have both `"master"` and `"slave"` for all of its fields or unexpected behaviour may occur.
|
||||
|
||||
**type**:
|
||||
The type of config value this field will support. The supported
|
||||
values are boolean, string, number, and object, and if an attept is
|
||||
made to set the field to a value that is not of the right type it
|
||||
will be reject unless it's a string that can be converted to the
|
||||
right type.
|
||||
The type of config value this field will support.
|
||||
The supported values are boolean, string, number, and object, and if an attept is made to set the field to a value that is not of the right type it will be reject unless it's a string that can be converted to the right type.
|
||||
|
||||
**name**:
|
||||
Name of the configuration field, this is the primary way the field
|
||||
is accessed, both in code and on the command line. As such it
|
||||
should follow the lower\_case\_underscore style and not contain any
|
||||
spaces or dots.
|
||||
Name of the configuration field, this is the primary way the field is accessed, both in code and on the command line.
|
||||
As such it should follow the lower\_case\_underscore style and not contain any spaces or dots.
|
||||
|
||||
**title** (optional):
|
||||
Text used to identify the config field in user interfaces.
|
||||
@@ -48,27 +37,24 @@ following properties:
|
||||
Text used to describe the config field in user interfaces.
|
||||
|
||||
**enum** (optional):
|
||||
Array of acceptable values this field can have. Attempts to set a
|
||||
value not in this array will be rejected, which makes it useful for
|
||||
making enumareted list of choices.
|
||||
Array of acceptable values this field can have.
|
||||
Attempts to set a value not in this array will be rejected, which makes it useful for making enumareted list of choices.
|
||||
|
||||
**optional** (optional):
|
||||
True if this field can be set to null. Defaults to false.
|
||||
True if this field can be set to null.
|
||||
Defaults to false.
|
||||
|
||||
**initial_value** (optional if optional is true):
|
||||
Value this config field should take in a newly initialized config.
|
||||
This can also be an async function returning the value to use.
|
||||
|
||||
If the value for a field stored in on disk somehow ends up being invalid
|
||||
the field will take on the initial\_value instead when the config is
|
||||
loaded.
|
||||
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
|
||||
|
||||
|
||||
Accessing Fields
|
||||
----------------
|
||||
## Accessing Fields
|
||||
|
||||
On the Config instance there's two ways to access fields: through the
|
||||
set and get methods using dot notation. E.g.:
|
||||
On the Config instance there's two ways to access fields: through the set and get methods using dot notation.
|
||||
E.g.:
|
||||
|
||||
let value = configInstance.get("group.field");
|
||||
|
||||
@@ -77,26 +63,16 @@ E.g.:
|
||||
|
||||
let value = configInstance.group("group").get("field")
|
||||
|
||||
The set method takes an extra argument, which is the value to set the
|
||||
field to. If the value is not valid this will throw an InvalidValue
|
||||
exception, if the group or field doesn't exist it will throw an
|
||||
InvalidField exception.
|
||||
The set method takes an extra argument, which is the value to set the field to.
|
||||
If the value is not valid this will throw an InvalidValue exception, if the group or field doesn't exist it will throw an InvalidField exception.
|
||||
|
||||
|
||||
Config Lifecycle
|
||||
----------------
|
||||
## Config Lifecycle
|
||||
|
||||
Values for all config fields are created when the config is initialized,
|
||||
as well as for any fields with missing values when a config is loaded.
|
||||
Once a field has a value it is never automatically removed, even if the
|
||||
field or group it was part of no longer exists. This ensures that
|
||||
plugin configuration is not lost should the server be started up without
|
||||
the plugins that defined the configuration installed.
|
||||
Values for all config fields are created when the config is initialized, as well as for any fields with missing values when a config is loaded.
|
||||
Once a field has a value it is never automatically removed, even if the field or group it was part of no longer exists.
|
||||
This ensures that plugin configuration is not lost should the server be started up without the plugins that defined the configuration installed.
|
||||
|
||||
For instance configs, they are kept on the master server and serialized
|
||||
and sent over to the relevant slaves on startup. This makes it possible
|
||||
to edit instance configs when the slave the instance is on is offline.
|
||||
A copy is also stored in the instance directory on the slave, and this
|
||||
config will be deliverd to the master server on slave startup, which
|
||||
enables instances to be copied between clusters as long as their IDs are
|
||||
unique.
|
||||
For instance configs, they are kept on the master server and serialized and sent over to the relevant slaves on startup.
|
||||
This makes it possible to edit instance configs when the slave the instance is on is offline.
|
||||
A copy is also stored in the instance directory on the slave, and this config will be deliverd to the master server on slave startup, which enables instances to be copied between clusters as long as their IDs are unique.
|
||||
@@ -0,0 +1,421 @@
|
||||
# Configuration
|
||||
|
||||
|
||||
## Master Configuration
|
||||
|
||||
### master.name
|
||||
|
||||
Name of the cluster.
|
||||
Used to distinguish it from other clusters and is prepended to the name of servers in the server list.
|
||||
|
||||
Defaluts to "Your Cluster".
|
||||
|
||||
|
||||
### master.database_directory
|
||||
|
||||
Directory used to store cluster data to.
|
||||
Needs to be writeable by the master server.
|
||||
This is also used by plugins to store its data.
|
||||
|
||||
Defaults to "database".
|
||||
|
||||
|
||||
### master.http_port
|
||||
|
||||
Port to host HTTP server on.
|
||||
If set to null no HTTP server will be exposed.
|
||||
At least one of this and master.https_port needs to be set to a port.
|
||||
|
||||
Defaults to 8080.
|
||||
|
||||
|
||||
### master.https_port
|
||||
|
||||
Port to host HTTPS server on.
|
||||
Uses master.tls_certificate and master.tls_private_key as the certificate and private key for the HTTPS server.
|
||||
If set to null no HTTPS server will be exposed.
|
||||
At least one of this and master.http_port needs to be set to a port.
|
||||
|
||||
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### master.bind_address
|
||||
|
||||
IP address to bind the HTTP and HTTPS ports on.
|
||||
Useful to limit which interface to accept connections from.
|
||||
If set to null the unspecified IPv6 address will be used, which on most systems means it will also listen on the unspecified IPv4 address and accept connections from all
|
||||
interfaces.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### master.external_address
|
||||
|
||||
External address the master server is accessible on.
|
||||
Currently only used for `clusteriomaster bootstrap create-ctl-config` in order to give the right url to connect to.
|
||||
This should be a full URL ending with a /.
|
||||
|
||||
Defaults to null meaning assume localhost.
|
||||
|
||||
|
||||
### master.tls_certificate
|
||||
|
||||
Path to TLS certificate to use for the HTTPS server when master.https_port is configured.
|
||||
|
||||
Defaults to "database/certificates/cert.crt".
|
||||
|
||||
|
||||
### master.tls_private_key
|
||||
|
||||
Path to TLS private key to use for the HTTPS server when master.https_port is configured.
|
||||
|
||||
Defaults to "database/certificates/cert.key".
|
||||
|
||||
|
||||
### master.auth_secret
|
||||
|
||||
Base64 encoded authentication secret used to verify tokens issued to slaves, users, and sessions.
|
||||
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
|
||||
Keep this secret if leaked an attacker could easily compromise the cluster.
|
||||
|
||||
Defaults to null which means generate a secure secret on startup.
|
||||
|
||||
|
||||
### master.heartbeat_interval
|
||||
|
||||
Interval in seconds heartbeats are sent out at for WebSocket connections.
|
||||
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
|
||||
A lower value means less time between connections going stale and them being closed.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### master.session_timeout
|
||||
|
||||
Time in seconds from the connection is closed following a heartbeat timeout until the session is invalidated and data loss occurs.
|
||||
A higher timeout gives more time for a client to recconect and resume an active session over a connection that was dropped, but also leads to stale connections taking longer to clear out.
|
||||
In the case of the master or client crashing the data loss is unavoidable.
|
||||
|
||||
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `master.heartbeat_interval`.
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### master.metrics_timeout
|
||||
|
||||
Timeout in seconds before a call to gather metrics from a slave times out.
|
||||
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
|
||||
|
||||
Defaults to 8.
|
||||
|
||||
|
||||
### master.stream_timeout
|
||||
|
||||
Timeout in seconds for proxy streams through the master that has been created but is not yet sending data to be automatically closed.
|
||||
Streams that have started sending data before this timeout is not affected.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### master.default_role_id
|
||||
|
||||
Role to automatically grant to new users.
|
||||
If null no role is granted.
|
||||
|
||||
Defaults to 1 which correspond to the default Player role.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the plugin on the master server.
|
||||
Plugins not loaded on the master server will not be loaded on instances.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Slave Configuration
|
||||
|
||||
### slave.name
|
||||
|
||||
Name of the slave.
|
||||
Shows up in slave lists and is used to reference this slave in the clusterioctl command line interface.
|
||||
|
||||
Defaults to "New Slave".
|
||||
|
||||
|
||||
### slave.id
|
||||
|
||||
Immutable numeric id of the slave which uniquely identifies this slave in the cluster.
|
||||
|
||||
Defaults to a random 31 bit number.
|
||||
|
||||
|
||||
### slave.factorio_directory
|
||||
|
||||
Directory to look for the Factorio server(s) in.
|
||||
This can either point to a Factorio server directory, or to a directory containing multiple versions of the Factorio server.
|
||||
|
||||
Defaults to "factorio".
|
||||
|
||||
|
||||
### slave.instances_directory
|
||||
|
||||
Directory to store instances in.
|
||||
One sub-directory will be created in it for each instance assigned to this slave and these sub-directories will contain logs, saves, and various auto generated configuration files for the instances.
|
||||
|
||||
Defaults to "instances".
|
||||
|
||||
|
||||
### slave.master_url
|
||||
|
||||
URL to connect to the master server to.
|
||||
|
||||
Defaults to "http://localhost:8080/".
|
||||
|
||||
|
||||
### slave.master_token
|
||||
|
||||
Access token used for authenticating with the master server.
|
||||
You can generate an access token with `clusterioctl slave generate-token --id <slave-id>`, or use the `clusterioctl slave create-config` to create a new slave config with the correct url and token in it.
|
||||
|
||||
Defaults to "enter token here".
|
||||
|
||||
|
||||
### slave.tls_ca
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the master server.
|
||||
If you have a self signed certificate on the master server you will need to copy the certificate to your slaves and set it as the certificate authority with this option.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### slave.public_address
|
||||
|
||||
External address instances hosted on this slave can be accessed on.
|
||||
This is used by plugins like Server Select to give the correct address to switch between instances in-game.
|
||||
|
||||
Defaults to "localhost".
|
||||
|
||||
|
||||
### slave.max_reconnect_delay
|
||||
|
||||
Maximum delay in seconds to wait after connection to the master server is dropped before attempting to reconnect to it.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
## Instance Configuration
|
||||
|
||||
### instance.name
|
||||
|
||||
Name of the instance, shown in instance lists and used to reference to this instance in the clusterioctl command line interface.
|
||||
|
||||
Defaults to "New Instance".
|
||||
|
||||
|
||||
### instance.id
|
||||
|
||||
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
|
||||
|
||||
Defaluts to a random 31 bit integer.
|
||||
|
||||
|
||||
### instance.assigned_slave
|
||||
|
||||
Slave this instance is assigned to.
|
||||
To change this you need to use the separate assign instance interface.
|
||||
After being assigned to a slave the directory for the instance is created on the slave.
|
||||
|
||||
Defaults to null meaning not assigned.
|
||||
|
||||
|
||||
### instance.auto_start
|
||||
|
||||
If enabled start this instance when the slave it is assigned to is started up.
|
||||
Does not affect start or stop of the instance while the slave is running.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.version
|
||||
|
||||
Version of Factorio to use for this instance.
|
||||
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the slave.
|
||||
|
||||
Defaults to "latest".
|
||||
|
||||
|
||||
### factorio.game_port
|
||||
|
||||
UDP port to run the Factorio server on.
|
||||
If null a random port in the dynamyc range assigned each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.rcon_port
|
||||
|
||||
TCP port to start the RCON interface on the Factorio server on.
|
||||
If null a random port in the dynamic range is assigned each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.rcon_password
|
||||
|
||||
Password to use for the RCON interafec.
|
||||
If null a random secure password is generated and used each time the instance starts.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### player_online_autosave_slots
|
||||
|
||||
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
|
||||
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
|
||||
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
|
||||
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
|
||||
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
|
||||
|
||||
If 0 no autosave renaming takes place.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
|
||||
### factorio.enable_save_patching
|
||||
|
||||
Whether to use save patching or not.
|
||||
When enabled lua code is patched into the game save before starting it.
|
||||
Most plugins require the use of save patching to function.
|
||||
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.enable_whitelist
|
||||
|
||||
Turn on the whitelist on the server.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.settings
|
||||
|
||||
Object with the settings to put into `server-settings.json` for the Factorio server.
|
||||
The settings in `data/server-settings.example.json` of the Factorio installation is used as the base and properties specified here overrides the properties there.
|
||||
|
||||
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
|
||||
|
||||
Defaults to {"tags":["clusterio"],"auto_pause":false}.
|
||||
|
||||
|
||||
### factorio.verbose_logging
|
||||
|
||||
Pass the `--verbose` flag to Factorio when starting the server.
|
||||
This prints more messages to the log.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.strip_paths
|
||||
|
||||
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
|
||||
This improves the signal to noise ratio of the log.
|
||||
|
||||
Sample output when disabled:
|
||||
|
||||
0.487 Loading map /srv/clusterio/instances/test/saves/world.zip: 2615888 bytes.
|
||||
0.614 Checksum for script /srv/clusterio/instances/test/temp/currently-playing/control.lua: 2390553941
|
||||
1.277 Script @/srv/clusterio/instances/test/temp/currently-playing/modules/example/test.lua:7: Example log line.
|
||||
|
||||
Sample output when enabled:
|
||||
|
||||
0.487 Loading map saves/world.zip: 2615888 bytes.
|
||||
0.614 Checksum for script temp/currently-playing/control.lua: 2390553941
|
||||
1.277 Script @modules/example/test.lua:7: Example log line.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
### factorio.sync_adminlist
|
||||
|
||||
Synchronize in-game admin list with admin status of the users in cluster.
|
||||
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.sync_whitelist
|
||||
|
||||
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
|
||||
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.sync_banlist
|
||||
|
||||
Synchronize in-game banlist with the banned status of users in the cluster.
|
||||
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.max_concurrent_commands
|
||||
|
||||
Maximum number of commands being transmitted into the game in parallel.
|
||||
Since the rate at which long commands are streamed into the game is by default limeted to 100 bytes/tick and scales down to 25 bytes/tick when there's 20 players connected, long commands can hold up the command interface and cause large latencies in getting data pushed into the game.
|
||||
(The rates are controlled by the `segment_size` options in the server settings.)
|
||||
To counteract this Clusterio sends up to this configured value number of commands in parallel over the RCON interface.
|
||||
This causes the game updates sent to clients to have this many command streams in parallel contained in them, drastically increasing the maxiumum size they can become.
|
||||
Since commands are only processed so ofter the maximum rate of commands that can be sustained is roughly 30 times this configured value per second, though note that large commands will lower this value.
|
||||
|
||||
The game updates sent by the server is split into roughly 500 byte packets, and if any one of those parts are lost the entire update is resent.
|
||||
This means a high number of concurrent commands can amplify resends due to packet loss and degrade the player experience up to the point of becomming completely unplayable.
|
||||
|
||||
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
|
||||
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the given plugin on the instance.
|
||||
Note that plugins need to be loaded on the master server in order for them to be loaded on instances.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Control Configuration
|
||||
|
||||
### control.master_url
|
||||
|
||||
URL to connect to the master server to.
|
||||
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### control.master_token
|
||||
|
||||
Access token used for authenticating with the master server.
|
||||
You can generate an access token with `clusteriomaster bootstrap generate-user-token <username>`, or use the `clusteriomaster bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
|
||||
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### control.tls_ca
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the master server.
|
||||
If you have a self signed certificate on the master server you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### control.max_reconnect_delay
|
||||
|
||||
Max duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
|
||||
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
|
||||
|
||||
Defaults to 60.
|
||||
@@ -1,16 +1,16 @@
|
||||
Contributing to Clusterio
|
||||
=========================
|
||||
# Contributing to Clusterio
|
||||
|
||||
You are probably reading this because you want to contribute code to
|
||||
Clusterio. Great! We need people like you. Though we have some
|
||||
conventions and workflows that we kindly ask you to follow.
|
||||
You are probably reading this because you want to contribute code to Clusterio.
|
||||
Great! We need people like you.
|
||||
Though we have some conventions and workflows that we kindly ask you to follow.
|
||||
|
||||
|
||||
Contents
|
||||
--------
|
||||
## Contents
|
||||
|
||||
- [Workflow](#workflow)
|
||||
- [Starting a Feature Branch](#starting-a-feature-branch)
|
||||
- [Setting up the Development Environment](#setting-up-the-development-environment)
|
||||
- [Checking your code for errors](#checking-your-code-for-errors)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [Editing your Feature Branch](#editing-your-feature-branch)
|
||||
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
|
||||
@@ -18,195 +18,221 @@ Contents
|
||||
- [Changelog](#changelog)
|
||||
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
|
||||
- [Code Style](#code-style)
|
||||
- [Naming Style](#naming-style)
|
||||
- [Indenting](#indenting)
|
||||
- [Line Length](#line-length)
|
||||
- [Markdown](#markdown)
|
||||
- [Pushing releases to npm](#pushing-releases-to-npm)
|
||||
|
||||
|
||||
Workflow
|
||||
--------
|
||||
## Workflow
|
||||
|
||||
While we don't require you to follow this workflow, it makes things much
|
||||
simpler for both you and us if you choose to do so. Especially when it
|
||||
comes to making the commit history reflect what happened and not be a
|
||||
giant ball of mess. GitHub's git workflow is less than ideal for
|
||||
dealing with small feature-branches so you might see things being done
|
||||
here a little diffrent than the usual.
|
||||
While we don't require you to follow this workflow, it makes things much simpler for both you and us if you choose to do so.
|
||||
Especially when it comes to making the commit history reflect what happened and not be a giant ball of mess.
|
||||
GitHub's git workflow is less than ideal for dealing with small feature-branches so you might see things being done here a little diffrent than the usual.
|
||||
|
||||
It is recommended that you use the command line git version for doing
|
||||
the operations described here. GUI wrappers tend to have different
|
||||
ideas of what is what and you might find it difficult to make it do the
|
||||
things described here. For doing the actual commits though, your
|
||||
faviorit git GUI should work just fine.
|
||||
It is recommended that you use the command line git version for doing the operations described here.
|
||||
GUI wrappers tend to have different ideas of what is what and you might find it difficult to make it do the things described here.
|
||||
For doing the actual commits though, your faviorit git GUI should work just fine.
|
||||
|
||||
If you pressed the fork button on GitHub and used `git clone` _on your
|
||||
own fork_ you can skip to the next paragraph. Otherwise you will have
|
||||
to click the fork button and clone your fork. If you already cloned the
|
||||
main GitHub repository you can change your clone to point at your own
|
||||
fork using the `git remote` command, but it's probably easier to delete
|
||||
your local clone and clone it again.
|
||||
If you pressed the fork button on GitHub and used `git clone` _on your own fork_ you can skip to the next paragraph.
|
||||
Otherwise you will have to click the fork button and clone your fork.
|
||||
If you already cloned the main GitHub repository you can change your clone to point at your own fork using the `git remote` command, but it's probably easier to delete your local clone and clone it again.
|
||||
|
||||
To keep your local repository up to to date with the main repository add
|
||||
the main repository as a remote.
|
||||
To keep your local repository up to to date with the main repository add the main repository as a remote.
|
||||
|
||||
git remote add upstream https://github.com/clusterio/factorioClusterio
|
||||
git remote add upstream https://github.com/clusterio/clusterio
|
||||
|
||||
After this running `git fetch upstream` will get the updated branches
|
||||
from the main repository. Do not run `git pull upstream` it will mess
|
||||
thing up!
|
||||
After this running `git fetch upstream` will get the updated branches from the main repository.
|
||||
Do not run `git pull upstream` it will mess thing up!
|
||||
|
||||
|
||||
### Setting up the Development Environment
|
||||
|
||||
The project is organized into several packages that are all tied together using pnpm's workspace feature.
|
||||
In order to get the development environment up and running you will need to run the following command:
|
||||
|
||||
pnpm install
|
||||
|
||||
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
|
||||
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
|
||||
|
||||
node packages/create --dev
|
||||
|
||||
To add plugins to use in the development environment use
|
||||
|
||||
node packages/ctl plugin add ./path/to/plugin
|
||||
|
||||
It's important that the path starts with `./`.
|
||||
The `plugins/` directory contains the core plugins for the project and can be added using the above mentioned command.
|
||||
|
||||
The documentation uses the binaries provided by the packages, but for the development environment you will need to call the packages directly:
|
||||
|
||||
npx clusteriomaster -> node packages/master
|
||||
npx clusterioslave -> node packages/slave
|
||||
npx clusterioctl -> node packages/ctl
|
||||
|
||||
Note that to use `clusterioctl` you will have to create a control config first:
|
||||
|
||||
node packages/master bootstrap create-ctl-config [admin-user]
|
||||
|
||||
Once you've set up the cluster you can use `node packages/master run` to run the master server and `node packages/slave run` to run the slave which connects to the master server.
|
||||
|
||||
For web development on the master there are also the following flags:
|
||||
|
||||
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
|
||||
|
||||
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
|
||||
|
||||
These options can be combined and multiple plugins can be actived for dev mode.
|
||||
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
|
||||
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm run -r --if-present prepare` before you start the master without these flags again.
|
||||
|
||||
|
||||
### Starting a Feature Branch
|
||||
|
||||
Development is done exclusively in feature branches. This means the
|
||||
first thing to do before starting any development at all is to make a
|
||||
new branch starting from the master or 1.2.x branch of the main
|
||||
repository. To do this check out that branch from the main repository
|
||||
(substitute master for 1.2.x if you're working on that branch.)
|
||||
Development is done exclusively in feature branches.
|
||||
This means the first thing to do before starting any development at all is to make a new branch starting from the master branch of the main repository.
|
||||
To do this check out that branch from the main repository.
|
||||
|
||||
git fetch upstream
|
||||
git checkout upstream/master
|
||||
|
||||
This will give a warning about detached HEAD which means you did it
|
||||
correctly. Now you can make a feature branch based on this with.
|
||||
This will give a warning about detached HEAD which means you did it correctly.
|
||||
Now you can make a feature branch based on this with.
|
||||
|
||||
git checkout -b my-feature-branch
|
||||
|
||||
The name of the branch is not important, as long as it's not a name
|
||||
you've used before. But it's nice to have its name be somewhat
|
||||
descriptive of what it implements.
|
||||
The name of the branch is not important, as long as it's not a name you've used before.
|
||||
But it's nice to have its name be somewhat descriptive of what it implements.
|
||||
|
||||
|
||||
### Checking your code for errors
|
||||
|
||||
There's automated tests and linting setup for the project.
|
||||
Please do add tests for any code you add and run both:
|
||||
|
||||
pnpm run test
|
||||
pnpm run lint
|
||||
|
||||
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
|
||||
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
Once you've commited changes to your feature branch that you want to
|
||||
have included into the main repository push it to you fork with
|
||||
Once you've commited changes to your feature branch that you want to have included into the main repository push it to you fork with
|
||||
|
||||
git push origin my-feature-branch
|
||||
|
||||
and make a pull request through the GitHub web interface. You can also
|
||||
pass the `--set-upstream` (or `-u`) flag to make `git push` do the same
|
||||
thing for this branch. After your feature branch have been merged you
|
||||
should consider it final. If there are more changes you want to have
|
||||
merged or mistakes you need to fix you must start a new feature branch
|
||||
and submit a new pull request
|
||||
and make a pull request through the GitHub web interface.
|
||||
You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the same thing for this branch.
|
||||
After your feature branch have been merged you should consider it final.
|
||||
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
|
||||
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
Until your feature branch is merged you can edit it as you see fit by
|
||||
using the rebase tool. If your feature branch has been merged however
|
||||
it's final and you will have to start a new feature branch and submit
|
||||
that in a new pull request. The base usage of rebase assuming you are
|
||||
checked out on your feature branch is the following command (substitute
|
||||
master with 1.2.x if your feature branch is based on that)
|
||||
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
|
||||
If your feature branch has been merged however it's final and you will have to start a new feature branch and submit that in a new pull request.
|
||||
The base usage of rebase assuming you are checked out on your feature branch is the following command
|
||||
|
||||
git rebase -i upstream/master
|
||||
|
||||
This will open up an editor with the commits of your feature branch and
|
||||
let you reorder, edit, and remove commits as you see fit.
|
||||
This will open up an editor with the commits of your feature branch and let you reorder, edit, and remove commits as you see fit.
|
||||
|
||||
If the main repository has been updated and you have fetched down those
|
||||
updates with `git fetch upstream` this will also move your feature
|
||||
branch so that it is based on the latest commit from the main
|
||||
repository.
|
||||
If the main repository has been updated and you have fetched down those updates with `git fetch upstream` this will also move your feature branch so that it is based on the latest commit from the main repository.
|
||||
|
||||
If you have already pushed your feature branch to your fork a simple
|
||||
`git push origin my-feature-branch` will no longer work as the histories
|
||||
are different, instead you will have to run
|
||||
If you have already pushed your feature branch to your fork a simple `git push origin my-feature-branch` will no longer work as the histories are different, instead you will have to run
|
||||
|
||||
git push origin +my-feature-branch
|
||||
|
||||
The + sign signifies a force push and should be used with care. If you
|
||||
have a pull request that has not yet been merged submitted for this
|
||||
feature branch this will update the pull request too.
|
||||
The + sign signifies a force push and should be used with care.
|
||||
If you have a pull request that has not yet been merged submitted for this feature branch this will update the pull request too.
|
||||
|
||||
|
||||
### Resolving Merge Conflicts
|
||||
|
||||
The best way to resolve merge conflicts in feature braches is to rebase
|
||||
it on top of the updated upstream. Run the following commands
|
||||
(substitute master with 1.2.x if your feature branch is based on that)
|
||||
The best way to resolve merge conflicts in feature braches is to rebase it on top of the updated upstream.
|
||||
Run the following commands
|
||||
|
||||
git fetch upstream
|
||||
git rebase upstream/master
|
||||
|
||||
This will stop on the commit(s) that caused the merge conflict and let
|
||||
you resolve them manually before continuing. Once you're done force
|
||||
push your updated feature branch with
|
||||
This will stop on the commit(s) that caused the merge conflict and let you resolve them manually before continuing.
|
||||
Once you're done force push your updated feature branch with
|
||||
|
||||
git push origin +my-feature-branch
|
||||
|
||||
|
||||
### Updating Your GitHub Fork
|
||||
|
||||
No. This is pointless busywork that accomplishes nothing. Fetch the
|
||||
main repository directly as described above and base your feature
|
||||
branches on the refs from it. GitHub has no interface or functionality
|
||||
for keeping the branches in your fork up to date with the branches in
|
||||
the main repository and there is no point in doing this for our feature
|
||||
branch based workflow.
|
||||
No.
|
||||
This is pointless busywork that accomplishes nothing.
|
||||
Fetch the main repository directly as described above and base your feature branches on the refs from it.
|
||||
GitHub has no interface or functionality for keeping the branches in your fork up to date with the branches in the main repository and there is no point in doing this for our feature branch based workflow.
|
||||
|
||||
|
||||
Changelog
|
||||
---------
|
||||
## Changelog
|
||||
|
||||
There's a [Changelog](../CHANGELOG.md) in the root of the project. If
|
||||
you make changes that are visible to users of this project you should
|
||||
add an entry to the changelog describing what has changed. The format
|
||||
of this log should be self explanatory, please follow it carefully.
|
||||
There's a [Changelog](../CHANGELOG.md) in the root of the project.
|
||||
If you make changes that are visible to users of this project you should add an entry to the changelog describing what has changed.
|
||||
The format of this log should be self explanatory, please follow it carefully.
|
||||
|
||||
|
||||
Supported Node.js and Factorio Versions
|
||||
---------------------------------------
|
||||
## Supported Node.js and Factorio Versions
|
||||
|
||||
Clusterio run on Node.js v12 and up, v11.13.0+ and v10.16.0+, make sure
|
||||
to check that the Node builtin API's and npm packages you use are
|
||||
supported on these versions. The Travis CI tests are runned on whatever
|
||||
version of Node.js v10 is the default, which at the time of writing is
|
||||
v10.18.0.
|
||||
Clusterio run on Node.js v12 and up, make sure to check that the Node builtin API's and npm packages you use are supported on these versions.
|
||||
The GitHub Actions tests are run on whatever version of Node.js v12, v14, and v16 is the default.
|
||||
|
||||
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up,
|
||||
including the latest experimental release. It's recommended that you
|
||||
use the lua API reference for 0.17.69, as there's no information on what
|
||||
version Factorio API's were introduced in. The Travis CI tests are
|
||||
runned against latest experimental release.
|
||||
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
|
||||
It's recommended that you use the lua API reference for 0.17.69, as there's no information on what version Factorio API's were introduced in.
|
||||
The GitHub Actions tests are run against the latest experimental release.
|
||||
|
||||
|
||||
Code Style
|
||||
----------
|
||||
## Code Style
|
||||
|
||||
The style of the code is a bit of a mixed bag at the moment. But there
|
||||
are at least a few things that have been agreed upon.
|
||||
The style of the code is a bit of a mixed bag at the moment.
|
||||
But there are at least a few things that have been agreed upon.
|
||||
|
||||
|
||||
### Naming style
|
||||
|
||||
JavaScript variables in general uses camelCase.
|
||||
The exception is for classes/constructors which use PascalCase and variables destructured from over the wire objects.
|
||||
|
||||
Fields in configs and messages sent over links uses lowercase_underscore.
|
||||
|
||||
JavaScript files are named using lowercase_underscore for files that export multiple items.
|
||||
Files containing and exporting a single class should be named the same as the class in PascalCase.
|
||||
|
||||
Imported modules from the lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
|
||||
E.g.:
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
|
||||
|
||||
For lua code lowercase_underscore is used for everything.
|
||||
|
||||
|
||||
### Strings
|
||||
|
||||
JavaScript and Lua code should use double quoted strings (`"string"`)
|
||||
for all strings with the only exception that strings that contain double
|
||||
quotes may use single quotes (i.e., `'String with "quotes"'`.) For
|
||||
JavaScript and Lua code should use double quoted strings (`"string"`) for all strings with the only exception that strings that contain double quotes may use single quotes (i.e., `'String with "quotes"'`).
|
||||
JavaScript template literals may also be used where appropriate.
|
||||
|
||||
There are places in the codebase that are using `'single'` quoted
|
||||
strings. Should you be modifying such code use double quotes on new and
|
||||
modified lines and leave the rest untouched.
|
||||
|
||||
|
||||
### Indenting
|
||||
|
||||
JavaScript code should be indented with tabs. There are places in the
|
||||
codebase that are mixing tabs and spaces or uses only spaces for
|
||||
indents. Should you be modifiying such code that is mixing spaces and
|
||||
tabs as indending you should update the indenting to use tabs _on the
|
||||
code you change_ while leaving the rest of the code untouched.
|
||||
JavaScript and Lua code should be indented with tabs.
|
||||
|
||||
Lua code should be indented with 4 spaces.
|
||||
Other files types should prefer tabs for indents where reasonable with the exception of Markdown.
|
||||
|
||||
|
||||
### Line Length
|
||||
|
||||
Lines are limited to 120 characters where tabs count as 4 characters.
|
||||
If the argument list for a function or expressino for compound statement
|
||||
gets very long break them out on their own line(s). For example:
|
||||
If the argument list for a function or expressino for compound statement gets very long break them out on their own line(s).
|
||||
For example:
|
||||
|
||||
function foo(
|
||||
really, long, argument, list,
|
||||
@@ -219,3 +245,61 @@ gets very long break them out on their own line(s). For example:
|
||||
// code
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
### Markdown
|
||||
|
||||
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
|
||||
Headings have two spaces before them and on space after them to make them stand out, the heading at the top of the file does not have any spaces before it, and heading immediadly followed by another heading have one space between them.
|
||||
For example:
|
||||
|
||||
```md
|
||||
# A markdown File
|
||||
|
||||
Sentence of text which end in a period.
|
||||
Each sentence is on it's own line.
|
||||
And there's two spaces before the next heading.
|
||||
|
||||
|
||||
## Subheading
|
||||
|
||||
### Sub-subheading
|
||||
|
||||
Headings use the `# heading` format.
|
||||
```
|
||||
|
||||
|
||||
## Pushing releases to npm
|
||||
|
||||
***Warning: This workflow is outdated due to lerna having been removed from the project.****
|
||||
|
||||
The releases are done by creating a new release tag with lerna and then uploading that with lerna.
|
||||
You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
|
||||
|
||||
1. To do a release switch to the master branch and make sure it's up to date with the repository before proceeding.
|
||||
|
||||
```sh
|
||||
git switch master
|
||||
git pull --ff-only upstream master
|
||||
```
|
||||
|
||||
Check and make sure there are no untracked files with `git status`, otherwise they will be auto commited!
|
||||
|
||||
2. Run a lerna version to create and tag a new version.
|
||||
This will push a commit to the master branch.
|
||||
|
||||
```sh
|
||||
npx lerna version prerelease --git-remote upstream --no-granular-pathspec
|
||||
```
|
||||
|
||||
3. Run a full build in every package to update the dist/ files.
|
||||
|
||||
```sh
|
||||
npx lerna run build
|
||||
```
|
||||
|
||||
4. Publish the packages with lerna.
|
||||
|
||||
```sh
|
||||
npx lerna publish from-package
|
||||
```
|
||||
@@ -1,13 +1,9 @@
|
||||
Developing for Clusterio
|
||||
========================
|
||||
# Developing for Clusterio
|
||||
|
||||
This document describes the various different types of content that can
|
||||
be created for Factorio and/or Clusterio and how this is made compatible
|
||||
with Clusterio.
|
||||
This document describes the various different types of content that can be created for Factorio and/or Clusterio and how this is made compatible with Clusterio.
|
||||
|
||||
|
||||
Contents
|
||||
--------
|
||||
## Contents
|
||||
|
||||
- [Factorio Mods](#factorio-mods)
|
||||
- [Factorio Scenarios](#factorio-scenarios)
|
||||
@@ -18,35 +14,21 @@ Contents
|
||||
- [Clusterio Plugins](#clusterio-plugins)
|
||||
|
||||
|
||||
Factorio Mods
|
||||
-------------
|
||||
## Factorio Mods
|
||||
|
||||
Factorio mods that do not interact with Clusterio should not have any
|
||||
conciderations that need to be taken into account for them to work with
|
||||
Clusterio.
|
||||
Factorio mods that do not interact with Clusterio should not have any considerations that need to be taken into account for them to work with Clusterio.
|
||||
|
||||
|
||||
Factorio Scenarios
|
||||
------------------
|
||||
## Factorio Scenarios
|
||||
|
||||
Clusterio patches saves and because of this scenarios have a few
|
||||
limitations and must explicitly mark themselves as compatible with
|
||||
Clusterio to work. The main limitation is that the
|
||||
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
|
||||
lib must be used, and control.lua cannot contain any code other than
|
||||
calls to event_handler loading the relevant libs of the scenario.
|
||||
This is because the control.lua file will be overwritten by the save
|
||||
patcher. Scenarios also cannot use the event registring functions
|
||||
exposed by `script` such as `script.on_event` or `script.on_nth_tick` as
|
||||
using these will overwrite the handlers registered by the
|
||||
event_handler lib.
|
||||
Clusterio patches saves and because of this scenarios have a few limitations and must explicitly mark themselves as compatible with Clusterio to work.
|
||||
The main limitation is that the [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) lib must be used, and control.lua cannot contain any code other than calls to event_handler loading the relevant libs of the scenario.
|
||||
This is because the control.lua file will be overwritten by the save patcher.
|
||||
Scenarios also cannot use the event registering functions exposed by `script` such as `script.on_event` or `script.on_nth_tick` as using these will overwrite the handlers registered by the event_handler lib.
|
||||
|
||||
A breif description of the usage of the event_handler library is
|
||||
provided in the [event_handler interface](#event_handler-interface)
|
||||
section.
|
||||
A brief description of the usage of the event_handler library is provided in the [event_handler interface](#event_handler-interface) section.
|
||||
|
||||
Once a scenario is made using the event_handler it should have a
|
||||
control.lua file that looks something like this:
|
||||
Once a scenario is made using the event_handler it should have a control.lua file that looks something like this:
|
||||
|
||||
```lua
|
||||
-- control.lua
|
||||
@@ -55,8 +37,7 @@ event_handler.add_lib(require("module_foo"))
|
||||
event_handler.add_lib(require("module_bar"))
|
||||
```
|
||||
|
||||
To make Clusterio recognize and correctly handle this scenario it
|
||||
needs to include a clusterio.json file with following content:
|
||||
To make Clusterio recognize and correctly handle this scenario it needs to include a clusterio.json file with following content:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -70,60 +51,43 @@ needs to include a clusterio.json file with following content:
|
||||
}
|
||||
```
|
||||
|
||||
This tells Clusterio that it should load both module_foo and module_bar
|
||||
by passing the result of requiring them to the `add_lib` function when
|
||||
writing a new control.lua file to the save.
|
||||
This tells Clusterio that it should load both module_foo and module_bar by passing the result of requiring them to the `add_lib` function when writing a new control.lua file to the save.
|
||||
|
||||
|
||||
### event_handler interface
|
||||
|
||||
<sub>**Note:** At the time of writing the event_handler lib has been
|
||||
a part of Factorio since at least 0.17.4, and was moved from base to
|
||||
core in 0.17.63 but is still not documented anywhere. A brief
|
||||
description is provided here for this reason.</sub>
|
||||
<sub>**Note:** At the time of writing the event_handler lib has been a part of Factorio since at least 0.17.4, and was moved from base to core in 0.17.63 but is still not documented anywhere.
|
||||
A brief description is provided here for this reason.</sub>
|
||||
|
||||
The event_handler lib provides a simple interface for registring
|
||||
multiple callbacks to the same events without having to be concerned
|
||||
with callback chaining or overwriting callbacks defined elsewhere. It
|
||||
works by taking over the task of registring the actual callbacks with
|
||||
Factorio, providing its own interface for the rest of the code to use.
|
||||
The event_handler lib provides a simple interface for registering multiple callbacks to the same events without having to be concerned with callback chaining or overwriting callbacks defined elsewhere.
|
||||
It works by taking over the task of registering the actual callbacks with Factorio, providing its own interface for the rest of the code to use.
|
||||
|
||||
The sole function of interest exported by event_handler is `add_lib`
|
||||
which accepts a table with event handler callbacks definitions that it
|
||||
will register. The following entries are recognized by `add_lib`, all
|
||||
of which are optional:
|
||||
The sole function of interest exported by event_handler is `add_lib` which accepts a table with event handler callbacks definitions that it will register.
|
||||
The following entries are recognized by `add_lib`, all of which are optional:
|
||||
|
||||
- `on_init`:
|
||||
Callback called when the callback of `script.on_init` is invoked.
|
||||
- `on_load`:
|
||||
Callback called when the callback of `script.on_load` is invoked.
|
||||
- `on_configuration_changed`:
|
||||
Callback called when the callback of
|
||||
`script.on_configuration_changed` is invoked.
|
||||
Callback called when the callback of `script.on_configuration_changed` is invoked.
|
||||
- `events`:
|
||||
Table mapping event ids as defined in `defines.events` to callbacks.
|
||||
For example the table `{ [defines.events.on_player_died] = foo }`
|
||||
will call bar with the usual `event` table as argument every time a
|
||||
player dies.
|
||||
For example the table `{ [defines.events.on_player_died] = foo }` will call bar with the usual `event` table as argument every time a player dies.
|
||||
- `on_nth_tick`:
|
||||
Table mapping the nth tick number to a callback. For example the
|
||||
table `{ [30] = bar }` will call bar every 30th tick.
|
||||
Table mapping the nth tick number to a callback.
|
||||
For example the table `{ [30] = bar }` will call bar every 30th tick.
|
||||
- `add_remote_interface`:
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
|
||||
has no special meaning and receives no arguments but should be used
|
||||
for registring remote interfaces.
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
|
||||
It has no special meaning and receives no arguments but should be used for registering remote interfaces.
|
||||
- `add_commands`:
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>. It
|
||||
has no special meaning and receives no arguments but should be used
|
||||
for registring commands.
|
||||
Callback called before on_init/on_load callbacks<sup>[1]</sup>.
|
||||
It has no special meaning and receives no arguments but should be used for registering commands.
|
||||
|
||||
<sub>1: Before 0.17.69 these callbacks were called after
|
||||
on_init/on_load.</sub>
|
||||
<sub>1: Before 0.17.69 these callbacks were called after on_init/on_load.</sub>
|
||||
|
||||
The usual way to use `add_lib` is to define the table of events to
|
||||
register in a separate file and return it, then load it in control.lua
|
||||
via `require` before passing it to `add_lib`. For example a module
|
||||
could be defined as
|
||||
The usual way to use `add_lib` is to define the table of events to register in a separate file and return it, then load it in control.lua via `require` before passing it to `add_lib`.
|
||||
For example a module could be defined as
|
||||
|
||||
```lua
|
||||
-- example.lua
|
||||
@@ -148,41 +112,27 @@ local event_handler = require("event_handler")
|
||||
event_handler.add_lib(require("example"))
|
||||
```
|
||||
|
||||
Because the event_handler lib registers events itself you may not use
|
||||
`script.on_load` or `script.on_init` at all in your code and any usage
|
||||
of `script.on_configuration_changed`, `script.on_nth_tick` and/or
|
||||
`script.on_event` will cause the corresponding events registered with
|
||||
event_handler to break and should therefore not be used.
|
||||
Because the event_handler lib registers events itself you may not use `script.on_load` or `script.on_init` at all in your code and any usage of `script.on_configuration_changed`, `script.on_nth_tick` and/or `script.on_event` will cause the corresponding events registered with event_handler to break and should therefore not be used.
|
||||
|
||||
There's also the `add_libraries` function exported by event_handler,
|
||||
which accepts a table and calls `add_lib` for each value in the table.
|
||||
There's also the `add_libraries` function exported by event_handler, which accepts a table and calls `add_lib` for each value in the table.
|
||||
|
||||
|
||||
Communicating with Clusterio
|
||||
----------------------------
|
||||
## Communicating with Clusterio
|
||||
|
||||
There's a poorly to virtuarly undocumented HTTP interface that can be
|
||||
used to communicate with Clusterio to do things like get the status of
|
||||
instances, grab items from the store, run commands on instances, etc,
|
||||
etc. For now you can read the source code, and maybe ask on the Discord
|
||||
for some pointers if you get stuck.
|
||||
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
|
||||
See [socket.md](socket.md) for the implementation details of it.
|
||||
|
||||
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
|
||||
|
||||
|
||||
Clusterio Modules
|
||||
-----------------
|
||||
## Clusterio Modules
|
||||
|
||||
Modules are primarily used by plugins to inject code into Factorio games
|
||||
with the save patcher, though it's also possible to make stand alone
|
||||
modules that are loaded into the game if you don't need the capabilites
|
||||
of the plugin system. The save patcher puts modules into the `modules`
|
||||
folder of the Factorio save and adds code to `control.lua` to load the
|
||||
module according to the `load` and `require` options to the module.json
|
||||
file.
|
||||
Modules are primarily used by plugins to inject code into Factorio games with the save patcher, though it's also possible to make stand alone modules that are loaded into the game if you don't need the capabilites of the plugin system.
|
||||
The save patcher puts modules into the `modules` folder of the Factorio save and adds code to `control.lua` to load the module according to the `load` and `require` options to the module.json file.
|
||||
Like with scenarios for Clusterio the [event_handler interface](#event_handler-interface) has to be used for any event subscriptions in modules.
|
||||
|
||||
Stand alone modules are placed into the modules folder of Clusterio,
|
||||
plugin modules are located in the module folder of the plugin. In
|
||||
either case a `module.json` file is required and has the following
|
||||
structure:
|
||||
Stand alone modules are placed into the modules folder of Clusterio, plugin modules are located in the module folder of the plugin.
|
||||
In either case a `module.json` file is required and has the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -200,68 +150,44 @@ structure:
|
||||
The following entries are supported in the module.json file:
|
||||
|
||||
- `name`:
|
||||
Name of the module, must match the folder the module is located in
|
||||
for stand alone modules or the name of the plugin the module is
|
||||
located in.
|
||||
Name of the module, must match the folder the module is located in for stand alone modules or the name of the plugin the module is located in.
|
||||
- `version`:
|
||||
Version of the module. Must be compatible with [Semantic Versioning
|
||||
2.0.0](https://semver.org/). In plugin modules this defaults to the
|
||||
version of the plugin otherwise it's required.
|
||||
Version of the module.
|
||||
Must be compatible with [Semantic Versioning 2.0.0](https://semver.org/).
|
||||
In plugin modules this defaults to the version of the plugin otherwise it's required.
|
||||
- `dependencies`:
|
||||
Optional mapping of modules this module depends on and their
|
||||
version. In the example `>=0.4.2` means that the foo module must be
|
||||
at least version 0.4.2. See the [Ranges
|
||||
syntax](https://www.npmjs.com/package/semver#ranges) of the
|
||||
node-semver package for a full description of what operators are
|
||||
supported. The events for dependencies is invoked before the events
|
||||
of the dependent, and starting an instance will fail if the
|
||||
dependencies cannot be satisfied. If not specified it will default
|
||||
to depending on `clusterio`. Make sure to add `clusterio` to your
|
||||
dependencies if you add other dependencies and depend on the Lua API.
|
||||
Optional mapping of modules this module depends on and their version.
|
||||
In the example `>=0.4.2` means that the foo module must be at least version 0.4.2.
|
||||
See the [Ranges syntax](https://www.npmjs.com/package/semver#ranges) of the node-semver package for a full description of what operators are supported.
|
||||
The events for dependencies is invoked before the events of the dependent, and starting an instance will fail if the dependencies cannot be satisfied.
|
||||
If not specified it will default to depending on `clusterio`.
|
||||
Make sure to add `clusterio` to your dependencies if you add other dependencies and depend on the Lua API.
|
||||
- `require`:
|
||||
Lua files to require in `control.lua`. This should only really be
|
||||
needed if you want to make a global function available for use in
|
||||
Lua commands. The paths are relative to the module's own folder and
|
||||
should be specified using forward slashes as directory sepparators
|
||||
if it's located in a sub directory in the module.
|
||||
Lua files to require in `control.lua`.
|
||||
This should only really be needed if you want to make a global function available for use in Lua commands.
|
||||
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
|
||||
- `load`:
|
||||
Lua files to load with the `event_handler` lib, the result of
|
||||
requiring the file will be passed to the `add_lib` function of the
|
||||
`event_handler` lib in `control.lua`. See the section on the
|
||||
[event_handler interface](#event_handler-interface) for a detailed
|
||||
description on how the `event_handler` lib works. The paths are
|
||||
relative to the module's own folder and should be specified using
|
||||
forward slashes as directory sepparators if it's located in a sub
|
||||
directory in the module.
|
||||
Lua files to load with the `event_handler` lib, the result of requiring the file will be passed to the `add_lib` function of the `event_handler` lib in `control.lua`.
|
||||
See the section on the [event_handler interface](#event_handler-interface) for a detailed description on how the `event_handler` lib works.
|
||||
The paths are relative to the module's own folder and should be specified using forward slashes as directory sepparators if it's located in a sub directory in the module.
|
||||
|
||||
It's recommended to use the new style of defining modules in Lua, as
|
||||
well as avoid the use of global variables as much as possible. This
|
||||
means always declearing your top level variables and functions local and
|
||||
exporting only the things you need in other files. You can require
|
||||
other files in your module by prefixing your require paths with
|
||||
`modules/your_module_name/`. It's also possible to require files from
|
||||
other modules this way too.
|
||||
It's recommended to use the new style of defining modules in Lua, as well as avoid the use of global variables as much as possible.
|
||||
This means always declearing your top level variables and functions local and exporting only the things you need in other files.
|
||||
You can require other files in your module by prefixing your require paths with `modules/your_module_name/`.
|
||||
It's also possible to require files from other modules this way too.
|
||||
|
||||
The global variables and data stored in the global table is shared by
|
||||
all Clusterio modules as well as the scenario, so it's important that
|
||||
you use unique names. It's recommended to prefix the global variables
|
||||
and data in the global table that your module has with the name of your
|
||||
module.
|
||||
The global variables and data stored in the global table is shared by all Clusterio modules as well as the scenario, so it's important that you use unique names.
|
||||
It's recommended to prefix the global variables and data in the global table that your module has with the name of your module.
|
||||
|
||||
Because modules can be patched into an existing game you cannot rely on
|
||||
the `on_init` callback to be called in Clusterio Modules. Nor can you
|
||||
rely on the `on_configuration_changed` callback, as this is not called
|
||||
when level code changes. The Clusterio Lua API provides the custom
|
||||
`on_server_startup` event that can be used as a substitute, see the next
|
||||
section.
|
||||
Because modules can be patched into an existing game you cannot rely on the `on_init` callback to be called in Clusterio Modules.
|
||||
Nor can you rely on the `on_configuration_changed` callback, as this is not called when level code changes.
|
||||
The Clusterio Lua API provides the custom `on_server_startup` event that can be used as a substitute, see the next section.
|
||||
|
||||
|
||||
Clusterio Lua API
|
||||
-----------------
|
||||
## Clusterio Lua API
|
||||
|
||||
Clusterio provides a save patched module as well as a regular Factorio
|
||||
mod for interfacing with Clusterio. This includes tools sending data to
|
||||
plugins, script events to listen for, and an item serialization module.
|
||||
Clusterio provides a save patched module as well as a regular Factorio mod for interfacing with Clusterio.
|
||||
This includes tools sending data to plugins, script events to listen for, and an item serialization module.
|
||||
|
||||
|
||||
### clusterio_api library
|
||||
@@ -272,64 +198,54 @@ From a module
|
||||
From a mod
|
||||
`local clusterio_api = require("__clusterio_lib__/api")`
|
||||
|
||||
Provides the main interface to Clusterio from within the game. When
|
||||
using the mod version you must call `init` before it'll function
|
||||
properly.
|
||||
Provides the main interface to Clusterio from within the game.
|
||||
When using the mod version you must call `init` before it'll function properly.
|
||||
|
||||
#### init
|
||||
|
||||
<sub>Mod version only</sub>
|
||||
|
||||
Initialize the API. This should be called from both the `on_init` and
|
||||
`on_load` events in your mod, and before this is called certain features
|
||||
will not be available, most notably the events table.
|
||||
Initialize the API.
|
||||
This should be called from both the `on_init` and `on_load` events in your mod, and before this is called certain features will not be available, most notably the events table.
|
||||
|
||||
#### clusterio_api.events
|
||||
|
||||
Table of events raised by clusterio. If the game is started outside of
|
||||
Clusterio then none of these events will be raised.
|
||||
Table of events raised by clusterio.
|
||||
If the game is started outside of Clusterio then none of these events will be raised.
|
||||
|
||||
In the mod version this table does not exist before the `init` function
|
||||
is called.
|
||||
In the mod version this table does not exist before the `init` function is called.
|
||||
|
||||
##### on_instance_updated
|
||||
|
||||
Raised after the name and id of an instance has been updated. This may
|
||||
occur even if the id and name didn't change.
|
||||
Raised after the name and id of an instance has been updated.
|
||||
This may occur even if the id and name didn't change.
|
||||
|
||||
Event data:
|
||||
- `instance_id`: The id of the instance.
|
||||
- `instance_id`: the id of the instance.
|
||||
- `instance_name`: the name of the instance.
|
||||
|
||||
##### on_server_startup
|
||||
|
||||
Raised when Clusterio is starting up the server. It can be used to
|
||||
initialize clusterio related features in a mod, and as a stand-in for
|
||||
the `on_init` and `on_configuration_changed` event for modules. It is
|
||||
invoked on the first tick the server runs after the save has been
|
||||
patched, before most other events.
|
||||
Raised when Clusterio is starting up the server.
|
||||
It can be used to initialize clusterio related features in a mod, and as a stand-in for the `on_init` and `on_configuration_changed` event for modules.
|
||||
It is invoked on the first tick the server runs after the save has been patched, before most other events.
|
||||
|
||||
Use this event to initialize and and/or migrate the data structures
|
||||
you need. Keep in mind that both mods and Clusterio modules can switch
|
||||
from any version to any version so it should be able to handle both
|
||||
forwards and backwards migrations gracefully.
|
||||
Use this event to initialize and/or migrate the data structures you need.
|
||||
Keep in mind that both mods and Clusterio modules can switch from any version to any version so it should be able to handle both forwards and backwards migrations gracefully.
|
||||
|
||||
#### clusterio_api.send_json(channel, data)
|
||||
|
||||
Send json data to Clusterio over the given channel. The `data` argument
|
||||
should be table that can be serialized with `game.table_to_json`.
|
||||
Clusterio plugins can listen to channels and will receive an event with
|
||||
the data sent here. See the [Communicating with Factorio
|
||||
section](writing-plugins.md#communicating-with-factorio) in the Writing
|
||||
Plugins document for more information.
|
||||
Send JSON data to Clusterio over the given channel.
|
||||
The `data` argument should be table that can be serialized with `game.table_to_json`.
|
||||
Clusterio plugins can listen to channels and will receive an event with the data sent here.
|
||||
See the [Communicating with Factorio section](writing-plugins.md#communicating-with-factorio) in the Writing Plugins document for more information.
|
||||
|
||||
If the game is started outside of Clusterio the data will be sent, but
|
||||
since there's no code following stdout to pick it up it will be lost.
|
||||
If the game is started outside of Clusterio the data will be sent, but since there's no code following stdout to pick it up it will be lost.
|
||||
|
||||
**Note**: Payloads greater than 4 MB will cause stuttering in the game.
|
||||
|
||||
**Note**: This is not a binary safe way of sending data. Strings
|
||||
embedded into the tables sent must be valid UTF-8 text.
|
||||
**Note**: This is not a binary safe way of sending data.
|
||||
Strings embedded into the tables sent must be valid UTF-8 text.
|
||||
|
||||
Parameters:
|
||||
- `channel`: string identifying which channel to send it on.
|
||||
@@ -344,19 +260,17 @@ From a module
|
||||
From a mod
|
||||
`local serialize = require("__clusterio_lib__/serialize")`
|
||||
|
||||
Serializes inventories and item stacks into a table that in turn can be
|
||||
converted into JSON with `game.table_to_json`. Properly handles the
|
||||
extra data for most items.
|
||||
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `game.table_to_json`.
|
||||
Properly handles the extra data for most items.
|
||||
|
||||
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
|
||||
|
||||
Returns a table containing a serialized representation of the equipment
|
||||
grid passed.
|
||||
Returns a table containing a serialized representation of the equipment grid passed.
|
||||
|
||||
#### serialize.deserialize_equipment_grid(LuaEquipmentGrid, serialized_grid)
|
||||
|
||||
Deserialize a previously serialized equipment grid into the target
|
||||
LuaEquipmentGrid. Overwrites all content in the target.
|
||||
Deserialize a previously serialized equipment grid into the target LuaEquipmentGrid.
|
||||
Overwrites all content in the target.
|
||||
|
||||
#### serialize.serialize_item_stack(LuaItemStack, destination_table)
|
||||
|
||||
@@ -365,25 +279,19 @@ The destination_table should be a new empty table.
|
||||
|
||||
#### serialize.deserialize_item_stack(LuaItemStack, serialized_stack)
|
||||
|
||||
Deserialize a previously serialized item stack into the target
|
||||
LuaItemStack. Overwrites the content of the item stack.
|
||||
Deserialize a previously serialized item stack into the target LuaItemStack.
|
||||
Overwrites the content of the item stack.
|
||||
|
||||
#### serialize.serialize_inventory(LuaInventory)
|
||||
|
||||
Returns a table containing a serialized representation of the inventory
|
||||
passed.
|
||||
Returns a table containing a serialized representation of the inventory passed.
|
||||
|
||||
#### serialize.deserialize_inventory(LuaInventory, serialized_inventory)
|
||||
|
||||
Deserialize a previously serialized inventory into the target
|
||||
LuaInventory. Overwrites slots that has content in the serialized
|
||||
inventory.
|
||||
Deserialize a previously serialized inventory into the target LuaInventory.
|
||||
Overwrites slots that have content in the serialized inventory.
|
||||
|
||||
|
||||
Clusterio Plugins
|
||||
-----------------
|
||||
## Clusterio Plugins
|
||||
|
||||
The plugin interface is undergoing a major rewrite, and will be
|
||||
documented once that is done. See
|
||||
[issue #220](https://github.com/clusterio/factorioClusterio/issues/220)
|
||||
for details.
|
||||
See the [Writing Plugins](writing-plugins.md) document for details on the plugin interface and how to write plugins for Clusterio.
|
||||
@@ -0,0 +1,268 @@
|
||||
# Protocol
|
||||
|
||||
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
|
||||
The protocol is implemented in [packages/lib/link](/packages/lib/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
|
||||
|
||||
Two general kinds of messages are supported: events and requests.
|
||||
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
|
||||
Requests are pairs of request-response messages where one side sends the requests and the other side replies with a response.
|
||||
Apart from the handshake used to establish connections the protocol is directionless, and either party can send or receive both requests and events.
|
||||
|
||||
Events and requests are defined as instances of the `Request` and `Event` classes, and specify a unique type string, who can send and receive the message, optional forwarding of the message and the format of the data payload as a JSON schema.
|
||||
All messages sent and received are validated with JSON schemas to catch potential errors in sending messages and avoid having to write lots of validation code in message handlers.
|
||||
|
||||
Messages are sent by calling the `.send()` method of the message instance and passing a `Link` instance to send it over and the data to send.
|
||||
For the master server there's a `Link` instance for each slave and control connection stored in the `WsServer` class.
|
||||
For slaves the `Slave` instance is a link to the master server, and each `Instance` instance is an in-memory link to the `Slave` instance and has a corresponding `InstanceConnection` link stored on the `Slave` instance.
|
||||
For ctl and web_ui there's a `Control` instance that acts as the link to the master server.
|
||||
|
||||
Messages received invokes message handlers that were registered when the link was brought up.
|
||||
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
|
||||
The handlers are defined on the link class itself for core messages, and on the plugin class in the case of plugin defined messages.
|
||||
|
||||
|
||||
## Connection flow
|
||||
|
||||
Clients connect via WebSocket to the `/api/socket` endpoint and upon establishing connection to the server the connection is in the handshake phase and the server will send a `hello` message to the client.
|
||||
If the client has no previous session with the server it sends a `register_slave` or `register_control` message depending on what kind of client it is to the server.
|
||||
If the register is valid then the server will respond with a `ready` message with a session token and the connection has left the handshake phase, otherwise the server will close the connection with a WebSocket close code indicating the kind of failure.
|
||||
|
||||
Upon leaving the handshake phase both parties should start sending heartbeat messages with the sequence number of the last received message.
|
||||
Messages sent over the link should be stored in a send buffer and only cleared from the send buffer when a heartbeat with a `last_seq` equal or or higher is received or the session is invalidated.
|
||||
|
||||
If the connection goes stale or is unexpectedly closed the client can reastablish the same session by connecting to the server again and sending a `resume` message after received the `hello` from the server.
|
||||
If the session resume is successful then the server will respond with a `continue` message and the connection is active again.
|
||||
The client should then re-send all messages it has in its send buffer that is greater than the `last_seq` number in the `continue` message.
|
||||
Otherwise the server will respond with `invalidate` and the client is expected to drop its send buffer start a new session with `register_slave` or `register_control`.
|
||||
|
||||

|
||||
|
||||
|
||||
## Connector flow
|
||||
|
||||
To manage the complexity of connecting, reconnecting, resuming or invalidating dropped sessions the WebSocket connection for a `Link` class is managed by a connector.
|
||||
There's three kinds of connectors defined: `WebSocketClientConnector`, `WebSocketServerConnector` and `VirtualConnector`.
|
||||
The virtual connector is for in-memory connections and doesn't have any connection state logic.
|
||||
|
||||
For the WebSocket connectors there's four states they can exist in:
|
||||
- `closed` - The connector has just been created or has been explicitly closed.
|
||||
- `connecting` (client only) - The connector is in the process of trying to connect to the server.
|
||||
- `connected` - The connector is successfully connected.
|
||||
- `resuming` - The connection dropped and the connector is in the process of attempting to resume it.
|
||||
|
||||
Along with the following events emitted when transition between states:
|
||||
- `connect` From `connecting` (client) or `closed` (server) to `connected`
|
||||
- `drop` - From `connected` to `resuming`
|
||||
- `resume` - From `resuming` to `connected`
|
||||
- `invalidate` - From `resuming` to `connecting` or `closed`
|
||||
- `close` - From any state to `closed`
|
||||
|
||||

|
||||
|
||||
|
||||
### WsClientConnector
|
||||
|
||||
The client connector will keep trying to establish and mantain a connection after `.connect()` is called on it, stopping only in the case of authentication failure or `.close()`/`.setClosing()` being called.
|
||||
Other than that, the logic is pretty straight forward.
|
||||
|
||||

|
||||
|
||||
|
||||
### WsServerConnector
|
||||
|
||||
The server connector doesn't do the work of connecting or validating the handshake.
|
||||
Instead it's handed over a WebSocket connection from `WsServer` once there's only the step of sending the final message left to do.
|
||||
Because of this there's no `connecting` state, and once a session times out from the `resuming` state it goes directly to closed.
|
||||
|
||||

|
||||
|
||||
|
||||
## Wire format
|
||||
|
||||
All communication in the protocol is done via WebSocket text frames containing a JSON payload with the following properties:
|
||||
|
||||
- `seq: integer | null` - The sequence number of the message, can only be null for handshake and heartbeats.
|
||||
- `type: string` - The type of message
|
||||
- `data: object` - Message data
|
||||
|
||||
Before a connection is established it's in the handshake phase and the `seq` property is `null`.
|
||||
The `type` property is used to distinguish which of handshake, heartbeat, events or requests this message is and determines the expected format of the `data` property.
|
||||
|
||||
|
||||
### Handshake Messages
|
||||
|
||||
The following messages are the only valid messages that can be sent during the handshake phase.
|
||||
|
||||
|
||||
#### `hello`
|
||||
|
||||
Sent by the master server when the WebSocket connection has been opened.
|
||||
|
||||
- `version: string` - The version of the master, e.g. "2.0.0".
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are loaded on the master server.
|
||||
|
||||
#### `register_slave`
|
||||
|
||||
Slave handshake for establishing a new connection session.
|
||||
|
||||
- `token: string` - Authentication token.
|
||||
- `agent: string` - Human readable string of the software connecting.
|
||||
- `version: string` - The protocol version of the slave, e.g. "2.0.0".
|
||||
- `name: string` - Name of the slave.
|
||||
- `id: integer` - ID of the slave.
|
||||
- `plugins: Object<string, string>` -
|
||||
Object mapping plugin names to plugin version for plugins that are available on the slave.
|
||||
|
||||
#### `register_control`
|
||||
|
||||
Control handshake for enstablishing a new connection session.
|
||||
|
||||
- `token: string` - Authentication token.
|
||||
- `agent: string` - Human readable string of the software connecting.
|
||||
- `version: string` - The protocol version of the control, e.g. "2.0.0".
|
||||
|
||||
#### `resume`
|
||||
|
||||
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
|
||||
|
||||
- `session_token: string` - Session token to resume session with.
|
||||
- `last_seq: integer | null` - Last message seq received by the client.
|
||||
|
||||
#### `invalidate`
|
||||
|
||||
Sent by the master server in reply to a resume for a session that cannot be resumed.
|
||||
The client should start a new session with a register\_\* message in response.
|
||||
|
||||
#### `continue`
|
||||
|
||||
Sent by the master server to signal resuming the session was successful.
|
||||
|
||||
- `last_seq: integer | null` - Last message seq received by the server.
|
||||
- `session_timeout: integer` - Session timeout in seconds.
|
||||
- `heartbeat_interval: number` - Rate client should send heartbeats at.
|
||||
|
||||
#### `ready`
|
||||
|
||||
Sent by the master server to signal the connection has been established with a new session.
|
||||
|
||||
- `session_token: string` - Token to resume the session with.
|
||||
- `session_timeout: integer` - Session timeout in seconds.
|
||||
- `heartbeat_interval: number` - Rate client should send heartbeats at.
|
||||
|
||||
|
||||
### Heartbeat message
|
||||
|
||||
Once connected as indicated by the `ready` and `continue` messages both sides of the connection starts sending heartbeats at the inverval given in the `ready` and `continue` messages.
|
||||
The heartbeat should contain the `seq` of the last received message, and receiving a heartbeat with a given sequence number means that the other party of the link has received all messages up to that seq number.
|
||||
|
||||
|
||||
#### `heartbeat`
|
||||
|
||||
Sent by both the master server and clients at the interval given in the continue/ready messages.
|
||||
|
||||
- `last_seq: integer` - Last message seq received over the link.
|
||||
|
||||
### Events
|
||||
|
||||
Events are messages send in one direction over the link, that the receiver is expected to act upon, but not make any replies (for that there's [Requests](#requests).
|
||||
By convention message types ending in `*_event` are events.
|
||||
The data payload of an event consists solely of event specific properties.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core events and their contents.
|
||||
Plugins may defined their own events as well.
|
||||
|
||||
#### `*_event`
|
||||
|
||||
- any properties defined by the event type.
|
||||
|
||||
|
||||
### Requests
|
||||
|
||||
Requests are messages which expects a response in return.
|
||||
They function a lot like HTTP requests except that both parties of a connection can inniate a request that is expected to be responded to.
|
||||
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
|
||||
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
|
||||
|
||||
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core requests and their contents.
|
||||
Plugins may define their own requests as well.
|
||||
|
||||
#### `*_request`
|
||||
|
||||
- any properties defined by the request type.
|
||||
|
||||
#### `*_response` success
|
||||
|
||||
- `seq: integer` - The seq number of the request message.
|
||||
- any additional properties defined by the request type.
|
||||
|
||||
#### `*_response` error
|
||||
|
||||
- `seq: integer` - The seq number of the request message.
|
||||
- `error: string` - Human readable text message describing the error.
|
||||
|
||||
|
||||
### Graceful Disconnect
|
||||
|
||||
To ensure no data is lost when closing the link it's important to send a prepare disconnect request and wait for it to complete before closing the WebSocket.
|
||||
Either party of the link can send this request and the party sending the request should be the one that initates the close of the WebSocket.
|
||||
|
||||
#### `prepare_disconnect_request`
|
||||
|
||||
Request graceful disconnect of the connection.
|
||||
The receiving party should stop tasks that send events and requests over the link and wait for pending requests it has over the link to complete, but it should not stop responding to requests sent to it.
|
||||
Only after this process is done should the response be sent.
|
||||
|
||||
#### `prepare_disconnect_response`
|
||||
|
||||
Signals the preparation for disconnect is complete and that the connection can be closed.
|
||||
The party that initiatied the prepare disconnect request may still send other requests/events over the link to finish its part of the disconnection proceedure.
|
||||
|
||||
If the error response variant is received it should be logged if possible and treated as a success.
|
||||
|
||||
|
||||
### Example handshake
|
||||
|
||||
Server hello after opening the WebSocket.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "hello",
|
||||
"data": {
|
||||
"version": "2.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Slave registering a new connection session.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "register_slave",
|
||||
"data": {
|
||||
"token": "xyzabc123",
|
||||
"agent": "Clusterio Slave",
|
||||
"version": "2.0.0",
|
||||
"name": "Foo",
|
||||
"id": 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Master server reply signaling the session is established and ready.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "ready",
|
||||
"data": {
|
||||
"session_token": "xyz.azy.bzy",
|
||||
"session_timeout": 60,
|
||||
"heartbeat_interval": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Slave and master can now send events and requests over the connection.
|
||||
@@ -1,15 +1,16 @@
|
||||
Save Patching
|
||||
-------------
|
||||
## Save Patching
|
||||
|
||||
In order to efficiently get data in and out of running Factorio servers
|
||||
and to provide custom interfaces Clusterio uses save
|
||||
patching<sup>[1]</sup> to add code to the game. The save patching is
|
||||
based on the built in
|
||||
[event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua)
|
||||
library to Factorio and requires scenarios to be coded specifically for
|
||||
it. Mods on the other hand work without modifications.
|
||||
In order to efficiently get data in and out of running Factorio servers and to provide custom interfaces Clusterio uses save patching<sup>[1]</sup> to add code to the game.
|
||||
The save patching is based on the built in [event_handler](https://github.com/wube/factorio-data/blob/master/core/lualib/event_handler.lua) library to Factorio and requires scenarios to be coded specifically for it.
|
||||
Mods on the other hand work without modifications.
|
||||
|
||||
<sub>1: Before version 2.0 of Clusterio this was done at runtime
|
||||
with a patcher called Hotpatch, but due to the difficulty in supporting
|
||||
run time patching and the lack of documentation it was replace it with
|
||||
save patching instead.</sub>
|
||||
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
|
||||
|
||||
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
|
||||
|
||||
|
||||
## User Accounts
|
||||
|
||||
The master server creates and stores accounts for all Factorio users that log into instances under its control.
|
||||
These accounts are used to store per player data and authorize management of the cluster.
|
||||
By default users accounts are assigned a Player role that gives a limited read access to the cluster resources, and admins of the cluster can be assigned a Cluster Admin role to get access to manage it.
|
||||
@@ -1,23 +1,25 @@
|
||||
{
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"tags": {
|
||||
"allowUnknownTags": false
|
||||
},
|
||||
"opts": {
|
||||
"destination": "docs/api",
|
||||
"recurse": true
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"master.js",
|
||||
"slave.js",
|
||||
"clusterctl.js",
|
||||
"lib/",
|
||||
"plugins/",
|
||||
"routes/",
|
||||
"routes.js"
|
||||
]
|
||||
}
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"tags": {
|
||||
"allowUnknownTags": false
|
||||
},
|
||||
"opts": {
|
||||
"destination": "docs/api",
|
||||
"recurse": true
|
||||
},
|
||||
"source": {
|
||||
"include": [
|
||||
"packages/master/src/",
|
||||
"packages/master/master.js",
|
||||
"packages/slave/src/",
|
||||
"packages/slave/slave.js",
|
||||
"packages/ctl/src/",
|
||||
"packages/ctl/ctl.js",
|
||||
"packages/lib/",
|
||||
"plugins/"
|
||||
],
|
||||
"excludePattern": "node_modules"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
# Managing a Cluster
|
||||
|
||||
Clusterio clusters are managed through the master server by using the `clusterioctl` command line interface which is invoked by running `npx clusterioctl <command>` in the clusterio directory.
|
||||
This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` should be executed.
|
||||
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
|
||||
|
||||
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
|
||||
The easiest way to do this is to run `npx clusteriomaster bootstrap create-ctl-config <username>` on the master server, which creates the necessary `config-control.json` for managing the cluster as the given user.
|
||||
|
||||
|
||||
## Slaves
|
||||
|
||||
### List slaves
|
||||
|
||||
ctl> slave list
|
||||
|
||||
Lists all slaves.
|
||||
|
||||
### Generate token
|
||||
|
||||
ctl> slave generate-token [--id <slave id>]
|
||||
|
||||
Generate a token for a slave. `--id` is the slave id.
|
||||
|
||||
### Create config
|
||||
|
||||
ctl> slave create-config [--id <slave id>] [--name <slave name>] [--generate-token <bool>] [--output <path to output>]
|
||||
|
||||
Create a slave config. `--id` id the slave id. `--name` is the slave name. `--generate-token` decides whether to also generate a token, defaulting to `0`. `--output` is the filename for the output of the config; Default is "config-slave.json", "-" for stdout.
|
||||
|
||||
## Instances
|
||||
|
||||
### List Instances
|
||||
|
||||
ctl> instance list
|
||||
|
||||
List instances.
|
||||
|
||||
### Create instance
|
||||
|
||||
ctl> instance create <name> [--id <instance id>]
|
||||
|
||||
Create a new instance. `name` is the instance name. `--id` is the optional id.
|
||||
|
||||
### Kill instance
|
||||
|
||||
ctl> instance kill <name>
|
||||
|
||||
Terminates the running Factorio server without giving it a chance to save or cleanup after itself.
|
||||
|
||||
Note: This may cause loss of data.
|
||||
|
||||
### Extract players from instance
|
||||
|
||||
ctl> instance extract-players <name>
|
||||
|
||||
Creates a user account in the cluster for each player that has been online on the currently running save of the instance and sets the online time of that account on this instance to the online time recorded in the save.
|
||||
|
||||
Useful when importing a save to a cluster.
|
||||
Note that the online time in a save is recorded differntly from how Clusterio records online time.
|
||||
Most notably Clusterio records wall clock time while the save time is recorded in ticks and this extraction of player time assumes the game runs at 60 ticks a second.
|
||||
|
||||
|
||||
### Config management
|
||||
|
||||
#### List Configuration
|
||||
|
||||
ctl> instance config list <instance>
|
||||
|
||||
List config values for an instance. `instance` is the instance whose config values to list.
|
||||
|
||||
#### Set config value
|
||||
|
||||
ctl> instance config set <instance> <field> [value] [--stdin]
|
||||
|
||||
Set field in instance config. `instance` is instance to set. `field` is field to set. `value` is value to set in field. `--stdin` signals to read value from stdin.
|
||||
|
||||
#### Set config property of field
|
||||
|
||||
To be written.
|
||||
|
||||
#### Edit instance config
|
||||
|
||||
ctl> instance config edit <instance> [editor]
|
||||
|
||||
Edit instance config in a text editor. Once file is saved and editor has been exited, config values will be read back in. `instance` is the instance to edit, `editor` is the editor to use. If `editor` is not given, clusterioctl will read from the `EDITOR` or `VISUAL` enviroment variables.
|
||||
|
||||
### Assign instance to slave
|
||||
|
||||
ctl> instace assign <instance> [slave]
|
||||
|
||||
Assigns instance to slave. `instance` is instance to assign, `slave` is slave to assign to. If `slave` is not provided, instance will be unassigned.
|
||||
|
||||
## Users
|
||||
|
||||
Clusterio automatically creates user accounts for all players that join an instance when save patching is enabled.
|
||||
These accounts are used to store per player data shared between instances, like if the player should be whitelisted, admin or banned.
|
||||
|
||||
### List users
|
||||
|
||||
ctl> user list
|
||||
|
||||
Lists all user accounts in the cluster along with some data for each.
|
||||
|
||||
|
||||
### Create User
|
||||
|
||||
ctl> user create <name>
|
||||
|
||||
Creates a new empty user account for the given Factorio player.
|
||||
Note that case matters here.
|
||||
This is usually not required as user accounts are created automatically for players that join instances with save patching enabled.
|
||||
|
||||
|
||||
### Promote User to Server Admin
|
||||
|
||||
ctl> user set-admin <name> [--revoke] [--create]
|
||||
|
||||
Promotes the user given to in-game admin on instances with the `sync_adminlist` option enabled.
|
||||
If the `--revoke` switch is used the user is removed from the adminlist.
|
||||
|
||||
Since admin status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
|
||||
|
||||
**Note:** Being a server admin does not grant any access to manage the cluster.
|
||||
See [set-roles](#set-cluster-roles) for adding roles which grant access to managing the clusetr.
|
||||
|
||||
|
||||
### Whitelist User
|
||||
|
||||
ctl> user set-whitelisted <name> [--remove] [--create]
|
||||
|
||||
Add the user given to the whitelist on instances with the `sync_whitelist` option enabled.
|
||||
If the `--remove` switch is used the user is removed from the whitelist.
|
||||
|
||||
Since whitelisted status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
|
||||
|
||||
|
||||
### Ban User
|
||||
|
||||
ctl> user set-banned <name> [--reason <message>] [--pardon] [--create]
|
||||
|
||||
Ban user in-game from instances with the `sync_banlist` option enabled.
|
||||
Reason is a message that will be shown to the user when attemption to log in.
|
||||
If the `--pardon` switch is used it removes the ban.
|
||||
|
||||
Since ban status is a part of the account data the account must exist for this to succeed, passing `--create` will create the account if it does not exist.
|
||||
|
||||
Note: This bans the user from logging in to Factorio servers in the cluster, it does not revoke access to any cluster management they might have, see next section on setting roles for revoking tha.
|
||||
|
||||
### Bulk importing admin, whitelist and ban lists
|
||||
|
||||
Whitelists and admin lists can be bulk imported with the following commands:
|
||||
|
||||
ctl> user whitelist-bulk-import <file> [--create]
|
||||
ctl> user admin-bulk-import <file> [--create]
|
||||
|
||||
The file should simply be newline seperated names:
|
||||
|
||||
joe
|
||||
bob
|
||||
allan
|
||||
|
||||
The `--create` option will create the user if they do not exist, and then set the relevent value.
|
||||
|
||||
Banlists can be bulk imported with the following command:
|
||||
|
||||
ctl> user banned-bulk-import <file> [--create]
|
||||
|
||||
The file should be names, followed by a ban reason:
|
||||
|
||||
joe greifed copper mines
|
||||
bob stole materials
|
||||
allan rude language
|
||||
|
||||
The first string on the line is used as the username, the others are used as the ban reason.
|
||||
The `--create` option will create the user if they do not exist, then ban them.
|
||||
|
||||
### Set Cluster Roles
|
||||
|
||||
ctl> user set-roles <name> [roles...]
|
||||
|
||||
Replaces the roles the user has in the cluster with a new list of roles.
|
||||
Calling with an empty roles argument will remove all roles from the user.
|
||||
|
||||
By default there's a roled named Cluster Admin which grants access to everything and a role named Player which grants a limited read access to the cluster, see [the section on roles](#roles) for more information about setting up roles and permissions.
|
||||
|
||||
|
||||
### Delete user
|
||||
|
||||
ctl> user delete <name>
|
||||
|
||||
Deletes everything stored on the master server for this user.
|
||||
|
||||
Note: If the player is banned from the cluster this will effectively unban them, as the ban status is stored with the user account.
|
||||
|
||||
Note: If the player joins the cluster again a new account will be made for them automatically.
|
||||
|
||||
|
||||
## Roles
|
||||
|
||||
To be written.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Setting up TLS
|
||||
|
||||
By default Clusterio will listen for connections over HTTP which is fine for local networks but over the Internet it should idealy be over HTTPS as there are some credentials that could be exposed to eavesdroppers.
|
||||
The recommended setup is to have an ordinary HTTPS server software like Apache or Nginx to act as a reverse proxy in front of the Clusterio master server.
|
||||
The reverse proxy would then be set up with a TLS certificate from a Certificate Authoritiy like [Let's Encrypt](https://letsencrypt.org).
|
||||
Alternatively you can have Clusterio listen for connections over HTTPS directly by providing it with a TLS certificate and private key.
|
||||
|
||||
Note that a TLS certificate signed by a recognized Certificate Authority in general requires that you own a domain.
|
||||
See the last section for how to create and use a self-signed certificate for an IP address if you don't have a domain.
|
||||
|
||||
|
||||
## Using a reverse proxy
|
||||
|
||||
Configure the proxy to forward requests over HTTP to the port the master server is listening to so that for example a request to `https://example.com/instances/` is forwarded to `http://localhost:8080/instances/`.
|
||||
It is possible to host the interface under a sub-path, in that case a request under the sub-path should be mapped to the master server with the sub-path stripped out.
|
||||
For example `https://example.com/sub/path/instances/` would be mapped to `http://localhost:8080/instances`.
|
||||
|
||||
Additianlly WebSocket connections to `/api/socket` also needs to be forwarded.
|
||||
Typically this requires explicit configuration and support for forwarding WebSocket connections.
|
||||
If the interface hosted under a sub-path the `/api/socket` address is relative to the sub-path and the sub-path also needs to be stripped out when forwarded.
|
||||
|
||||
You can also optionally set the master server to listen only on the loopback interface to prevent it being directly connected to from the outside.
|
||||
|
||||
npx clusteriomaster config set master.bind_address 127.0.0.1
|
||||
|
||||
|
||||
### Apache Config
|
||||
|
||||
To proxy with Apache make sure that `proxy_module`, `proxy_http_module` and `proxy_wstunnel_module` is loaded in the config, then add the following directives to wherever is the approriate place for your flavour of Apache config organization.
|
||||
|
||||
```apache
|
||||
<Location /sub/path/>
|
||||
ProxyPass "http://localhost:8080/"
|
||||
</location>
|
||||
<Location /sub/path/api/socket>
|
||||
ProxyPass "ws://localhost:8080/api/socket"
|
||||
</Location>
|
||||
```
|
||||
|
||||
Replace `/sub/path/` with the location on the proxy you want to host the Clusterio interface under.
|
||||
If the whole domain is to be dedicated to the Clusterio interface then `/sub/path/` should be just `/`.
|
||||
Pay attention to the use of trailing slashes, as they are important.
|
||||
|
||||
|
||||
## Hosting HTTPS directly from Node.js
|
||||
|
||||
Listening on HTTPS is supported by the Clusterio master server via the `master.https_port` option provided paths to a certificate file and a private key file is configured for the `master.tls_certificate` and `master.tls_private_key` options.
|
||||
Both of these are expected to be unencrypted PEM files.
|
||||
|
||||
|
||||
## Creating and using a self-signed certificate
|
||||
|
||||
Create a self-signed certificate with OpenSSL by running
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3650 -subj /CN=localhost -addext "subjectAltName=IP:<ip>"
|
||||
|
||||
substituting `<ip>` with the IP address the server will be accessed under, for example `IP:203.0.113.4`.
|
||||
Multiple addresses can be given by supplying a comma sepparated list of `IP:address` pairs and DNS names can also be specified using the `DNS:example.com` format.
|
||||
You need to supply every name you want to connect to the server under the subjectAltName field, for example to connect to the HTTPS port locally via an address like `https://localhost:4443/` you need to add `DNS:localhost`.
|
||||
|
||||
This creates a private key file named `key.pem` and a public certificate file named `cert.pem` and these are used for the `master.tls_certificate` and `master.tls_private_key` config options respectively on the master server:
|
||||
|
||||
npx clusteriomaster config set master.tls_certificate cert.pem
|
||||
npx clusteriomaster config set master.tls_private_key key.pem
|
||||
|
||||
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as slaves and configure it as the `slave.tls_ca` option:
|
||||
|
||||
npx clusterioslave config set slave.tls_ca cert.pem
|
||||
|
||||
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
|
||||
|
||||
npx clusterioctl control-config set control.tls_ca cert.pem
|
||||
@@ -1,280 +0,0 @@
|
||||
WebSocket protocol
|
||||
==================
|
||||
|
||||
<sub>This document describes the protocol used between the master and
|
||||
slaves, and unless you're developing Clusterio it's probably not going
|
||||
to be very useful for you.</sub>
|
||||
|
||||
The protocol is built on WebSocket and uses JSON encoded text messages
|
||||
to communicate to and from the master server. These messages contain
|
||||
a JSON encoded object with the following three properties:
|
||||
|
||||
- seq - integer|null - The sequence number of the message
|
||||
- type - string - The type of message
|
||||
- data - object - Message data
|
||||
|
||||
For the handshake and heartbeats the seq property is null.
|
||||
|
||||
|
||||
Handshake
|
||||
---------
|
||||
|
||||
Upon connecting to the WebSocket endpoint the server will respond with a
|
||||
`hello` message containing the master server version as the data. The
|
||||
client is then expected to send either `register_*` or a `resume`
|
||||
message to set up the connection with the master. The exact flow is
|
||||
described below.
|
||||
|
||||
|
||||
### Client connection flow
|
||||
|
||||
For a client making a new connecting to the master server the first
|
||||
course of action after obtaining a token is to connect with WebSocket to
|
||||
the /api/socket endpoint and wait for the server hello. After the
|
||||
hello, the client sends a register\_\* message corresponding to the type
|
||||
of the client. If successful the server will reply with a ready message
|
||||
and the connection has been established. See the flowchart below.
|
||||
|
||||

|
||||
|
||||
If for any reason the connection is closed or lost during the connection
|
||||
attempt the process is started over after a random delay, with the
|
||||
exception of the connection being closed with a 4003 code which means
|
||||
authentication failed and the client should stop trying to connect. The
|
||||
wait for hello and wait for ready states should also have a timeout,
|
||||
after which the connection is closed if the hello/reply wasn't received
|
||||
in time.
|
||||
|
||||
|
||||
### Client reconnection flow
|
||||
|
||||
If the connection is lost after the client has received the ready
|
||||
message from the server it should attempt a reconnect instead. This is
|
||||
very similar to a new connection, but instead of a register\_\* messsage
|
||||
the client sends a resume message and the server replies with a continue
|
||||
message if resuming the session was successful. This continue message
|
||||
has sequence number of the last message the master received and the
|
||||
client is expected to imediately send all messages from its send buffer
|
||||
that occured after the sequence number given.
|
||||
|
||||
The server will respond with an invalidate message if resuming the
|
||||
session is not possible. In this case the client should continue from
|
||||
the new session state of the connection flow (see the previous section).
|
||||
|
||||

|
||||
|
||||
Like the connection flow the process starts over after a delay if the
|
||||
connection is closed or lost. The same exception for connection closed
|
||||
with 4003 applies as with the connection flow. Additionally after an
|
||||
invalidate message has been received and the connection flow fails, it
|
||||
should start over from the connection flow.
|
||||
|
||||
|
||||
### Server connection flow
|
||||
|
||||
From the master server side new connections and reconnection attempts
|
||||
start out the same, a new connection has been made and the server sends
|
||||
the hello messoge to the client. If a vaild register\_\* message is
|
||||
received a new connection is set up for the client and the ready message
|
||||
is sent in reply. If a valid resume message is received and the session
|
||||
that's attempted to be resumed is active it'll signal that the session
|
||||
is resumed with a continue message and the client upon receiving the
|
||||
continue message should send the messages from its send buffer from
|
||||
after the last\_seq given in the continue message.
|
||||
|
||||
If the session token used to resume is not valid it'll send an invalidate
|
||||
message and expect the client to continue with a registr\_\* message. If an
|
||||
invalid or unexpected message is received during the handshake the connection
|
||||
is closed. See the flowchart below.
|
||||
|
||||

|
||||
|
||||
For authentication failure the connection is closed with code 4003,
|
||||
which the connection client should interpret as connecting will never
|
||||
succeed and stop trying to reconnect.
|
||||
|
||||
|
||||
### Connection tracking and heartbeats
|
||||
|
||||
Once connected as indicated by the ready and continue messages from the
|
||||
master server the client must start sending heartbeats at the interval
|
||||
given in the ready/continue message, and storing messages sent over the
|
||||
link in a sending side buffer. The heartbeats sent must have the seq of
|
||||
the last received message on the link and acts as an ack for received
|
||||
messages. The master server also sends heartbeats in the same manner
|
||||
and the client should remove messages up to and including the sequence
|
||||
number received in the heartbeat from its send buffer.
|
||||
|
||||
|
||||
Handshake messages
|
||||
------------------
|
||||
|
||||
### `hello`
|
||||
|
||||
Sent by the master server when the WebSocket connection has been opened.
|
||||
|
||||
- version - string - The version of the master, e.g. "2.0.0".
|
||||
|
||||
### `register_slave`
|
||||
|
||||
Slave handshake for establishing a new connection session.
|
||||
|
||||
- token - string - Authentication token.
|
||||
- agent - string - Human readable string of the software connecting.
|
||||
- version - string - The protocol version of the slave, e.g. "2.0.0".
|
||||
- name - string - Name of the slave.
|
||||
- id - integer - ID of the slave.
|
||||
|
||||
### `register_control`
|
||||
|
||||
Control handshake for enstablishing a new connection session.
|
||||
|
||||
- token - string - Authentication token.
|
||||
- agent - string - Human readable string of the software connecting.
|
||||
- version - string - The protocol version of the control, e.g. "2.0.0".
|
||||
|
||||
### `resume`
|
||||
|
||||
Handshake for resuming an existing connection session where the
|
||||
connection had dropped unexpectedly.
|
||||
|
||||
- session_token - string - Session token to resume session with.
|
||||
- last_seq - integer|null - Last message seq received by the client.
|
||||
|
||||
### `invalidate`
|
||||
|
||||
Sent by the master server in reply to a resume for a session that cannot
|
||||
be resumed. The client should start a new session with a register\_\*
|
||||
message in response.
|
||||
|
||||
### `continue`
|
||||
|
||||
Sent by the master server to signal resuming the session was successful.
|
||||
|
||||
- last_seq - integer|null - Last message seq received by the server.
|
||||
- heartbeat_interval - number - Rate client should send heartbeats at.
|
||||
|
||||
### `ready`
|
||||
|
||||
Sent by the master server to signal the connection has been established
|
||||
with a new session.
|
||||
|
||||
- session_token - string - Token to resume the session with.
|
||||
- heartbeat_interval - number - Rate client should send heartbeats at.
|
||||
|
||||
### `heartbeat`
|
||||
|
||||
Sent by both the master server and clients at the interval given in the
|
||||
continue/ready messages.
|
||||
|
||||
- last_seq - integer - Last message seq received over the link.
|
||||
|
||||
|
||||
Example handshake
|
||||
-----------------
|
||||
|
||||
Server hello after opening the WebSocket.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "hello",
|
||||
"data": {
|
||||
"version": "2.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Slave registering a new connection session.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "register_slave",
|
||||
"data": {
|
||||
"token": "xyzabc123",
|
||||
"agent": "Clusterio Slave",
|
||||
"version": "2.0.0",
|
||||
"name": "Foo",
|
||||
"id": 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Master server reply signaling the session is established and ready.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "ready",
|
||||
"data": {
|
||||
"session_token": "xyz.azy.bzy",
|
||||
"heartbeat_interval": 45
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Events
|
||||
------
|
||||
|
||||
Events are messages send in one direction over the link, that the
|
||||
receiver is expected to act upon, but not make any replies (for that
|
||||
there's [Requests](#requests). By convention message types ending in
|
||||
`*_event` are events. The data payload of an event consists solely of
|
||||
event specific properties.
|
||||
|
||||
See [lib/link/messages.js](/lib/link/messages.js) for the recognized
|
||||
events and their contents.
|
||||
|
||||
|
||||
Requests
|
||||
--------
|
||||
|
||||
Requests are messages which expects a response in return. They function
|
||||
a lot like HTTP requests except that both parties of a connection can
|
||||
inniate a request that is expected to be responded to. By convention
|
||||
message types ending in `*_request` are requests, and is expected to be
|
||||
replied to with a corresponding `*_response` message. If an error
|
||||
occured while processing the request an error response containing an
|
||||
error property in the data payload is sent instead.
|
||||
|
||||
See [lib/link/messages.js](/lib/link/messages.js) for the recognized
|
||||
requests and their contents.
|
||||
|
||||
### `*_response`
|
||||
|
||||
- seq - integer - The seq number of the request message.
|
||||
- additional properties may be present depending on the type of request.
|
||||
|
||||
### `*_response` error
|
||||
|
||||
- seq - integer - The seq number of the request message.
|
||||
- error - string - Human readable text message describing the error.
|
||||
|
||||
|
||||
Graceful shutdown
|
||||
-----------------
|
||||
|
||||
To ensure no data is lost when closing the link it's important to
|
||||
send connection shutdown request and wait for it to complete before
|
||||
closing the WebSocket. Either party of the link can send this request
|
||||
and the party sending the request should be the one that initates the
|
||||
close of the WebSocket.
|
||||
|
||||
### `connection_shutdown_request`
|
||||
|
||||
Request graceful shutdown of the connection. The receiving party should
|
||||
stop tasks that send events and requests over the link and wait for
|
||||
pending requests it has over the link to complete, but it should not
|
||||
stop responding to requests sent to it. Only after this process is done
|
||||
should the response be sent.
|
||||
|
||||
### `connection_shutdown_response`
|
||||
|
||||
Signals the shutdown is complete and that the connection can be closed.
|
||||
The party that initiatied the shutdown request may still send other
|
||||
requests/events over the link to finish its part of the shutdown.
|
||||
|
||||
If the error response variant is received it should be logged if
|
||||
possible and treated as a success.
|
||||
@@ -1,27 +1,26 @@
|
||||
Writing Plugins
|
||||
===============
|
||||
# Writing Plugins
|
||||
|
||||
Plugins for Clusterio are classes written in JavaScript that run under
|
||||
Node.js. The plugin classes have pre-defined hooks that are called
|
||||
during various stages and operations of Clusterio.
|
||||
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
|
||||
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
|
||||
|
||||
|
||||
Contents
|
||||
--------
|
||||
## Contents
|
||||
|
||||
- [Plugin Structure](#plugin-structure)
|
||||
- [Defining the plugin class](#defining-the-plugin-class)
|
||||
- [Logging Messages](#logging-messages)
|
||||
- [Plugin Configuration](#plugin-configuration)
|
||||
- [Communicating with Factorio](#communicating-with-factorio)
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
- [Defining Events](#defining-events)
|
||||
- [Defining Requests](#defining-requests)
|
||||
- [Sending Link Messages](#sending-link-messages)
|
||||
- [Handling connection events](#handling-connection-events)
|
||||
- [Collecting Statistics](#collecting-statistics)
|
||||
- [Adding Custom Commands to clusterioctl](#adding-custom-commands-to-clusterioctl)
|
||||
|
||||
|
||||
Plugin Structure
|
||||
----------------
|
||||
## Plugin Structure
|
||||
|
||||
The basic file structure of a plugin is the following.
|
||||
|
||||
@@ -30,468 +29,591 @@ The basic file structure of a plugin is the following.
|
||||
+- package.json
|
||||
+- master.js
|
||||
+- instance.js
|
||||
+- control.js
|
||||
+- test/
|
||||
| +- plugin.js
|
||||
+- module/
|
||||
+- module.json
|
||||
+- plugin.lua
|
||||
|
||||
The most important file is the `info.js` file. Without it the plugin
|
||||
will not recognized by Clusterio. Here's an example of it:
|
||||
Clusterio plugins are Node.js packages that can be published on npm for ease of installation and distribution.
|
||||
The usual guides for creating such packages apply.
|
||||
At minimum the `package.json` file must contain a version entry.
|
||||
|
||||
const link = require('lib/link'); // For messages
|
||||
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
|
||||
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriomaster, clusterioslave or clusterioctl.
|
||||
Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows).
|
||||
|
||||
module.exports = {
|
||||
name: "foo_frobber",
|
||||
title: "Foo Frobber",
|
||||
description: "Does advance frobnication",
|
||||
version: "0.8.1",
|
||||
instanceEntrypoint: "instance",
|
||||
masterEntrypoint: "master",
|
||||
messages: {
|
||||
/* See below */
|
||||
},
|
||||
};
|
||||
For a plugin the most important file is the `info.js` file.
|
||||
Without it the plugin will not recognized by Clusterio.
|
||||
Here's an example of it:
|
||||
|
||||
```js
|
||||
const { libLink } = require("@clusterio/lib"); // For messages
|
||||
|
||||
module.exports = {
|
||||
name: "foo_frobber",
|
||||
title: "Foo Frobber",
|
||||
description: "Does advanced frobnication",
|
||||
instanceEntrypoint: "instance",
|
||||
masterEntrypoint: "master",
|
||||
messages: {
|
||||
/* See below */
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The following properties are recognized:
|
||||
|
||||
**name**:
|
||||
Internal name of the plugin. Must be the same as the plugin's
|
||||
directory name
|
||||
Internal name of the plugin.
|
||||
Must be the same as the plugin's directory name
|
||||
|
||||
**title**:
|
||||
Name of the plugin as shown to users. Currently not used.
|
||||
Name of the plugin as shown to users.
|
||||
Currently not used.
|
||||
|
||||
**description**:
|
||||
Brief description of what the plugin does. Currently not used.
|
||||
|
||||
**version**:
|
||||
Semver compatible version string of the plugin. Currently only
|
||||
servers as the default for the Clusterio module embedded in the
|
||||
plugin, if any.
|
||||
Brief description of what the plugin does.
|
||||
Currently not used.
|
||||
|
||||
**instanceEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which
|
||||
contains the InstancePlugin class definition for this plugin. This
|
||||
is an optional paramater. A plugin can be for instances only but
|
||||
for it to be able to send any messages to other instances it will
|
||||
still have to be loaded on the master server.
|
||||
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin may have code only for instances but it must still be loaded on the master in order for it to be possible to load it on an instance.
|
||||
|
||||
**InstanceConfigGroup**:
|
||||
Subclass of `PluginConfigGroup` to defining the per instance
|
||||
configuration fields for this plugin. See [Plugin
|
||||
Configuration](#plugin-configuration)
|
||||
Subclass of `PluginConfigGroup` for defining the per instance configuration fields for this plugin.
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**masterEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which
|
||||
contains the MasterPlugin class definiton for this plugin. This is
|
||||
an optional parameter. A plugin can be made that only runs on the
|
||||
master server.
|
||||
Path to a Node.js module relative to the plugin directory which contains the MasterPlugin class definiton for this plugin.
|
||||
This is an optional parameter.
|
||||
A plugin can be made that only runs on the master server.
|
||||
|
||||
**MasterConfigGroup**:
|
||||
Subclass of `PluginConfigGroup` to defining the master server
|
||||
configuration fields for this plugin. See [Plugin
|
||||
Configuration](#plugin-configuration)
|
||||
Subclass of `PluginConfigGroup` for defining the master server configuration fields for this plugin.
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**controlEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which contains the ControlPlugin class definition for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin can be made that only runs on the clusterioctl side.
|
||||
|
||||
**messages**:
|
||||
Object with link messages definitions for this plugin. See guide
|
||||
for [defining link messages](#defining-link-messages) below.
|
||||
Object with link messages definitions for this plugin.
|
||||
See guide for [defining link messages](#defining-link-messages) below.
|
||||
|
||||
The optional module folder contains a Clusterio module that will be
|
||||
patched into the save when the plugin is enabled. See the section on
|
||||
[Clusterio Modules](developing-for-clusterio.md) in the Developing for
|
||||
Clusterio document. The only restriction imposed on modules embedded
|
||||
into plugins is that they must be named the same as the plugin.
|
||||
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
|
||||
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
|
||||
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
|
||||
|
||||
While there is no standard for how to organize a plugin it's recommended
|
||||
to put the MasterPlugin class definition into master.js and the
|
||||
InstancePlugin class definition into instance.js. You can put them into
|
||||
whatever file you want (even the same one for both)
|
||||
While there is no standard for how to organize a plugin it's recommended to put the MasterPlugin class definition into master.js and the InstancePlugin class definition into instance.js.
|
||||
You can put them into whatever file you want (even the same one for both).
|
||||
|
||||
For both instanceEntrypoint and masterEntrypoint the path should not end
|
||||
with .js and it should use forward slashes for directory sepparators if
|
||||
any.
|
||||
For both instanceEntrypoint and masterEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
|
||||
|
||||
|
||||
Defining the plugin class
|
||||
-------------------------
|
||||
## Defining the plugin class
|
||||
|
||||
The plugin class should derive from its respective base class defined in
|
||||
`lib/plugin`. For example to define a MasterPlugin class the following
|
||||
code can be used:
|
||||
The plugin class should derive from its respective base class defined in `lib/plugin`.
|
||||
For example, to define a MasterPlugin class the following code can be used:
|
||||
|
||||
const plugin = require('lib/plugin');
|
||||
|
||||
class MasterPlugin extends plugin.BaseMasterPlugin {
|
||||
async init() {
|
||||
this.foo = 42;
|
||||
await this.startFrobnication();
|
||||
}
|
||||
|
||||
// ...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MasterPlugin,
|
||||
}
|
||||
|
||||
For the instance plugin it's exactly the same except "Master" is
|
||||
replaced with "Instance". The available hooks that you can override is
|
||||
documented on the base class [in lib/plugin.js](../lib/plugin.js).
|
||||
|
||||
It's best to avoid defining a constructor, but if you insist on defining
|
||||
one forward all arguments to be the base class. E.g.:
|
||||
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
// Code here
|
||||
}
|
||||
|
||||
The arguments passed may change, and attempting to modify them will
|
||||
result in unpredicatable behaviour. The async init method always called
|
||||
immediatly after the constructor so there's little reason to do this.
|
||||
|
||||
|
||||
Defining Configuration
|
||||
----------------------
|
||||
|
||||
Clusterio provides a configuration system that handles storing,
|
||||
distributing, editing and validating config fields for you. You can
|
||||
take advantage of it by subclassing `PluginConfigGroup`, setting the
|
||||
`groupName` to your plugin name, defining fields on it, finalizing it,
|
||||
and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in
|
||||
the `info.js` export. For example in info.js:
|
||||
|
||||
const config = require("lib/config");
|
||||
|
||||
class MasterConfigGroup extends config.PluginConfigGroup { }
|
||||
MasterConfigGroup.groupName = "foo_frobber";
|
||||
MasterConfigGroup.define({
|
||||
name: "level",
|
||||
description: "Level of frobnication done",
|
||||
type: "number",
|
||||
initial_value: 2,
|
||||
});
|
||||
MasterConfigGroup.finalize();
|
||||
|
||||
module.exports = {
|
||||
...
|
||||
MasterConfigGroup: MasterConfigGroup,
|
||||
};
|
||||
|
||||
Code inside the `MasterPlugin` class will then be able to access the
|
||||
level config field through the `Config` object at `this.master.config`,
|
||||
for example in the MasterPluginClass:
|
||||
```js
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
|
||||
class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
async init() {
|
||||
let level = this.master.config.get("foo_frobber.level");
|
||||
console.log("I got a frobnication level of", level);
|
||||
this.foo = 42;
|
||||
await this.startFrobnication();
|
||||
}
|
||||
|
||||
The same applies for instance configs, replace "master" with "instance"
|
||||
where appropriate. See [Configuration System](configuration-system.md)
|
||||
for more details on how this system works.
|
||||
// ...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MasterPlugin,
|
||||
}
|
||||
```
|
||||
|
||||
For the instance plugin it's exactly the same except "Master" is replaced with "Instance", and for the clusterioctl plugin "Control" is used.
|
||||
The available hooks that you can override are documented in the base class [in lib/plugin.js](/packages/lib/plugin.js).
|
||||
|
||||
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
|
||||
E.g.:
|
||||
|
||||
```js
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
// Code here
|
||||
}
|
||||
```
|
||||
|
||||
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
|
||||
The async init method is always called immediatly after the constructor, so there's little reason to do this.
|
||||
|
||||
|
||||
Communicating with Factorio
|
||||
---------------------------
|
||||
## Logging Messages
|
||||
|
||||
For pushing data into Factorio there's RCON which lets you send
|
||||
arbitrary Lua command to invoke whatever code you want in the game.
|
||||
This is done by calling sendRcon method on the instance's server object.
|
||||
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
|
||||
For instances a copy of the log is also stored on the slave the instance is on.
|
||||
To use it, pass a string to one of the log levels functions, for example:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.logger.info("Initializing frobbing");
|
||||
}
|
||||
```
|
||||
|
||||
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
|
||||
|
||||
The available levels are fatal, error, warn, audit, info, and verbose.
|
||||
These levels have roughly the following meanings:
|
||||
|
||||
- fatal: Unrecoverable error that prevents continuing execution of the Node.js process.
|
||||
Note that Plugins shouldn't have fatal errors.
|
||||
- error: An unexpected error condition occured, usually an exception thrown when it wasn't expected.
|
||||
Should be investigated and fixed.
|
||||
- warn: An potential error was detected, but operation continued gracefully.
|
||||
These may indicate that something is running sub-optimally, or is not working as intedend.
|
||||
- audit: Record of actions performed by users of the cluster.
|
||||
This is usually emitted in response to management requests by a control link.
|
||||
- info: A general log message.
|
||||
May indicae a status or operation completed.
|
||||
This should not be emitted by periodic tasks that run often.
|
||||
- verbose: A verbose log message which might be useful when investigating issues, does not show by default.
|
||||
Note that logging messages is not a cheap operation even when the messages do not show up and spammy logging should be avoided for the verbose level.
|
||||
If the logs are still valuable in certain cases consider putting them behind a config option.
|
||||
|
||||
For guarding unexpected errors the best option is to log a short description along with the`stack` property of the Error:
|
||||
|
||||
```js
|
||||
try {
|
||||
// Operation that should not throw but may end up throwing
|
||||
} catch (err) {
|
||||
this.logger.error(`Operation failed:\n${err.stack}`);
|
||||
)
|
||||
```
|
||||
|
||||
For plugin hooks expections thrown are automatically catched and logged, but for event handlers registered on EventEmitters it's critical that exceptions are catched and handled appropriately.
|
||||
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you.
|
||||
You can take advantage of it by subclassing `PluginConfigGroup`, setting `defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export.
|
||||
For example in info.js:
|
||||
|
||||
```js
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
|
||||
class MasterConfigGroup extends libConfig.PluginConfigGroup { }
|
||||
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
|
||||
MasterConfigGroup.groupName = "foo_frobber";
|
||||
MasterConfigGroup.define({
|
||||
name: "level",
|
||||
description: "Level of frobnication done",
|
||||
type: "number",
|
||||
initial_value: 2,
|
||||
});
|
||||
MasterConfigGroup.finalize();
|
||||
|
||||
module.exports = {
|
||||
...
|
||||
MasterConfigGroup: MasterConfigGroup,
|
||||
};
|
||||
```
|
||||
|
||||
Code inside the `MasterPlugin` class will then be able to access the level config field through the `Config` object at `this.master.config`, for example in the MasterPluginClass:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
let level = this.master.config.get("foo_frobber.level");
|
||||
this.logger.info(`I got a frobnication level of ${level}`);
|
||||
}
|
||||
```
|
||||
|
||||
The same applies for instance configs, replace "master" with "instance" where appropriate.
|
||||
See [Configuration System](config-system.md) for more details on how this system works.
|
||||
|
||||
|
||||
### Handling Invalid Configuration
|
||||
|
||||
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
|
||||
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
|
||||
For example:
|
||||
|
||||
async onStart() {
|
||||
let response = await this.instance.server.sendRcon(
|
||||
"/sc rcon.print('data')"
|
||||
);
|
||||
|
||||
// Do stuff with response.
|
||||
```js
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("foo_frobber plugin requires save patching.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Because data into Factorio is streamed at a rate of 3-6 kB/s by default
|
||||
it is recommended to avoid sending large commands as much as possible,
|
||||
and to strip down the data on the ones you send to only what's strictly
|
||||
necessary. You can have lua code injected into the game via the module
|
||||
system and call that from RCON to avoid having to send code through the
|
||||
commands.
|
||||
## Communicating with Factorio
|
||||
|
||||
For getting data out from Factorio there's both RCON and the `send_json`
|
||||
API of the Clusterio module. Returning data via RCON is prefered if the
|
||||
action is initiated from the Node.js side. The `send_json` API allows
|
||||
sending JSON payloads on channels that plugins can listen to. From a
|
||||
plugin you listen for an event named ipc-channel_name in order to get
|
||||
data sent by `send_json`. For example in the plugin code:
|
||||
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
|
||||
This is done by calling the `sendRcon` method on the plugin object.
|
||||
For example:
|
||||
|
||||
async init() {
|
||||
this.instance.server.on('ipc-my_plugin_foo', content =>
|
||||
this.handleFoo(content).catch(err => console.log(
|
||||
"Error handling foo:", foo
|
||||
))
|
||||
);
|
||||
}
|
||||
```js
|
||||
async onStart() {
|
||||
let response = await this.sendRcon(
|
||||
"/sc rcon.print('data')"
|
||||
);
|
||||
|
||||
async handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
// Do stuff with response.
|
||||
}
|
||||
```
|
||||
|
||||
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
|
||||
You can have lua code injected into the game via the module system and call that from RCON to avoid having to send code through the commands.
|
||||
|
||||
For getting data out from Factorio there's both RCON and the `send_json` API of the Clusterio module.
|
||||
Returning data via RCON is prefered if the action is initiated from the Node.js side.
|
||||
The `send_json` API allows sending JSON payloads on channels that plugins can listen to.
|
||||
From a plugin you listen for an event named `ipc-channel_name` in order to get data sent by `send_json`.
|
||||
For example in the plugin code:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.instance.server.on("ipc-my_plugin_foo", content =>
|
||||
this.handleFoo(content).catch(err => this.logger.error(
|
||||
`Error handling foo:\n${err.stack}`
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
async handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
|
||||
And then in the module for the plugin:
|
||||
|
||||
local clusterio_api = require('modules/clusterio/api')
|
||||
```lua
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
|
||||
-- inside some event handler
|
||||
clusterio_api.send_json('my_plugin_foo', { data = 123 })
|
||||
-- inside some event handler
|
||||
clusterio_api.send_json("my_plugin_foo", { data = 123 })
|
||||
```
|
||||
|
||||
It's recommended to either use the plugin name as the channel name or to
|
||||
prefix the channel name with the name of the plugin if you need multiple
|
||||
channels. It's also important to catch any errors that might occur as
|
||||
they will otherwise be propogated to the instance code and kill the
|
||||
server.
|
||||
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
|
||||
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
|
||||
|
||||
Data out from Factorio does not have the same limits as data into
|
||||
Factorio, RCON responses can be in 100kB range without causing issues,
|
||||
and payloads to the `send_json` API can be in the 4MB range provided the
|
||||
server has a fast enough storage system.
|
||||
Data out from Factorio does not have the same limits as data into Factorio, RCON responses can be in 100kB range without causing issues, and payloads to the `send_json` API can be in the 4MB range provided the server has a fast enough storage system.
|
||||
|
||||
**Note:** both `send_json` and RCON can operate out of order. For
|
||||
`send_json` it's possible that payloads greater than 4kB are received
|
||||
after payloads that was sent at a later point in time. For RCON,
|
||||
commands longer than 50 characters may end up being executed after
|
||||
shorter commands sent after it.
|
||||
**Note:** both `send_json` and RCON can operate out of order.
|
||||
For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time.
|
||||
For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it.
|
||||
|
||||
|
||||
Defining Link Messages
|
||||
----------------------
|
||||
## Defining Link Messages
|
||||
|
||||
You will most likely have to communicate with the master or other
|
||||
instances in your plugin for it to do anything useful. For this there's
|
||||
a WebSocket communication channel established between the slaves and the
|
||||
master server that plugins can define their own messages to send over
|
||||
it. This channel is bi-directional and all messages send over it are
|
||||
validated with a JSON schema (see [this guide][guide] for an
|
||||
introduction to writing JSON schema.)
|
||||
You will most likely have to communicate with the master or other instances in your plugin for it to do anything useful.
|
||||
For this there's a WebSocket communication channel established between the slaves and the master server that plugins can define their own messages to send over it.
|
||||
This channel is bi-directional and all messages sent over it are validated with a JSON schema (see [this guide][guide] for an introduction to writing JSON schema).
|
||||
|
||||
[guide]: https://json-schema.org/learn/getting-started-step-by-step.html
|
||||
|
||||
There are currently two kinds of messages that can be defined. Events
|
||||
and requests. Events are simple one way notifications that invoke a
|
||||
handler on target it's sent to. Requests are pairs of request and
|
||||
response messages where the request is sent to the target and the
|
||||
response is the reply back from the target. The requests are similar to
|
||||
HTTP requests only that both parties of a link may innitiate one.
|
||||
There are currently two kinds of messages that can be defined: events and requests.
|
||||
Events are simple one-way notifications that invoke a handler on the target it's sent to.
|
||||
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
|
||||
The requests are similar to HTTP requests, except both parties of a link may initiate one.
|
||||
|
||||
|
||||
### Defining Events
|
||||
|
||||
Events are defined as properties of the messages object exported by
|
||||
`info.js` that map to instances of the `Event` class from `lib/link`.
|
||||
The name of the property correspond to the handler invoked on the plugin
|
||||
class. The Event constructor takes an object of properties that define
|
||||
the event, for example the following could be defined in `info.js`:
|
||||
Events are defined as properties of the messages object exported by `info.js` that map to instances of the `Event` class from `lib/link`.
|
||||
The name of the property correspond to the handler invoked on the plugin class.
|
||||
The Event constructor takes an object of properties that define the event, for example the following could be defined in `info.js`:
|
||||
|
||||
messages: {
|
||||
startFrobnication: new link.Event({
|
||||
type: 'foo_frobber:start_frobnication',
|
||||
links: ['master-slave', 'slave-instance'],
|
||||
forwardTo: 'instance',
|
||||
eventProperties: {
|
||||
"frobnication_type": { type: "string" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
```js
|
||||
messages: {
|
||||
startFrobnication: new libLink.Event({
|
||||
type: "foo_frobber:start_frobnication",
|
||||
links: ["master-slave", "slave-instance"],
|
||||
forwardTo: "instance",
|
||||
eventRequired: ["frobnication_type"],
|
||||
eventProperties: {
|
||||
"frobnication_type": { type: "string" },
|
||||
"urgent": { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
This specifies an event that can be sent from the master to a slave,
|
||||
and from a slave to an instance. And that the event must contain the
|
||||
property `frobnication_type` with a string value in the data payload.
|
||||
This specifies an event that can be sent from the master to a slave, and from a slave to an instance.
|
||||
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
|
||||
It will also be forwarded by slaves to a specific instance.
|
||||
|
||||
The following properties are recognized by the Event constructor:
|
||||
|
||||
**type**:
|
||||
The message type sent over the wire. This can be any string but
|
||||
it must be unique across all plugins and it's recommended that it is
|
||||
of format `plugin_name:message_name`. The suffix `_event` will be
|
||||
appened to the type.
|
||||
#### type
|
||||
|
||||
**links**:
|
||||
An array of strings describing which links this event can be sent
|
||||
over. Direction matters, `'master-slave'` means the event can be
|
||||
sent from the master to slave, but can't be sent back the other way
|
||||
unless `'slave-master'` is also present in the links array.
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_event` appended to it.
|
||||
|
||||
**forwardTo**:
|
||||
Target to forward event to. Can be either `'master'` to indicate a
|
||||
slave should forward it to the master server or `'instance'` to
|
||||
indicate it should be forwarded to the instances specified by the
|
||||
`instance_id` event property. This works by using a default handler
|
||||
for the event at the links that forward it.
|
||||
#### links
|
||||
|
||||
**broadcastTo**:
|
||||
Target to broadcast this message towards. Currently only
|
||||
`'instance'` is supported and means the event will be broadcast to
|
||||
all instances downstream of the target it's sent to, but not back
|
||||
from where it came from. This means that sending an event to a
|
||||
slave from an instance will cause it to be broadcast to all
|
||||
instances of that slave except for the instance it came from.
|
||||
An array of strings describing which links this event can be sent over.
|
||||
Direction matters, `"master-slave"` means the event can be sent from the master to the slave, but can't be sent back the other way, unless `"slave-master"` is also present in the links array.
|
||||
|
||||
**eventProperties**:
|
||||
Object with properties mapping to a JSON schema of that property
|
||||
that specifies what's valid to send in the event. This is
|
||||
equivalent to using the `properties` keyword in JSON schema except
|
||||
that the properties specified are implicitly required and additional
|
||||
properties are not allowed. See [this guide][guide] for an
|
||||
introduction to writing JSON schemas
|
||||
The available endpoints are `master`, `slave`, `instance`, and `control`.
|
||||
Master talks with slave and control, and slave talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
|
||||
See `forwardTo` and `broadcastTo` for ways to forward an event to the next link in a chain.
|
||||
|
||||
The forwardTo and broadcastTo can be combined such that specifying
|
||||
`'master'` as the forwardTo value and `'instance'` as the broadcastTo
|
||||
value will cause the event to be broadcast to all instances in the
|
||||
cluster. For this to work you will need to specify instance-slave,
|
||||
slave-master and master-slave, slave-instance as the links.
|
||||
#### forwardTo
|
||||
|
||||
Keep in mind when forwarding events that if the target an event is being
|
||||
forwarded to is not online the event will be dropped. If you need a
|
||||
confirmation that the message was received use a request.
|
||||
Target to forward an event to.
|
||||
Can either be `"master"`, to indicate a slave should forward it to the master server, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
|
||||
This works by using a default handler for the event at the links that forward it.
|
||||
|
||||
#### broadcastTo
|
||||
|
||||
Target to broadcast this message towards.
|
||||
A value of "instance" means the event will be broadcast to all instances downstream of the target it's sent to, but not back from where it came from.
|
||||
Currently, only "instance" is supported.
|
||||
This means that sending the event to a slave from an instance will cause it to be broadcast to all instances of that slave except for the instance it came from.
|
||||
|
||||
#### eventRequired
|
||||
|
||||
By default all properties defined in `eventProperties` are required to be present in the payload for the event.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
|
||||
#### eventProperties
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the event.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas.
|
||||
|
||||
The forwardTo and broadcastTo can be combined such that specifying `"master"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
|
||||
For this to work, you will need to specify `instance-slave`, `slave-master`, `master-slave`, and `slave-instance` as the links.
|
||||
|
||||
Keep in mind when forwarding events that if the target an event is being forwarded to is not online, the event will be dropped.
|
||||
Use a request if you need a confirmation that the message was received.
|
||||
|
||||
### Definining Requests
|
||||
|
||||
Requests are defined as properties of the messages object exported by
|
||||
`info.js` that map to instances of the `Request` class from `lib/link`.
|
||||
The name of the property correspond to the handler invoked on the plugin
|
||||
class. The Request constructor takes an object of properties that define
|
||||
the event, for example the following could be defined in `info.js`:
|
||||
Requests are defined as properties of the messages object exported by `info.js` that map to instances of the `Request` class from `lib/link`.
|
||||
The name of the property corresponds to the handler invoked on the plugin class.
|
||||
The Request constructor takes an object of properties that define the event.
|
||||
For example, the following could be defined in `info.js`:
|
||||
|
||||
messages: {
|
||||
reportFrobnication: new link.Request({
|
||||
type: 'foo_frobber:report_frobnication',
|
||||
links: ['master-slave', 'slave-instance'],
|
||||
forwardTo: 'instance',
|
||||
requestProperties: {
|
||||
"verbosity": { type: "integer" },
|
||||
```js
|
||||
messages: {
|
||||
reportFrobnication: new libLink.Request({
|
||||
type: "foo_frobber:report_frobnication",
|
||||
links: ["master-slave", "slave-instance"],
|
||||
forwardTo: "instance",
|
||||
requestRequired: ["verbosity"],
|
||||
requestProperties: {
|
||||
"verbosity": { type: "integer" },
|
||||
"special": { type: "boolean" },
|
||||
},
|
||||
responseProperties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
responseProperties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
This specifies a request that can be sent from the master to a slave,
|
||||
and from a slave to an instance. The request data must contain the
|
||||
property `verbosity` with an integer number as the value as well as the
|
||||
`instance_id` property (implied by `forwardTo: 'instance'`), and the
|
||||
response sent must contain a `report` property mapping to an array of
|
||||
strings. When received by a slave it will also be forwarded to the
|
||||
instance specified by `instance_id`.
|
||||
This specifies a request that can be sent from the master to a slave, and from a slave to an instance.
|
||||
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
|
||||
It also defines that the response sent must contain a `report` property mapping to an array of strings.
|
||||
When received by a slave, it will also be forwarded to the instance specified by `instance_id`.
|
||||
|
||||
The following properties are recognized by the Request constructor:
|
||||
|
||||
**type**:
|
||||
The message type sent over the wire. This can be any string but
|
||||
it must be unique across all plugins and it's recommended that it is
|
||||
of format `plugin_name:message_name`. The suffix `_request` will be
|
||||
appened to the type for the request message sent, and the suffix
|
||||
`_response` will be appended to the type for the response.
|
||||
#### type
|
||||
|
||||
**links**:
|
||||
An array of strings describing which links this request can be sent
|
||||
over. Direction matters, `'master-slave'` means the request can be
|
||||
sent from the master to slave and the slave can reply to the master,
|
||||
but the slave can't sent a request to the master unless
|
||||
`'slave-master'` is also present in the links array.
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_request` appended to it for the request and `_response` appended to it for the response.
|
||||
|
||||
**forwardTo**:
|
||||
Target to forward request to. Can be either `'master'` to indicate
|
||||
a slave should forward it to the master server when receiving it
|
||||
from an instance or `'instance'` to indicate it should be forwarded
|
||||
to the instances specified by the `instance_id` request property.
|
||||
This works by using a default handler for the request at the links
|
||||
that forward it.
|
||||
#### links
|
||||
|
||||
**requestProperties**:
|
||||
Object with properties mapping to a JSON schema of that property
|
||||
that specifies what's valid to send in the request. This is
|
||||
equivalent to using the `properties` keyword in JSON schema except
|
||||
that the properties specified are implicitly required and additional
|
||||
properties are not allowed. See [this guide][guide] for an
|
||||
introduction to writing JSON schemas
|
||||
An array of strings describing which links this request can be sent over.
|
||||
Direction matters; `"master-slave"` means the request can be sent from the master to the slave and the slave can reply to the master, but the slave can't send a request to the master unless `"slave-master"` is also present in the links array.
|
||||
|
||||
**responseProperties**:
|
||||
Same as the requestProperties only for the response sent back by the
|
||||
target.
|
||||
The available endpoints are `master`, `slave`, `instance`, and `control`.
|
||||
Master talks with slave and control, and slave talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
|
||||
See `forwardTo` for ways to forward a request to the next link in a chain.
|
||||
|
||||
#### forwardTo
|
||||
|
||||
Target to forward the request to.
|
||||
Can either be `"master"` to indicate a slave should forward it to the master server when receiving it from an instance, or `"instance"` to indicate it should be forwarded to the instances specified by the `instance_id` request property.
|
||||
This works by using a default handler for the request by the links that forward it.
|
||||
|
||||
#### requestRequired
|
||||
|
||||
By default all properties defined in `requestProperties` are required to be present in the payload for the request.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
|
||||
#### requestProperties
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the request.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas
|
||||
|
||||
#### responseRequired
|
||||
|
||||
Same as the requestRequired only for the response sent back by the target.
|
||||
|
||||
#### responseProperties
|
||||
|
||||
Same as the requestProperties only for the response sent back by the target.
|
||||
|
||||
|
||||
Sending Link Messages
|
||||
---------------------
|
||||
## Sending Link Messages
|
||||
|
||||
Link messages are sent by calling the `.send()` method on the
|
||||
Event/Request instance with the link you want to send it over and the
|
||||
data to send. For instance plugins the link is the instance itself
|
||||
which is accessible through the `.instance` property on the plugin. The
|
||||
`.info` property of the plugin class exposes the data exported from the
|
||||
plugin's `info.js` module. In other words:
|
||||
Link messages are sent by calling the `.send()` method on the Event/Request instance with the link you want to send it over and the data to send.
|
||||
For `InstancePlugin` code the link to the slave is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
|
||||
The `.info` property of the plugin class exposes the data exported from the plugin's `info.js` module.
|
||||
In other words:
|
||||
|
||||
// In an InstancePlugin class
|
||||
async frobnicate() {
|
||||
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
|
||||
```js
|
||||
// In an InstancePlugin class
|
||||
async frobnicate() {
|
||||
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
|
||||
}
|
||||
```
|
||||
|
||||
For the Request class the send method is async and returns the response data received from the target it was sent to, or throws an error if the request failed.
|
||||
|
||||
|
||||
### Handling connection events
|
||||
|
||||
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
|
||||
The most important is the prepare disconnect for the link between master and slave.
|
||||
This is signaled to `MasterPlugin` classes via the `onPrepareSlaveDisconnect` hook and to `InstancePlugin` classes via the `onPrepareMasterDisconnect` hook.
|
||||
|
||||
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
|
||||
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
|
||||
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `SlaveConnection` class and `Slave` class on the master and slave respectively.
|
||||
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
||||
|
||||
```js
|
||||
if (this.slave.connected) {
|
||||
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
|
||||
}
|
||||
```
|
||||
|
||||
If the event or request needs to be sent to the master it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
|
||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `onMasterConnectionEvent` and `onSlaveConnectionEvent` hooks.
|
||||
|
||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onMasterConnectionEvent` for `InstancePlugin` classes and through `onSlaveConnectionEvent` for `MasterPlugin` classes.
|
||||
This is raised when the connection between the master and slave in question is lost, most likely due to networking issues.
|
||||
When in the dropped state the slave will keep trying to reconnect to the master server in order to re-establish it, and if successful no events or requests will be lost.
|
||||
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
|
||||
This means that if your plugin sends a lot of events or requests, they can end up being queued up in a buffer and sent out all at once the connection is re-estabished.
|
||||
To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised.
|
||||
|
||||
|
||||
## Collecting Statistics
|
||||
|
||||
Clusterio comes with its own Prometheus client implementation, one part due to Not Invented Here and another part due to collectors in prom-client being difficult to get to work nicely with collecting data from plugins optionally loaded at runtime on different computers.
|
||||
|
||||
In its simplest form collecting data from plugins consists of defining the metric and updating it somewhere in the plugin code.
|
||||
For example:
|
||||
|
||||
```js
|
||||
const { Counter } = require("@clusterio/lib/prometheus");
|
||||
|
||||
const fooMetric = new Counter(
|
||||
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
|
||||
);
|
||||
|
||||
// Somewhere in the master plugin code
|
||||
fooMetric.inc();
|
||||
```
|
||||
|
||||
This works for master plugins, and the metric will be automatically available through the /metric HTTP endpoint.
|
||||
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
|
||||
|
||||
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
|
||||
|
||||
```js
|
||||
const { Counter } = require("@clusterio/lib/prometheus");
|
||||
|
||||
const barMetric = new Gauge(
|
||||
"clusterio_foo_frobber_bar_metric", "Bar instance level",
|
||||
{ labels: ["instance_id"] }
|
||||
);
|
||||
|
||||
// Somewhere in the instance plugin code
|
||||
barMetric.labels(String(this.instance.id)).set(someValue);
|
||||
```
|
||||
|
||||
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the master server on slaves.
|
||||
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a slave.
|
||||
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
|
||||
|
||||
For statistics you need to update on collection there's an `onMetrics` hook on both master and instance plugins that is run before the metrics in the default registry are collected.
|
||||
|
||||
|
||||
## Adding Custom Commands to clusterioctl
|
||||
|
||||
The control entrypoint for plugins allows you to extend clustectl with your own commands.
|
||||
The creation of custom commands typically starts with defining a command tree for the plugin:
|
||||
|
||||
```js
|
||||
const { Command, CommandTree } = require("@clusterio/lib/command");
|
||||
const fooFrobberCommands = new CommandTree({
|
||||
name: "foo-frobber", description: "Foo Frobber Plugin commands"
|
||||
});
|
||||
```
|
||||
|
||||
Then commands are added to the the plugin's command tree:
|
||||
|
||||
```js
|
||||
const info = require("./info");
|
||||
|
||||
fooFrobberCommands.add(new Command({
|
||||
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
|
||||
yargs.positional("level", {
|
||||
describe: "type of frobnication", type: "string"
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await info.messages.frobnicate.send(control, {
|
||||
instance_name: "Console",
|
||||
content: args.message,
|
||||
});
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
|
||||
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
|
||||
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
|
||||
|
||||
Note that messages sent from clusterioctl needs to have `"control-master"` as a part of the links array for it to be accepted by the master server, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the master.
|
||||
|
||||
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Control plugin:
|
||||
|
||||
```js
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
|
||||
class ControlPlugin extends libPlugin.BaseControlPlugin {
|
||||
async addCommands(rootCommand) {
|
||||
rootCommand.add(fooFrobberCommands);
|
||||
}
|
||||
}
|
||||
|
||||
For the Request class the send method is async and returns the response
|
||||
data received from the target it was sent to, or throws an error if the
|
||||
request failed.
|
||||
|
||||
|
||||
Collecting Statistics
|
||||
---------------------
|
||||
|
||||
Clusterio comes with it's own Prometheus client implementation, one part
|
||||
due to Not Invented Here and another part due to collectors in
|
||||
prom-client being difficult to get to work nicely with collecting data
|
||||
from plugins optionally loaded at runtime on different computers.
|
||||
|
||||
In its simplest form collecting data from a plugins consists of defining
|
||||
the metric and updating it somewhere in the plugin code. For example:
|
||||
|
||||
const { Counter } = require('lib/prometheus');
|
||||
|
||||
const fooMetric = new Counter(
|
||||
'clusterio_foo_frobber_foo_metric', "Measures the level of foo",
|
||||
);
|
||||
|
||||
// Somewhere in the master plugin code
|
||||
fooMetric.inc();
|
||||
|
||||
This works for master plugins, and the metric will be automatically
|
||||
available through the /metric HTTP endpoint. It's recommended that
|
||||
plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the
|
||||
naming scheme.
|
||||
|
||||
For metrics that are per-instance you must define an `instance_id` label and
|
||||
set it accordingly, for example:
|
||||
|
||||
const { Counter } = require('lib/prometheus');
|
||||
|
||||
const barMetric = new Gauge(
|
||||
'clusterio_foo_frobber_bar_metric', "Bar instance level",
|
||||
{ labels: ['instance_id'] }
|
||||
);
|
||||
|
||||
// Somewhere in the instance plugin code
|
||||
barMetric.labels(String(this.instance.config.get("instance.id"))).set(someValue);
|
||||
|
||||
Metrics are automatically registered to the default registry, and this
|
||||
default registry is automatically polled by the master server on slaves.
|
||||
This means that it's important that you place the definition of the
|
||||
metric at module level so that it's not created more than once over the
|
||||
lifetime of a slave. Since the metrics remember their values and would
|
||||
continue to be exported after an instance is shutdown there's code at
|
||||
instance shutdown that removes all the values where the `instance_id`
|
||||
label matches the id of the instance shut down.
|
||||
|
||||
For statistics you need to update on collection there's an `onMetrics`
|
||||
hook on both master and instance plugins that is runned before the
|
||||
metrics in the default registry is collected.
|
||||
module.exports = {
|
||||
ControlPlugin,
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
@@ -0,0 +1,4 @@
|
||||
# External plugins directory
|
||||
|
||||
Plugins placed here will have their npm dependencies incorporated in the shared pnpm storage for this repository when `pnpm install` is invoked.
|
||||
Use this to enable `--dev-plugin` to work with plugins developed outside of the Clusterio repository.
|
||||
@@ -1,48 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
let authSecret = null;
|
||||
function setAuthSecret(secret) {
|
||||
authSecret = secret;
|
||||
}
|
||||
|
||||
function middleware(req, res, next) {
|
||||
// This should not happen
|
||||
if (!authSecret) {
|
||||
return res.status(500).send({ auth: false, message: 'Secret not set', endpoint: req.route.path });
|
||||
}
|
||||
|
||||
var token = req.headers['x-access-token'];
|
||||
if (!token) {
|
||||
return res.status(401).send({ auth: false, message: 'No token provided.', endpoint: req.route.path });
|
||||
}
|
||||
|
||||
jwt.verify(token, authSecret, function(err, decoded) {
|
||||
if (err) {
|
||||
return res.status(401).send({ auth: false, message: 'Failed to authenticate token.', endpoint: req.route.path });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
async function check(token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// This should not happen
|
||||
if (!authSecret) {
|
||||
resolve({ ok: false, msg: 'Secret not set' });
|
||||
}
|
||||
|
||||
jwt.verify(token, authSecret, function(err, decoded) {
|
||||
if (err) {
|
||||
resolve({ok:false, msg:err})
|
||||
} else {
|
||||
resolve({ok:true, msg:"Successfully authenticated"});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setAuthSecret,
|
||||
middleware,
|
||||
check,
|
||||
};
|
||||
@@ -1,345 +0,0 @@
|
||||
/**
|
||||
* @file Core definitions for the configuration system
|
||||
* @author Hornwitser
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const util = require("util");
|
||||
const crypto = require("crypto");
|
||||
const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
|
||||
const classes = require("./classes");
|
||||
|
||||
|
||||
class MasterGroup extends classes.ConfigGroup { }
|
||||
MasterGroup.groupName = "master"
|
||||
MasterGroup.define({
|
||||
name: "database_directory",
|
||||
title: "Database directory",
|
||||
description: "Directory where item and configuration data is stored.",
|
||||
type: "string",
|
||||
initial_value: "database",
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "factorio_directory",
|
||||
description: "Path to directory to look for factorio installs",
|
||||
type: "string",
|
||||
initial_value: "factorio"
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "http_port",
|
||||
title: "HTTP Port",
|
||||
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
|
||||
type: "number",
|
||||
optional: true
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "https_port",
|
||||
title: "HTTPS Port",
|
||||
description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
initial_value: 8443,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "web_root",
|
||||
title: "Web Root",
|
||||
description: "Sub-path to where the web interface is hosted. Needed when proxying the web interface.",
|
||||
type: "string",
|
||||
initial_value: "/",
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "tls_certificate",
|
||||
title: "TLS Certificate",
|
||||
description: "Path to the certificate to use for HTTPS.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
initial_value: "database/certificates/cert.crt",
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "tls_private_key",
|
||||
title: "TLS Private Key",
|
||||
description: "Path to the private key to use for HTTPS.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
initial_value: "database/certificates/cert.key",
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "auth_secret",
|
||||
title: "Master Authentication Secret",
|
||||
description:
|
||||
"Secret used to generate and verify authentication tokens."
|
||||
+" Should be a long string of random letters and numbers."
|
||||
+" Do not share this.",
|
||||
type: "string",
|
||||
initial_value: async function() {
|
||||
console.log("Generating new master authentication secret");
|
||||
let asyncRandomBytes = util.promisify(crypto.randomBytes);
|
||||
let bytes = await asyncRandomBytes(256);
|
||||
return bytes.toString("base64");
|
||||
},
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "heartbeat_interval",
|
||||
title: "Heartbeat Interval",
|
||||
description: "Interval heartbeats are sent out on WebSocket connections",
|
||||
type: "number",
|
||||
initial_value: 60,
|
||||
});
|
||||
MasterGroup.finalize();
|
||||
|
||||
class MasterConfig extends classes.Config { }
|
||||
MasterConfig.registerGroup(MasterGroup);
|
||||
|
||||
|
||||
class SlaveGroup extends classes.ConfigGroup {}
|
||||
SlaveGroup.groupName = "slave";
|
||||
SlaveGroup.define({
|
||||
name: "name",
|
||||
description: "Name of the slave",
|
||||
type: "string",
|
||||
initial_value: "New Slave",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "id",
|
||||
description: "ID of the slave",
|
||||
type: "number",
|
||||
initial_value: () => Math.random() * 2**31 | 0,
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "factorio_directory",
|
||||
description: "Path to directory to look for factorio installs",
|
||||
type: "string",
|
||||
initial_value: "factorio"
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "instances_directory",
|
||||
description: "Path to directory to store instances in.",
|
||||
type: "string",
|
||||
initial_value: "instances",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "master_url",
|
||||
description: "URL to connect to the master server at",
|
||||
type: "string",
|
||||
initial_value: "https://localhost:8443/",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "master_token",
|
||||
description: "Token to authenticate to master server with.",
|
||||
type: "string",
|
||||
initial_value: "enter token here",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "public_address",
|
||||
description: "Public facing address players should connect to in order to join instances on this slave",
|
||||
type: "string",
|
||||
initial_value: "localhost",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "reconnect_delay",
|
||||
title: "Reconnect Delay",
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initial_value: 5,
|
||||
});
|
||||
SlaveGroup.finalize();
|
||||
|
||||
class SlaveConfig extends classes.Config { }
|
||||
SlaveConfig.registerGroup(SlaveGroup);
|
||||
|
||||
|
||||
class InstanceGroup extends classes.ConfigGroup { }
|
||||
InstanceGroup.groupName = "instance"
|
||||
InstanceGroup.define({
|
||||
name: "name",
|
||||
type: "string",
|
||||
initial_value: "New Instance",
|
||||
});
|
||||
InstanceGroup.define({
|
||||
name: "id",
|
||||
description: "ID of the slave",
|
||||
type: "number",
|
||||
initial_value: () => Math.random() * 2**31 | 0,
|
||||
});
|
||||
InstanceGroup.define({
|
||||
name: "assigned_slave",
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
InstanceGroup.finalize();
|
||||
|
||||
class FactorioGroup extends classes.ConfigGroup { }
|
||||
FactorioGroup.groupName = "factorio";
|
||||
FactorioGroup.define({
|
||||
name: "version",
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
type: "string",
|
||||
initial_value: "latest",
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "game_port",
|
||||
description: "UDP port to run game on, uses a random port if null",
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "rcon_port",
|
||||
description: "TCP port to run RCON on, uses a random port if null",
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "rcon_password",
|
||||
description: "Password for RCON, randomly generated if null",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "settings",
|
||||
description: "Settings overridden in server-settings.json",
|
||||
type: "object",
|
||||
initial_value: {
|
||||
"tags": ["clusterio"],
|
||||
"auto_pause": false,
|
||||
},
|
||||
});
|
||||
FactorioGroup.finalize();
|
||||
|
||||
class InstanceConfig extends classes.Config { }
|
||||
InstanceConfig.registerGroup(InstanceGroup);
|
||||
InstanceConfig.registerGroup(FactorioGroup);
|
||||
|
||||
|
||||
function validateGroup(pluginInfo, groupName) {
|
||||
if (!pluginInfo[groupName] instanceof classes.PluginConfigGroup) {
|
||||
throw new Error(
|
||||
`Expected ${groupName} for ${pluginInfo.name} to be an instance of PluginConfigGroup`
|
||||
);
|
||||
}
|
||||
|
||||
if (pluginInfo[groupName].groupName !== pluginInfo.name) {
|
||||
throw new Error(
|
||||
`Expected ${groupName} for ${pluginInfo.name} to be named after the plugin`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the config groups for the provided plugin infos
|
||||
*/
|
||||
function registerPluginConfigGroups(pluginInfos) {
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
if (pluginInfo.MasterConfigGroup) {
|
||||
validateGroup(pluginInfo, "MasterConfigGroup");
|
||||
MasterConfig.registerGroup(pluginInfo.MasterConfigGroup);
|
||||
|
||||
} else {
|
||||
class MasterConfigGroup extends classes.PluginConfigGroup { }
|
||||
MasterConfigGroup.groupName = pluginInfo.name;
|
||||
MasterConfigGroup.finalize();
|
||||
MasterConfig.registerGroup(MasterConfigGroup);
|
||||
}
|
||||
|
||||
if (pluginInfo.instanceEntrypoint) {
|
||||
if (pluginInfo.InstanceConfigGroup) {
|
||||
validateGroup(pluginInfo, "InstanceConfigGroup");
|
||||
InstanceConfig.registerGroup(pluginInfo.InstanceConfigGroup);
|
||||
|
||||
} else {
|
||||
class InstanceConfigGroup extends classes.PluginConfigGroup { }
|
||||
InstanceConfigGroup.groupName = pluginInfo.name;
|
||||
InstanceConfigGroup.finalize();
|
||||
InstanceConfig.registerGroup(InstanceConfigGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock configs from adding more groups and make them usable
|
||||
*/
|
||||
function finalizeConfigs() {
|
||||
MasterConfig.finalize();
|
||||
SlaveConfig.finalize();
|
||||
InstanceConfig.finalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Yargs config command
|
||||
*
|
||||
* Can be passed to yargs.command to implement a config command. Use
|
||||
* handleConfigCommand to do the requested action.
|
||||
*/
|
||||
function configCommand(yargs) {
|
||||
yargs
|
||||
.command("set <field> [value]", "Set config field")
|
||||
.command("show <field>", "Show value of the given config field")
|
||||
.command("list", "List all configuration fields and their values")
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle yargs command
|
||||
*
|
||||
* Handle the actions that are made available by configCommand.
|
||||
*/
|
||||
async function handleConfigCommand(args, instance, configPath) {
|
||||
let command = args._[1];
|
||||
|
||||
if (command === "list") {
|
||||
for (let GroupClass of instance.constructor.groups.values()) {
|
||||
for (let def of GroupClass.definitions.values()) {
|
||||
let value = instance.get(def.fullName);
|
||||
console.log(`${def.fullName} ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (command === "show") {
|
||||
try {
|
||||
console.log(instance.get(args.field));
|
||||
} catch (err) {
|
||||
if (err instanceof classes.InvalidField) {
|
||||
console.error(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (command === "set") {
|
||||
if (args.value === undefined) {
|
||||
args.value = null;
|
||||
}
|
||||
|
||||
try {
|
||||
instance.set(args.field, args.value);
|
||||
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
|
||||
} catch (err) {
|
||||
if (err instanceof classes.InvalidField || err instanceof classes.InvalidValue) {
|
||||
console.error(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
MasterGroup,
|
||||
SlaveGroup,
|
||||
InstanceGroup,
|
||||
FactorioGroup,
|
||||
|
||||
MasterConfig,
|
||||
SlaveConfig,
|
||||
InstanceConfig,
|
||||
|
||||
registerPluginConfigGroups,
|
||||
finalizeConfigs,
|
||||
configCommand,
|
||||
handleConfigCommand,
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @file Configuration System
|
||||
* @author Hornwitser
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
...require("./classes"),
|
||||
...require("./definitions"),
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Errors thrown in Clusterio
|
||||
* @module
|
||||
*/
|
||||
|
||||
class CommandError extends Error { }
|
||||
class RequestError extends Error { }
|
||||
|
||||
// Signal for messages that fail validation
|
||||
class InvalidMessage extends Error {
|
||||
constructor(msg, errors) {
|
||||
super(msg);
|
||||
this.errors = errors || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown when WebSocket authentication failed
|
||||
class AuthenticationFailed extends Error { }
|
||||
|
||||
// Errror class for known errors occuring during startup
|
||||
class StartupError extends Error { }
|
||||
|
||||
// Errors outside of our control.
|
||||
class EnvironmentError extends Error { }
|
||||
|
||||
// Errors caused by plugins
|
||||
class PluginError extends Error {
|
||||
constructor(pluginName, original) {
|
||||
super(`PluginError: ${original.message}`);
|
||||
this.pluginName = pluginName;
|
||||
this.original = original;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CommandError,
|
||||
RequestError,
|
||||
InvalidMessage,
|
||||
AuthenticationFailed,
|
||||
StartupError,
|
||||
EnvironmentError,
|
||||
PluginError,
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
...require('lib/factorio/locale'),
|
||||
...require('lib/factorio/patch'),
|
||||
...require('lib/factorio/server'),
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Function for parsing factorio locale from a local factorio install
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const ini = require("ini");
|
||||
|
||||
/**
|
||||
* Gets factorios locale as an object, does not respect mods.
|
||||
*
|
||||
* @param {string} factorioDirectory
|
||||
* @param {string} languageCode
|
||||
* @param {function} callback
|
||||
*/
|
||||
async function getLocale(dataDir, languageCode) {
|
||||
if (typeof dataDir !== "string") throw new TypeError("dataDir must be a string");
|
||||
if (typeof languageCode !== "string") throw new TypeError("languageCode must be a string");
|
||||
|
||||
let localeFilePath = path.join(dataDir, "base", "locale", languageCode, "base.cfg");
|
||||
let content = await fs.readFile(localeFilePath, "utf8");
|
||||
return ini.parse(content);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLocale,
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
/**
|
||||
* Returns the newest file in a directory
|
||||
*
|
||||
* @param {string} directory - The directory to look for the newest file in.
|
||||
* @param {function(string): boolean} filter -
|
||||
* Optional function to filter out files to ignore. Should return true
|
||||
* if the file is to be considered.
|
||||
* @returns {?string}
|
||||
* Name of the file with the newest timestamp or null if the
|
||||
* directory contains no files.
|
||||
*/
|
||||
async function getNewestFile(directory, filter = (name) => true) {
|
||||
let newestTime = new Date(0);
|
||||
let newestFile = null;
|
||||
for (let entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isFile()) {
|
||||
let stat = await fs.stat(path.join(directory, entry.name));
|
||||
if (filter(entry.name) && stat.mtime > newestTime) {
|
||||
newestTime = stat.mtime;
|
||||
newestFile = entry.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newestFile;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNewestFile,
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
const fileOps = require("./fileOps");
|
||||
module.exports = fileOps;
|
||||
@@ -1,44 +0,0 @@
|
||||
const forge = require("node-forge");
|
||||
const fs = require("fs-extra");
|
||||
const util = require("util");
|
||||
|
||||
const hrtime = process.hrtime.bigint;
|
||||
|
||||
|
||||
module.exports = async function({
|
||||
bits = 2048,
|
||||
sslCertPath,
|
||||
sslPrivKeyPath,
|
||||
doLogging = false,
|
||||
} = {}){
|
||||
function log(t) {
|
||||
if(doLogging) console.log("lib/generateSSLcert.js | "+t);
|
||||
}
|
||||
log("Generating SSL certificate with "+bits+" bits at "+sslCertPath);
|
||||
|
||||
let startNs = hrtime();
|
||||
let keypair = await util.promisify(forge.pki.rsa.generateKeyPair)({bits: bits, e: 0x10001});
|
||||
log("Generated 2048bit keypair in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
|
||||
|
||||
startNs = hrtime();
|
||||
let cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keypair.publicKey;
|
||||
cert.serialNumber = "01";
|
||||
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date(Date.now()+(1000*60*60*24*365)); // valid for one year
|
||||
|
||||
// XXX This should probably be limited to the actual hostname
|
||||
let attrs = [{ name: "commonName", value: "*" }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
|
||||
// self-sign certificate
|
||||
cert.sign(keypair.privateKey);
|
||||
|
||||
// Write certificate and private key to pem files
|
||||
await fs.outputFile(sslPrivKeyPath, forge.pki.privateKeyToPem(keypair.privateKey));
|
||||
await fs.outputFile(sslCertPath, forge.pki.certificateToPem(cert));
|
||||
|
||||
log("Generated certificate in: "+(Number(hrtime()-startNs) / 1e6)+" ms");
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Collection of small utilites that are useful in multiple places.
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return a string describing the type of the value passed
|
||||
*
|
||||
* Works the same as typeof, excpet that null and array types get their
|
||||
* own string.
|
||||
*
|
||||
* @param value - value to return the type of.
|
||||
* @returns {string} basic type of the value passed.
|
||||
*/
|
||||
function basicType(value) {
|
||||
if (value === null) { return "null"; }
|
||||
if (value instanceof Array) { return "array"; }
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
basicType,
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const WebSocket = require("ws");
|
||||
|
||||
const schema = require("lib/schema");
|
||||
const errors = require("lib/errors");
|
||||
|
||||
|
||||
class WebSocketBaseConnector extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._socket = null;
|
||||
this._seq = 1;
|
||||
this._lastHeartbeat = null;
|
||||
this._heartbeatId = null;
|
||||
this._heartbeatInterval = null;
|
||||
this._lastReceivedSeq = null;
|
||||
this._sendBuffer = [];
|
||||
}
|
||||
|
||||
_dropSendBufferSeq(seq) {
|
||||
if (seq === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dropCount = 0;
|
||||
for (let index = 0; index < this._sendBuffer.length; index++) {
|
||||
if (this._sendBuffer[index].seq <= seq) {
|
||||
dropCount++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._sendBuffer.splice(0, dropCount);
|
||||
}
|
||||
|
||||
_doHeartbeat() {
|
||||
this._check(["connected"]);
|
||||
if (Date.now() - this._lastHeartbeat > 2000 * this._heartbeatInterval) {
|
||||
console.log("SOCKET | closing after heartbeat timed out");
|
||||
this._socket.close(1008, "Heartbeat timeout");
|
||||
clearInterval(this._heartbeatId);
|
||||
this._heartbeatId = null;
|
||||
|
||||
} else {
|
||||
this._socket.send(JSON.stringify({
|
||||
seq: null,
|
||||
type: "heartbeat",
|
||||
data: { seq: this._lastReceivedSeq },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
_processHeartbeat(message) {
|
||||
if (!schema.heartbeat(message)) {
|
||||
console.log("SOCKET | closing after received invalid heartbeat:", message);
|
||||
this._socket.close(1002, "Invalid heartbeat");
|
||||
console.log(schema.heartbeat.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastHeartbeat = Date.now();
|
||||
this._dropSendBufferSeq(message.data.seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sending heartbeats
|
||||
*/
|
||||
startHeartbeat() {
|
||||
this._lastHeartbeat = Date.now();
|
||||
this._heartbeatId = setInterval(() => {
|
||||
this._doHeartbeat();
|
||||
}, this._heartbeatInterval * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop sending heartbeats
|
||||
*/
|
||||
stopHeartbeat() {
|
||||
if (this._heartbeatId) {
|
||||
clearInterval(this._heartbeatId);
|
||||
this._heartbeatId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message over the socket
|
||||
*
|
||||
* @returns the sequence number of the message sent
|
||||
*/
|
||||
send(type, data) {
|
||||
this._check(["handshake", "connected", "closing"]);
|
||||
let message = { seq: this._seq, type, data };
|
||||
this._sendBuffer.push(message);
|
||||
if (["connected", "closing"].includes(this._state)) {
|
||||
this._socket.send(JSON.stringify(message));
|
||||
}
|
||||
return this._seq++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set connection state to closing
|
||||
*
|
||||
* Signal the connection is about tho close and that once the close
|
||||
* frame is received it should be considered finished.
|
||||
*/
|
||||
setClosing() {
|
||||
this._state = "closing";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector for master server clients
|
||||
*/
|
||||
class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
constructor(url, reconnectDelay) {
|
||||
super();
|
||||
|
||||
this._url = url;
|
||||
|
||||
// The following states are used in the client connector
|
||||
// new: Not connected
|
||||
// handshake: Attempting to (re)connect to server.
|
||||
// connected: Connection is online
|
||||
// closing: Connection is in the process of being closed.
|
||||
this._state = "new";
|
||||
this._reconnectId = null;
|
||||
this._sessionToken = null;
|
||||
this._connected = false;
|
||||
this._reconnectDelay = reconnectDelay;
|
||||
}
|
||||
|
||||
_check(expectedStates) {
|
||||
if (!expectedStates.includes(this._state)) {
|
||||
throw new Error(
|
||||
`Expected state ${expectedStates} but state is ${this._state}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a handshake message over the socket
|
||||
*
|
||||
* Used in the register function in order to send the register handshake
|
||||
* message over the WebSocket.
|
||||
*/
|
||||
sendHandshake(type, data) {
|
||||
this._check(["handshake"]);
|
||||
this._socket.send(JSON.stringify({ seq: null, type, data }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection with the given reason.
|
||||
*
|
||||
* Sends a close frame and disconnects the connector.
|
||||
*/
|
||||
close(code, reason) {
|
||||
if (this._state === "new") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._reconnectId) {
|
||||
clearTimeout(this._reconnectId);
|
||||
this._reconnectId = null;
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
this._state = "closing";
|
||||
this._socket.close(code, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the master server
|
||||
*/
|
||||
async connect() {
|
||||
this._check(["new"]);
|
||||
this._state = "handshake";
|
||||
|
||||
this._doConnect();
|
||||
|
||||
// Wait for the connection to be ready
|
||||
await events.once(this, "connected");
|
||||
}
|
||||
|
||||
async _doConnect() {
|
||||
let url = new URL(this._url);
|
||||
url.pathname += "api/socket";
|
||||
|
||||
// Open WebSocket to master
|
||||
console.log(`SOCKET | connecting to ${url}`)
|
||||
|
||||
this._socket = new WebSocket(url, {
|
||||
// For now we do not verify TLS certificates since the default setup is
|
||||
// to create a self-signed certificate.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
this._attachSocketHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt re-establishing the connecting again
|
||||
*/
|
||||
reconnect() {
|
||||
if (this._reconnectId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let delay = Math.random() * this._reconnectDelay;
|
||||
console.log(`SOCKET | waiting ${delay.toLocaleString("en", { maximumFractionDigits: 2 })} seconds for reconnect`)
|
||||
this._reconnectId = setTimeout(() => {
|
||||
this._reconnectId = null;
|
||||
this._doConnect();
|
||||
}, delay * 1000);
|
||||
}
|
||||
|
||||
_attachSocketHandlers() {
|
||||
this._socket.on("close", (code, reason) => {
|
||||
console.log(`SOCKET | Close (code: ${code}, reason: ${reason})`);
|
||||
// Authentication failed
|
||||
if (code === 4003) {
|
||||
this.emit("error", new errors.AuthenticationFailed("Authentication failed"));
|
||||
this._state = "closing";
|
||||
}
|
||||
|
||||
if (this._state === "closing") {
|
||||
this._lastReceivedSeq = null;
|
||||
this._sessionToken = null;
|
||||
this._sendBuffer.length = 0;
|
||||
this._state = "new";
|
||||
|
||||
if (this._connected) {
|
||||
this.emit("close");
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
} else {
|
||||
this._state = "handshake";
|
||||
this.reconnect();
|
||||
|
||||
if (this._connected) {
|
||||
this.emit("dropped");
|
||||
this._connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
});
|
||||
|
||||
this._socket.on("error", err => {
|
||||
// It's assumed that close is always called by ws
|
||||
console.error("SOCKET | Error:", err);
|
||||
});
|
||||
|
||||
/*
|
||||
this._socket.on("unexpected-response", (req, res) => {
|
||||
let data = "";
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
console.error(`SOCKET | Unexpeced Response ${res.statusCode}`);
|
||||
console.error(data);
|
||||
this._socket.emit("error", new Error(`Unexpected Response ${res.statusCode}`));
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
this._socket.on("open", () => {
|
||||
console.log("SOCKET | Open");
|
||||
});
|
||||
this._socket.on("ping", data => {
|
||||
console.log(`SOCKET | Ping (data: ${data}`);
|
||||
});
|
||||
this._socket.on("pong", data => {
|
||||
console.log(`SOCKET | Pong (data: ${data}`);
|
||||
});
|
||||
|
||||
// Handle messages
|
||||
this._socket.on("message", data => {
|
||||
let message = JSON.parse(data);
|
||||
if (this._state === "handshake") {
|
||||
this._processHandshake(message);
|
||||
|
||||
} else if (["connected", "closing"].includes(this._state)) {
|
||||
if (message.seq !== null) {
|
||||
this._lastReceivedSeq = message.seq;
|
||||
}
|
||||
|
||||
if (message.type === "heartbeat") {
|
||||
this._processHeartbeat(message);
|
||||
|
||||
} else {
|
||||
this.emit("message", message);
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(`Received message in unexpected state ${this._state}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_processHandshake(message) {
|
||||
if (!schema.serverHandshake(message)) {
|
||||
console.log("SOCKET | closing after received invalid handshake:", message);
|
||||
this._socket.close(1002, "Invalid handshake");
|
||||
return;
|
||||
}
|
||||
|
||||
let { seq, type, data } = message;
|
||||
if (type == 'hello') {
|
||||
console.log(`SOCKET | received hello from master version ${data.version}`);
|
||||
if (this._sessionToken) {
|
||||
console.log("SOCKET | Attempting resume");
|
||||
this.sendHandshake("resume", {
|
||||
session_token: this._sessionToken,
|
||||
last_seq: this._lastReceivedSeq,
|
||||
});
|
||||
} else {
|
||||
this.register();
|
||||
}
|
||||
|
||||
} else if (type === 'ready') {
|
||||
console.log("SOCKET | received ready from master");
|
||||
this._state = "connected";
|
||||
this._sessionToken = data.session_token;
|
||||
this._heartbeatInterval = data.heartbeat_interval;
|
||||
this.startHeartbeat();
|
||||
this._connected = true;
|
||||
this.emit("connected");
|
||||
|
||||
} else if (type === "continue") {
|
||||
console.log("SOCKET | resuming existing session");
|
||||
this._state = "connected";
|
||||
this._heartbeatInterval = data.heartbeat_interval;
|
||||
this.startHeartbeat();
|
||||
this._dropSendBufferSeq(data.last_seq);
|
||||
for (let message of this._sendBuffer) {
|
||||
this._socket.send(JSON.stringify(message));
|
||||
}
|
||||
this._connected = true;
|
||||
this.emit("connected");
|
||||
|
||||
} else if (type === "invalidate") {
|
||||
this._heartbeatInterval = null;
|
||||
this._lastReceivedSeq = null;
|
||||
this._sessionToken = null;
|
||||
this._sendBuffer.length = 0;
|
||||
this.emit("invalidate");
|
||||
this.register();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the link with the server
|
||||
*
|
||||
* This function is expected to be overriden by sub-classes and send the
|
||||
* register message over the socket when invoked.
|
||||
*/
|
||||
register() {
|
||||
throw Error("Abstract function");
|
||||
}
|
||||
}
|
||||
|
||||
class VirtualConnector extends events.EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.other = this;
|
||||
this._seq = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pair of virtual connector hooked into each other
|
||||
*
|
||||
* Creates two virtual connectors that are liked to each othes such that
|
||||
* a message sent on one is received by the other.
|
||||
*
|
||||
* @returns {Array} two virtuarl connectors.
|
||||
*/
|
||||
static makePair() {
|
||||
let first = new this();
|
||||
let second = new this();
|
||||
first.other = second;
|
||||
second.other = first;
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the other end of the connector
|
||||
*
|
||||
* @returns the sequence number of the message sent
|
||||
*/
|
||||
send(type, data) {
|
||||
this.other.emit('message', { seq: this._seq, type, data });
|
||||
return this._seq++;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WebSocketBaseConnector,
|
||||
WebSocketClientConnector,
|
||||
VirtualConnector,
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
...require('lib/link/link'),
|
||||
...require('lib/link/messages'),
|
||||
...require('lib/link/connectors'),
|
||||
};
|
||||
@@ -1,558 +0,0 @@
|
||||
"use strict";
|
||||
const schema = require("lib/schema");
|
||||
const errors = require("lib/errors");
|
||||
|
||||
/**
|
||||
* Represents a request sent over the link
|
||||
*
|
||||
* A request-response message that can be innitated by either party of the
|
||||
* link.
|
||||
*/
|
||||
class Request {
|
||||
|
||||
/**
|
||||
* Creates a request
|
||||
*
|
||||
* @param {string} type -
|
||||
* message type used. Will have suffix '_request' for the request
|
||||
* message and suffix '_response' for the response message.
|
||||
* @param {Array<string>} links -
|
||||
* Links this request is valid on. Takes an array of strings
|
||||
* containing "<source>-<target>" specifications.
|
||||
* @param {?string} forwardTo -
|
||||
* Optional target to forward this request to. 'instance' add
|
||||
* instance_id into the requestProperties and forward to the given
|
||||
* instance. 'master' forwards it to the master server.
|
||||
* @param {Object<string, Object>} requestProperties -
|
||||
* Mapping of property values to JSON schema specifications for the
|
||||
* properties that are valid in the data payload of this requst.
|
||||
* @param {Object<string, Object>} responseProperties -
|
||||
* Mapping of property values to JSON schema specifications for the
|
||||
* properties that are valid in the data payload of the response to
|
||||
* this requst.
|
||||
*/
|
||||
constructor({
|
||||
type, links, forwardTo = null, requestProperties = {}, responseProperties = {}
|
||||
}) {
|
||||
this.type = type;
|
||||
this.links = links;
|
||||
this.forwardTo = forwardTo;
|
||||
|
||||
this.requestType = type + '_request';
|
||||
this.responseType = type + '_response';
|
||||
|
||||
if (forwardTo === 'instance') {
|
||||
requestProperties = Object.assign(
|
||||
{"instance_id": { type: "integer" }}, requestProperties
|
||||
);
|
||||
|
||||
} else if (forwardTo !== "master" && forwardTo !== null) {
|
||||
throw new Error(`Invalid forwardTo value ${forwardTo}`);
|
||||
}
|
||||
|
||||
this._requestValidator = schema.compile({
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
properties: {
|
||||
"type": { const: this.requestType },
|
||||
"data": {
|
||||
additionalProperties: false,
|
||||
required: Object.keys(requestProperties),
|
||||
properties: requestProperties,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this._responseValidator = schema.compile({
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
properties: {
|
||||
"type": { const: this.responseType },
|
||||
"data": {
|
||||
anyOf: [
|
||||
{
|
||||
additionalProperties: false,
|
||||
required: ["seq", ...Object.keys(responseProperties)],
|
||||
properties: {
|
||||
"seq": { type: "integer" },
|
||||
...responseProperties,
|
||||
},
|
||||
},
|
||||
{
|
||||
additionalProperties: false,
|
||||
required: ["seq", "error"],
|
||||
properties: {
|
||||
"seq": { type: "integer" },
|
||||
"error": { type: "string" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a link
|
||||
*
|
||||
* Set a the validator for the response on the given link if it's a
|
||||
* source and sets the handler for the request if it is a target.
|
||||
*
|
||||
* If forwardTo was set to 'instance' the `forwardRequestToInstance`
|
||||
* method on the link is used as the default handler.
|
||||
*
|
||||
* If forwardTo was set to 'master' the `forwardRequesToMaster` method
|
||||
* on the link is used as the default handler.
|
||||
*
|
||||
* Does nothing if the link is neither a source nor a target for this
|
||||
* request.
|
||||
*
|
||||
* @param {Link} link - The link to attach to.
|
||||
* @param {Function} handler -
|
||||
* Async function to invoke with link set as this to handle the
|
||||
* request request. Only used on the target side.
|
||||
* @throws {Error} if the handler is needed and not defined.
|
||||
*/
|
||||
attach(link, handler) {
|
||||
// Source side.
|
||||
if (this.links.includes(`${link.source}-${link.target}`)) {
|
||||
link.setValidator(this.responseType, this._responseValidator);
|
||||
}
|
||||
|
||||
// Target side. Note: Source and target is reversed here.
|
||||
if (this.links.includes(`${link.target}-${link.source}`)) {
|
||||
|
||||
// Use forwarder if handler is not present.
|
||||
if (!handler) {
|
||||
if (this.forwardTo === 'instance') {
|
||||
handler = link.forwardRequestToInstance;
|
||||
} else if (this.forwardTo === "master") {
|
||||
handler = link.forwardRequestToMaster;
|
||||
}
|
||||
}
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(`Missing handler for ${this.requestType} on ${link.source}-${link.target} link`);
|
||||
}
|
||||
|
||||
link.setHandler(this.requestType, message => {
|
||||
handler.call(link, message, this).then(response => {
|
||||
if (response === undefined) {
|
||||
// XXX Should we allow implicit responses like this?
|
||||
response = {};
|
||||
}
|
||||
|
||||
if (!this._responseValidator({ seq: 0, type: this.responseType, response})) {
|
||||
console.error(this._responseValidator.errors);
|
||||
throw new Error(`Validation failed responding to ${this.requestType}`);
|
||||
}
|
||||
|
||||
link.connector.send(this.responseType, { ...response, seq: message.seq });
|
||||
|
||||
}).catch(err => {
|
||||
if (!(err instanceof errors.RequestError)) {
|
||||
console.error(`Unexpected error while responding to ${this.requestType}`, err);
|
||||
}
|
||||
link.connector.send(this.responseType, { seq: message.seq, error: err.message });
|
||||
});
|
||||
}, this._requestValidator);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request over the given link
|
||||
*
|
||||
* Sends the given data over the link as the request payload and waits
|
||||
* for the response.
|
||||
*
|
||||
* @returns {object} response data
|
||||
*/
|
||||
async send(link, data = {}) {
|
||||
// XXX validate link target/source?
|
||||
if (!this._requestValidator({ seq: 0, type: this.requestType, data})) {
|
||||
console.error(this._requestValidator.errors);
|
||||
throw new Error(`Validation failed sending ${this.requestType}`);
|
||||
}
|
||||
let seq = link.connector.send(this.requestType, data);
|
||||
let responseMessage = await link.waitFor(this.responseType, { seq });
|
||||
if (responseMessage.data.error) {
|
||||
throw new errors.RequestError(responseMessage.data.error);
|
||||
}
|
||||
return responseMessage.data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let messages = {}
|
||||
|
||||
// Connection requests
|
||||
let wsLinks = ["master-control", "control-master", "master-slave", "slave-master"];
|
||||
messages.shutdownConnection = new Request({
|
||||
type: "shutdown_connection",
|
||||
links: wsLinks,
|
||||
});
|
||||
|
||||
messages.ping = new Request({
|
||||
type: "ping",
|
||||
links: wsLinks,
|
||||
});
|
||||
|
||||
// Management requests
|
||||
messages.listSlaves = new Request({
|
||||
type: 'list_slaves',
|
||||
links: ['control-master'],
|
||||
responseProperties: {
|
||||
"list": {
|
||||
type: "array",
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
required: ["agent", "version", "name", "id", "connected"],
|
||||
properties: {
|
||||
"agent": { type: "string" },
|
||||
"version": { type: "string" },
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"connected": { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.listInstances = new Request({
|
||||
type: 'list_instances',
|
||||
links: ['control-master'],
|
||||
responseProperties: {
|
||||
"list": {
|
||||
type: "array",
|
||||
items: {
|
||||
additionalProperties: false,
|
||||
required: ["name", "id", "assigned_slave"],
|
||||
properties: {
|
||||
"name": { type: "string" },
|
||||
"id": { type: "integer" },
|
||||
"assigned_slave": { type: ["null", "integer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.setInstanceOutputSubscriptions = new Request({
|
||||
type: 'set_instance_output_subscriptions',
|
||||
links: ['control-master'],
|
||||
requestProperties: {
|
||||
"instance_ids": {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.createInstance = new Request({
|
||||
type: 'create_instance',
|
||||
links: ['control-master'],
|
||||
requestProperties: {
|
||||
"serialized_config": { type: "object" },
|
||||
},
|
||||
});
|
||||
|
||||
messages.getInstanceConfig = new Request({
|
||||
type: "get_instance_config",
|
||||
links: ["control-master"],
|
||||
requestProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
},
|
||||
responseProperties: {
|
||||
"serialized_config": { type: "object" },
|
||||
},
|
||||
});
|
||||
|
||||
messages.setInstanceConfigField = new Request({
|
||||
type: "set_instance_config_field",
|
||||
links: ["control-master"],
|
||||
requestProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"field": { type: "string" },
|
||||
"value": { type: "string" },
|
||||
},
|
||||
});
|
||||
|
||||
messages.assignInstanceCommand = new Request({
|
||||
type: 'assign_instance_command',
|
||||
links: ['control-master'],
|
||||
requestProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"slave_id": { type: "integer" },
|
||||
},
|
||||
});
|
||||
|
||||
messages.assignInstance = new Request({
|
||||
type: 'assign_instance',
|
||||
links: ['master-slave'],
|
||||
requestProperties: {
|
||||
"serialized_config": { type: "object" },
|
||||
},
|
||||
forwardTo: 'instance',
|
||||
})
|
||||
|
||||
messages.startInstance = new Request({
|
||||
type: 'start_instance',
|
||||
links: ['control-master', 'master-slave', 'slave-instance'],
|
||||
requestProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
},
|
||||
forwardTo: 'instance',
|
||||
});
|
||||
|
||||
messages.createSave = new Request({
|
||||
type: 'create_save',
|
||||
links: ['control-master', 'master-slave', 'slave-instance'],
|
||||
forwardTo: 'instance',
|
||||
});
|
||||
|
||||
messages.stopInstance = new Request({
|
||||
type: 'stop_instance',
|
||||
links: ['control-master', 'master-slave', 'slave-instance'],
|
||||
forwardTo: 'instance',
|
||||
});
|
||||
|
||||
messages.deleteInstance = new Request({
|
||||
type: 'delete_instance',
|
||||
links: ['control-master', 'master-slave'],
|
||||
forwardTo: 'instance',
|
||||
});
|
||||
|
||||
messages.sendRcon = new Request({
|
||||
type: 'send_rcon',
|
||||
links: ['control-master', 'master-slave', 'slave-instance'],
|
||||
forwardTo: 'instance',
|
||||
requestProperties: {
|
||||
"command": { type: "string" },
|
||||
},
|
||||
responseProperties: {
|
||||
"result": { type: "string" },
|
||||
}
|
||||
});
|
||||
|
||||
messages.getMetrics = new Request({
|
||||
type: 'statistics_exporter:get_metrics',
|
||||
links: ['master-slave', 'slave-instance'],
|
||||
responseProperties: {
|
||||
"results": { type: "array" },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Represents an event sent over the link
|
||||
*
|
||||
* A one way message without any response or recipt confirmation that
|
||||
* can be innitiated by either party of the link.
|
||||
*/
|
||||
class Event {
|
||||
|
||||
/**
|
||||
* Creates an event
|
||||
*
|
||||
* @param {string} type -
|
||||
* message type used. Will have suffix '_event' appended to it for
|
||||
* the messages sent through this event.
|
||||
* @param {Array<string>} links -
|
||||
* Links this event is valid on. Takes an array of strings
|
||||
* containing "<source>-<target>" specifications.
|
||||
* @param {?string} forwardTo -
|
||||
* Optional target to forward this request to. Supported values are
|
||||
* 'master' and 'instance'. If set to 'instance' an instance_id
|
||||
* property is implicity added to eventProperties.
|
||||
* @param {?string} broadcastTo -
|
||||
* Optional target to broadcast this event to. Only 'instance' is
|
||||
* currently supported. An event will only be broadcast towards
|
||||
* links that are downstream, so sending an event set to instance
|
||||
* broadcast that's sent to a slave will only be broadcast to that
|
||||
* slave's instances.
|
||||
* @param {Object<string, Object>} eventProperties -
|
||||
* Mapping of property values to JSON schema specifications for the
|
||||
* properties that are valid in the data payload of this event.
|
||||
*/
|
||||
constructor({ type, links, forwardTo = null, broadcastTo = null, eventProperties = {} }) {
|
||||
this.type = type;
|
||||
this.links = links;
|
||||
this.forwardTo = forwardTo;
|
||||
this.broadcastTo = broadcastTo;
|
||||
|
||||
if (forwardTo === 'instance') {
|
||||
eventProperties = Object.assign(
|
||||
{"instance_id": { type: "integer" }}, eventProperties
|
||||
);
|
||||
|
||||
} else if (forwardTo !== 'master' && forwardTo !== null) {
|
||||
throw new Error(`Invalid forwardTo value ${forwardTo}`);
|
||||
}
|
||||
|
||||
if (broadcastTo !== 'instance' && broadcastTo !== null) {
|
||||
throw new Error(`Invalid broadcastTo value ${broadcastTo}`);
|
||||
}
|
||||
|
||||
this.eventType = type + '_event';
|
||||
this._eventValidator = schema.compile({
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
properties: {
|
||||
"type": { const: this.eventType },
|
||||
"data": {
|
||||
additionalProperties: false,
|
||||
required: Object.keys(eventProperties),
|
||||
properties: eventProperties,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a link
|
||||
*
|
||||
* Set a the handler for the event on the given link if it's a target
|
||||
* for the event.
|
||||
*
|
||||
* If forwardTo was set to 'master' the `forwardEventToMaster` method on
|
||||
* the link is used as the default handler. If it was set to 'instance'
|
||||
* the `forwardEventToInstance` method on the link is used by default.
|
||||
*
|
||||
* If broadcastTo was set to 'instance' the `broadcastEventToInstance`
|
||||
* method on the link is invoked before the handler.
|
||||
*
|
||||
* Does nothing if the link is not a target for this event.
|
||||
*
|
||||
* @param {Link} link - The link to attach to.
|
||||
* @param {Function} handler -
|
||||
* Async function to invoke with link set as this to handle the
|
||||
* event. Only used on the target side.
|
||||
* @throws {Error} if the handler is needed and not defined.
|
||||
*/
|
||||
attach(link, handler) {
|
||||
// Target side. Note: Source and target is reversed here.
|
||||
if (this.links.includes(`${link.target}-${link.source}`)) {
|
||||
// Use forwarder if handler is not present.
|
||||
if (!handler) {
|
||||
if (this.forwardTo === 'instance') {
|
||||
handler = link.forwardEventToInstance;
|
||||
} else if (this.forwardTo === 'master') {
|
||||
handler = link.forwardEventToMaster;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.broadcastTo === 'instance'
|
||||
&& [
|
||||
'instance-slave', 'slave-master', 'control-master', 'master-slave'
|
||||
].includes(`${link.target}-${link.source}`)
|
||||
) {
|
||||
if (!handler) {
|
||||
handler = link.broadcastEventToInstance;
|
||||
} else {
|
||||
let originalHandler = handler;
|
||||
handler = async (message, event) => {
|
||||
await link.broadcastEventToInstance(message, event);
|
||||
await originalHandler.call(link, message, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!handler) {
|
||||
throw new Error(`Missing handler for ${this.eventType} on ${link.source}-${link.target} link`);
|
||||
}
|
||||
|
||||
link.setHandler(this.eventType, message => {
|
||||
// XXX Should event handlers even be allowed to be async?
|
||||
handler.call(link, message, this).catch(err => {
|
||||
console.error(`Unexpected error while handling ${this.eventType}`, err);
|
||||
});
|
||||
}, this._eventValidator);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send event over the given link
|
||||
*
|
||||
* Sends the given event data over the link.
|
||||
*/
|
||||
send(link, data = {}) {
|
||||
if (!this._eventValidator({ seq: 0, type: this.eventType, data})) {
|
||||
console.error(this._eventValidator.errors);
|
||||
throw new Error(`Validation failed sending ${this.eventType}`);
|
||||
}
|
||||
|
||||
link.connector.send(this.eventType, data);
|
||||
}
|
||||
}
|
||||
|
||||
messages.instanceStopped = new Event({
|
||||
type: 'instance_stopped',
|
||||
links: ['instance-slave'],
|
||||
});
|
||||
|
||||
messages.updateInstances = new Event({
|
||||
type: 'update_instances',
|
||||
links: ['slave-master'],
|
||||
eventProperties: {
|
||||
"instances": {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["serialized_config"],
|
||||
properties: {
|
||||
"serialized_config": { type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.instanceOutput = new Event({
|
||||
type: 'instance_output',
|
||||
links: ['instance-slave', 'slave-master', 'master-control'],
|
||||
forwardTo: 'master',
|
||||
eventProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"output": {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["source", "received", "format", "type", "message"],
|
||||
properties: {
|
||||
"source": { type: "string" },
|
||||
"received": { type: "number" },
|
||||
"format": { type: "string" },
|
||||
"time": { type: "string" },
|
||||
"level": { type: "string" },
|
||||
"file": { type: "string" },
|
||||
"type": { type: "string" },
|
||||
"action": { type: "string" },
|
||||
"message": { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Attaches all requests and events to the given link
|
||||
*
|
||||
* Loops through all builtin messages defined and attaches all of them
|
||||
* to the link. The handler used for the messageis looked up on the
|
||||
* link instance as the concatenation of the name of the message, the
|
||||
* name of message class, and 'Handler'.
|
||||
*/
|
||||
function attachAllMessages(link) {
|
||||
for (let [name, message] of Object.entries(messages)) {
|
||||
message.attach(link, link[name + message.constructor.name + 'Handler']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
Request,
|
||||
Event,
|
||||
|
||||
attachAllMessages,
|
||||
|
||||
messages,
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
/**
|
||||
Provides tools to manage mods in a clusterio install
|
||||
@module
|
||||
*/
|
||||
|
||||
const asTable = require("as-table").configure({delimiter: ' | '});
|
||||
const moment = require("moment");
|
||||
const syncRequest = require("sync-request");
|
||||
const fs = require("fs-extra");
|
||||
const https = require('follow-redirects').https;
|
||||
const factorioAPI = require("node-factorio-api");
|
||||
const sLog = require("single-line-log").stdout;
|
||||
const query = require('cli-interact').getYesNo;
|
||||
const path = require("path");
|
||||
|
||||
const fileOps = require("lib/fileOps");
|
||||
|
||||
|
||||
function ellipse(str, max){
|
||||
return str.length > (max - 3) ? str.substring(0,max-3) + '...' : str;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
|
||||
function formatBytes(size, decimals) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Lists mods for an instance as a table. Warning, handles printing internally.
|
||||
@param {Instance} instance - Instance object, see slave.js
|
||||
|
||||
@example
|
||||
var modManager = require("modManager");
|
||||
modManager.listMods(config, instance);
|
||||
// console.logs table of mods installed on instance
|
||||
// if instance.name is shared:
|
||||
// console.logs mods located in /sharedMods as a nice table
|
||||
*/
|
||||
async function listMods(config, instance) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (true) {
|
||||
factorioAPI.init(false, instance.path("instanceMods"), instance.path("saves"), instance.server.version);
|
||||
factorioAPI.authenticate({
|
||||
username: config.username,
|
||||
token: config.token,
|
||||
require_ownership: false
|
||||
}).then(token => {
|
||||
console.log("* Currently installed:");
|
||||
factorioAPI.readModZips().then(list => {
|
||||
console.log(asTable(list.map(mod => {
|
||||
return {
|
||||
name:(mod.title || mod.name),
|
||||
version:mod.version,
|
||||
date:mod.date,
|
||||
factorio_ver:mod.factorio_version,
|
||||
author:mod.author,
|
||||
}
|
||||
})));
|
||||
});
|
||||
// Had issues earlier due to bug in node-factorio-api, see https://github.com/Danacus/node-factorio-api/issues/2
|
||||
// bug is resolved, thanks to @Danacus and @brotherpot
|
||||
factorioAPI.getModsFromSaves().then(list => {
|
||||
console.log("* Mods used in save:");
|
||||
console.log(asTable(list[0].mods))
|
||||
});
|
||||
}).catch(reason => {
|
||||
console.log("Failed to authenticate to factorio.com! Please check your connection and the username+token used for authentication in config.json");
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
// list shared mods in /sharedMods/
|
||||
factorioAPI.init(false, 'sharedMods/', '', instance.server.version);
|
||||
factorioAPI.authenticate({
|
||||
username: config.username,
|
||||
token: config.token,
|
||||
require_ownership: false
|
||||
}).then(() => {
|
||||
console.log("\r\n")
|
||||
console.log("* Mods shared by all instances:")
|
||||
factorioAPI.getInstalledMods().then(mods => {
|
||||
console.log(asTable(mods));
|
||||
resolve();
|
||||
});
|
||||
}).catch(e => {
|
||||
reject(new Error(e));
|
||||
});
|
||||
}
|
||||
|
||||
// get JSON data about GH releases
|
||||
/*let res = syncRequest('GET', 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases', {"headers":{"User-Agent":"factorioClusterio"}});
|
||||
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
|
||||
let name = JSON.parse(res.getBody())[0].assets[0].name;
|
||||
console.log(JSON.parse(res.getBody()))
|
||||
*/
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Searches mods.factorio.com for mods matching a search term and prints
|
||||
the results to console as a neat looking table
|
||||
|
||||
@param {string} searchTerm What to search for
|
||||
*/
|
||||
async function findMods(config, searchTerm) {
|
||||
return new Promise((resolve, reject) => {
|
||||
factorioAPI.init(false);
|
||||
factorioAPI.authenticate({
|
||||
username: config.username,
|
||||
token: config.token,
|
||||
require_ownership: false
|
||||
}).then(token => {
|
||||
factorioAPI.searchMods(searchTerm).then(mods => {
|
||||
let result = asTable(mods.reverse().map(mod => {
|
||||
return {
|
||||
title:ellipse(mod.title, 50),
|
||||
name:ellipse(mod.name, 30),
|
||||
downloads:mod.downloads_count,
|
||||
updated: moment(mod.updated_at).format("DD.MM.YY"),
|
||||
latest_version: mod.latest_release.version,
|
||||
owner:mod.owner,
|
||||
//license: mod.license_name,
|
||||
}
|
||||
}));
|
||||
resolve(result);
|
||||
// console.log(result);
|
||||
});
|
||||
}).catch(e => {
|
||||
if (e.contains("Insufficient information")) console.log("We are missing some parameters required for logging in! Make sure your factorio username and token are specified in config.json.");
|
||||
reject(new Error(e));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function addMod(config, term, instance) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (instance.name == "shared") {
|
||||
var modDirectory = "sharedMods";
|
||||
} else {
|
||||
var modDirectory = instance.path("instanceMods");
|
||||
}
|
||||
if (term && term.length > 0) {
|
||||
if (term.includes("mods.factorio.com/mods/")) { // https://mods.factorio.com/mods/Danielv123/ChooChoo
|
||||
// treat it as a link download
|
||||
factorioAPI.init(false, modDirectory, "", instance.server.version);
|
||||
factorioAPI.authenticate({
|
||||
username: config.username,
|
||||
token: config.token,
|
||||
require_ownership: false
|
||||
}).then(() => {
|
||||
factorioAPI.searchMods(term.split("/")[term.split("/").length-1]).then(body => {
|
||||
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
|
||||
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
|
||||
console.log("Mod downloaded");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} else if (term.includes("github.com")) { // https://github.com/Danielv123/factorioClusterioMod
|
||||
// treat it as a github releases download
|
||||
let apiURL = "https://api.github.com/repos/" + term.split("/")[3] + "/" + term.split("/")[4] + "/releases"; // https://api.github.com/repos/Danielv123/factorioClusterioMod/releases
|
||||
|
||||
let res = syncRequest('GET', apiURL, {"headers":{"User-Agent":"factorioClusterio"}});
|
||||
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
|
||||
let name = JSON.parse(res.getBody())[0].assets[0].name;
|
||||
if (url) {
|
||||
console.log("Downloading " + url);
|
||||
let file = fs.createWriteStream(path.join(modDirectory, name));
|
||||
let bytesDownloaded = 0;
|
||||
let downloadStartTime = Date.now();
|
||||
https.get(url, function(response) {
|
||||
let bytesToDownload = response.headers['content-length'];
|
||||
response.pipe(file);
|
||||
response.on("data", chunk => {
|
||||
bytesDownloaded += chunk.length;
|
||||
let downloadTimeElapsed = Date.now() - downloadStartTime;
|
||||
let bytesPerSecond = bytesDownloaded / (downloadTimeElapsed/1000);
|
||||
let timeToComplete = Math.floor((bytesToDownload - bytesDownloaded)/bytesPerSecond);
|
||||
sLog("Downloading "+name+" "+(bytesDownloaded/bytesToDownload*100).toPrecision(3)+"% ("+formatBytes(bytesDownloaded)+" / "+formatBytes(bytesToDownload)+")\r\n"+
|
||||
"about "+timeToComplete+" seconds remaining ("+formatBytes(bytesPerSecond)+"/s)");
|
||||
}).on("end", x => {
|
||||
sLog.clear();
|
||||
console.log(" Downloaded in "+moment.utc(moment.duration(Date.now()-downloadStartTime).as('milliseconds')).format('mm:ss')); // because sLog.clear() doesn't seem to do the job of ending the line properly
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// treat it as a search and download the top result
|
||||
factorioAPI.init(false, modDirectory, "", instance.server.version);
|
||||
factorioAPI.authenticate({
|
||||
username: config.username,
|
||||
token: config.token,
|
||||
require_ownership: false
|
||||
}).then(() => {
|
||||
factorioAPI.searchMods(term).then(body => {
|
||||
console.log(body)
|
||||
console.log("Im gonna download: "+body[0].name + " in version: " + body[0].latest_release.version);
|
||||
factorioAPI.downloadMod( {name: body[0].name, version: body[0].latest_release.version}).then(()=>{
|
||||
console.log("Mod downloaded");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
reject(new Error("Invalid search term"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function removeMod(config, term, instance, force = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (instance.name == "shared") {
|
||||
var modDirectory = "sharedMods";
|
||||
} else {
|
||||
var modDirectory = instance.path("instanceMods");
|
||||
}
|
||||
if (term && typeof term == "string" && term.length > 0) {
|
||||
let mods = fs.readdirSync(modDirectory);
|
||||
let modsMatchingFilter = mods.filter(mod => mod.toLowerCase().includes(term.toLowerCase()));
|
||||
if (modsMatchingFilter.length < 1) {
|
||||
console.log("No mods found matching search");
|
||||
} else if (modsMatchingFilter.length == 1) {
|
||||
console.log("Deleting "+modsMatchingFilter[0]);
|
||||
fs.unlinkSync(path.join(modDirectory, modsMatchingFilter[0]));
|
||||
console.log("Deleted.");
|
||||
} else if (modsMatchingFilter.length > 1) {
|
||||
console.log("Found multiple mods");
|
||||
modsMatchingFilter.forEach(mod => console.log(mod));
|
||||
if (query("Delete all mods matching filter?")) {
|
||||
let modsToDelete = modsMatchingFilter.length;
|
||||
for (let i = 0; i < modsToDelete; i++) {
|
||||
fs.unlinkSync(path.join(modDirectory, modsMatchingFilter[i]));
|
||||
console.log("Deleted "+modsMatchingFilter[i]);
|
||||
}
|
||||
} else console.log("Did not delete mods.")
|
||||
}
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("Invalid search term"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases'
|
||||
/**
|
||||
Download mod from github repo by looking for the first binary download in
|
||||
the latest release, saving it to /sharedMods
|
||||
|
||||
@param {string} url a github repo URL, for example https://api.github.com/repos/Danielv123/factorioClusterioMod/releases. Must link to /releases
|
||||
*/
|
||||
function downloadFromGitURL(url) {
|
||||
console.log("Downloading mod from "+url);
|
||||
// get JSON data about releases
|
||||
let res = syncRequest('GET', url, {"headers":{"User-Agent":"factorioClusterio"}});
|
||||
let downloadUrl = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
|
||||
let name = JSON.parse(res.getBody())[0].assets[0].name;
|
||||
console.log(JSON.parse(res.getBody()))
|
||||
if (downloadUrl) {
|
||||
console.log(downloadUrl);
|
||||
let file = fs.createWriteStream("sharedMods/"+name);
|
||||
https.get(downloadUrl, function(response) {
|
||||
response.pipe(file);
|
||||
console.log("Downloaded "+name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
function findUpdates() {
|
||||
console.log("Downloading mods...");
|
||||
// get JSON data about releases
|
||||
let res = syncRequest('GET', 'https://api.github.com/repos/Danielv123/factorioClusterioMod/releases', {"headers":{"User-Agent":"factorioClusterio"}});
|
||||
let url = JSON.parse(res.getBody())[0].assets[0].browser_download_url;
|
||||
let name = JSON.parse(res.getBody())[0].assets[0].name;
|
||||
console.log(JSON.parse(res.getBody()))
|
||||
}*/
|
||||
|
||||
module.exports = {
|
||||
listMods,
|
||||
findMods,
|
||||
addMod,
|
||||
removeMod,
|
||||
downloadFromGitURL,
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
var http = require('http');
|
||||
var https = require('https');
|
||||
var fs = require('fs-extra');
|
||||
|
||||
|
||||
function makeDirectory(path){
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirp(path, function(err){
|
||||
if(err){
|
||||
console.error("Unable to mkdirp "+path);
|
||||
console.error(err);
|
||||
resolve(false);
|
||||
} else {
|
||||
console.log("mkdirp "+path);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function download(url, file, callback){
|
||||
return new Promise((resolve, reject) => {
|
||||
let pathArray = file.split("/");
|
||||
pathArray.pop(); // remove filename from the end
|
||||
if(pathArray.length >= 1){
|
||||
// this might be an invalid path!
|
||||
fs.stat(pathArray.join("/"), (err, stat) => {
|
||||
if(err){
|
||||
makeDirectory(pathArray.join("/")).then(()=>{
|
||||
doDownload(url, file, callback);
|
||||
});
|
||||
} else {
|
||||
doDownload(url, file, callback);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
doDownload(url, file, callback);
|
||||
}
|
||||
function doDownload(url, file, callback){
|
||||
var writeStream = fs.createWriteStream(file);
|
||||
let protocol = http;
|
||||
if(url.split(":")[0] == "https") protocol = https;
|
||||
var request = protocol.get(url, function(response) {
|
||||
response.pipe(writeStream);
|
||||
response.on("end", () => {
|
||||
if(callback && typeof callback == "function") callback(file);
|
||||
console.log("Downloaded "+file+" from "+url);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var directories = [
|
||||
"sharedMods",
|
||||
"instances",
|
||||
];
|
||||
let downloadList = [
|
||||
// {
|
||||
// url: "http://fbpviewer.trakos.pl/images/spritesheet.json",
|
||||
// file: "static/pictures/spritesheet.js",
|
||||
// callback: function(file){
|
||||
// fs.writeFileSync("static/pictures/spritesheet.js", "export default\n"+fs.readFileSync("static/pictures/spritesheet.js"));
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// url: "http://fbpviewer.trakos.pl/images/spritesheet.png",
|
||||
// file: "static/pictures/spritesheet.png",
|
||||
// },
|
||||
{
|
||||
url: "https://code.jquery.com/jquery.js",
|
||||
file: "static/external/jquery.js",
|
||||
},
|
||||
{
|
||||
url: "https://code.jquery.com/ui/1.12.1/jquery-ui.js",
|
||||
file: "static/external/jquery-ui.js",
|
||||
},
|
||||
{
|
||||
url: "https://momentjs.com/downloads/moment.min.js",
|
||||
file: "static/external/moment.min.js",
|
||||
},
|
||||
{
|
||||
url: "https://craig.global.ssl.fastly.net/js/mousetrap/mousetrap.min.js",
|
||||
file: "static/external/mousetrap.min.js",
|
||||
},
|
||||
{
|
||||
url: "https://fonts.googleapis.com/css?family=Roboto",
|
||||
file: "static/external/css/Roboto.css",
|
||||
},
|
||||
]
|
||||
|
||||
// initiate folder creations and downloads
|
||||
function processMkdirRecursive(callback){
|
||||
let item = directories.shift();
|
||||
makeDirectory(item).then(()=>{
|
||||
if(directories.length == 0){
|
||||
if(callback) callback();
|
||||
} else {
|
||||
processMkdirRecursive(callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
function processDownloadRecursive(callback){
|
||||
let item = downloadList.shift();
|
||||
download(item.url, item.file, item.callback).then(()=>{
|
||||
if(downloadList.length == 0){
|
||||
if(callback) callback();
|
||||
} else {
|
||||
processDownloadRecursive(callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
processMkdirRecursive(processDownloadRecursive);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
Helper functions related to objects
|
||||
@module
|
||||
*/
|
||||
|
||||
/**
|
||||
Deep clones an object, is actually just JSON.parse(JSON.stringify()) but looks cleaner.
|
||||
@param {object} obj to clone
|
||||
@returns {object}
|
||||
|
||||
@example
|
||||
a = {"thingy":"with a value"};
|
||||
b = a;
|
||||
a == b; // true
|
||||
c = objectOps.deepclone(a);
|
||||
a == c; // false
|
||||
*/
|
||||
module.exports.deepclone = function(obj){
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
/**
|
||||
Checks if a string is valid JSON using try/catch. If passed an object it will always return true.
|
||||
@param {string} string to verify
|
||||
@returns {boolean}
|
||||
|
||||
@example
|
||||
objectOps.isJSON("99 bottles of beer"); // false
|
||||
objectOps.isJSON("99"); // true
|
||||
objectOps.isJSON({"thingy":"that is an object"}); // true
|
||||
objectOps.isJSON(JSON.stringify({"thingy":"that is an object"})); // true
|
||||
*/
|
||||
module.exports.isJSON = function(string){
|
||||
let stringIsJson = false;
|
||||
let x
|
||||
try {
|
||||
x = JSON.parse(string);
|
||||
} catch (e){
|
||||
return false;
|
||||
}
|
||||
if(typeof x == "object" || typeof string == "object"){
|
||||
stringIsJson = true;
|
||||
}
|
||||
return stringIsJson;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "lib",
|
||||
"description": "Internal library for factorioClusterio"
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* Plugin interfaces and utilities.
|
||||
* @module
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
|
||||
const errors = require("lib/errors");
|
||||
|
||||
|
||||
/**
|
||||
* Base class for instance plugins
|
||||
*/
|
||||
class BaseInstancePlugin {
|
||||
constructor(info, instance, slave) {
|
||||
/**
|
||||
* The plugin's own info module
|
||||
*/
|
||||
this.info = info;
|
||||
|
||||
/**
|
||||
* Instance the plugin started for
|
||||
* @type {Instance}
|
||||
*/
|
||||
this.instance = instance;
|
||||
|
||||
/**
|
||||
* Slave running the instance
|
||||
*
|
||||
* With the exepction of accessing the slave's config you should
|
||||
* avoid ineracting with the slave object directly.
|
||||
*
|
||||
* @type {Slave}
|
||||
*/
|
||||
this.slave = slave;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called before collecting Prometheus metrics
|
||||
*
|
||||
* Invoked before the default metrics of prometheus is collected. Note
|
||||
* that since this is done while replying to /metrics this should not
|
||||
* take a long time, otherwise the requst will time out.
|
||||
*
|
||||
* Although you can return the results of collecting your prometheus
|
||||
* collectors directly, all gauges in the default registry is already
|
||||
* automatically collected. It's therefore recommended to only update
|
||||
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
|
||||
* collecting the values.
|
||||
*
|
||||
* @returns an async iterator of prometheus metric results or undefined.
|
||||
*/
|
||||
async onMetrics() { }
|
||||
|
||||
/**
|
||||
* Called after the Factorio server is started
|
||||
*/
|
||||
async onStart() { }
|
||||
|
||||
/**
|
||||
* Called before the Factorio server is stopped
|
||||
*
|
||||
* This will not be called if for example the Factorio server crashes or
|
||||
* is killed.
|
||||
*/
|
||||
async onStop() { }
|
||||
|
||||
/**
|
||||
* Called when the Factorio server exited
|
||||
*/
|
||||
onExit() { }
|
||||
|
||||
/**
|
||||
* Called when the Factorio outputs a line
|
||||
*
|
||||
* Called when the Factorio server outputs a line on either stdout or
|
||||
* stderr. The output is a parsed form of the line and contains the
|
||||
* following properties (those marked with ? are optional):
|
||||
*
|
||||
* - source: Where the message came from, one of "stdout" and "stderr".
|
||||
* - format: Timestamp format, one of "date", "seconds" and "none".
|
||||
* - time?: Timestamp of the message. Not present if format is "none".
|
||||
* - type: Type of message, one of "log", "action" and "generic".
|
||||
* - level?: Log level for "log" type. i.e "Info" normally.
|
||||
* - file?: File reported for "log" type.
|
||||
* - action?: Kind of action for "action" type. i.e "CHAT" for chat.
|
||||
* - message: Main content of the line.
|
||||
*/
|
||||
async onOutput(output) { }
|
||||
}
|
||||
|
||||
class BaseMasterPlugin {
|
||||
constructor(info, master, metrics) {
|
||||
this.info = info;
|
||||
this.master = master;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() { }
|
||||
|
||||
/**
|
||||
* Called before collecting Prometheus metrics
|
||||
*
|
||||
* Invoked before the default metrics of prometheus is collected. Note
|
||||
* that since this is done while replying to /metrics this should not
|
||||
* take a long time, otherwise the requst will time out.
|
||||
*
|
||||
* Although you can return the results of collecting your prometheus
|
||||
* collectors directly, all gauges in the default registry is already
|
||||
* automatically collected. It's therefore recommended to only update
|
||||
* the Gauges/Counters/etc in onMetric and let Clusterio deal will
|
||||
* collecting the values.
|
||||
*
|
||||
* @returns an async iterator of prometheus metric results or undefined.
|
||||
*/
|
||||
async onMetrics() { }
|
||||
|
||||
/**
|
||||
* Called when the master server is shutting down
|
||||
*/
|
||||
async onExit() { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin information
|
||||
*
|
||||
* Searches through the plugin directory and loads each plugin's info
|
||||
* module. Once loaded the info modules will not be reloaded should this
|
||||
* function be called again.
|
||||
*
|
||||
* @returns {Map<string, Object>} mapping of plugin name to info module.
|
||||
*/
|
||||
async function loadPluginInfos(baseDir) {
|
||||
let plugins = [];
|
||||
for (let pluginDir of await fs.readdir(baseDir)) {
|
||||
let pluginInfo;
|
||||
|
||||
try {
|
||||
// Note: Require path is relative to this module
|
||||
pluginInfo = require(path.posix.join('..', baseDir, pluginDir, 'info'));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'MODULE_NOT_FOUND') {
|
||||
continue;
|
||||
} else {
|
||||
throw new errors.PluginError(pluginDir, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginInfo.name !== pluginDir) {
|
||||
throw new errors.EnvironmentError(
|
||||
`Plugin dir ${baseDir}/${pluginDir} does not match the name of the plugin (${pluginInfo.name})`
|
||||
);
|
||||
}
|
||||
|
||||
plugins.push(pluginInfo);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
function attachPluginMessages(link, pluginInfo, plugin) {
|
||||
let messageDefinitions = pluginInfo.messages || [];
|
||||
for (let [name, messageFormat] of Object.entries(messageDefinitions)) {
|
||||
let handler = name + messageFormat.constructor.name + 'Handler';
|
||||
if (plugin === null || !plugin[handler]) {
|
||||
messageFormat.attach(link);
|
||||
|
||||
} else {
|
||||
messageFormat.attach(link, async function(message, format) {
|
||||
return await plugin[handler](message, format, this);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
BaseInstancePlugin,
|
||||
BaseMasterPlugin,
|
||||
|
||||
loadPluginInfos,
|
||||
attachPluginMessages,
|
||||
};
|
||||
@@ -1,432 +0,0 @@
|
||||
class Metric {
|
||||
constructor(type, name, help, labels = []) {
|
||||
if (typeof name !== 'string') {
|
||||
throw new Error("Expected name to be a string");
|
||||
}
|
||||
|
||||
if (typeof help !== 'string') {
|
||||
throw new Error("Expected help to be a string");
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(name)) {
|
||||
throw new Error(`Invalid name '${name}'`);
|
||||
}
|
||||
|
||||
for (let name of labels) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
throw new Error(`Invalid label '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
this.name = name;
|
||||
this.help = help;
|
||||
this.type = type;
|
||||
this.labels = labels;
|
||||
}
|
||||
}
|
||||
|
||||
let defaultRegistry;
|
||||
|
||||
class Collector {
|
||||
constructor(register = true) {
|
||||
if (register) {
|
||||
defaultRegistry.register(this);
|
||||
}
|
||||
}
|
||||
|
||||
async* collect() { }
|
||||
}
|
||||
|
||||
function escapeLabelValue(value) {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/"/g, "\\\"")
|
||||
;
|
||||
}
|
||||
|
||||
function labelsToKey(labels, metricLabels) {
|
||||
let items = []
|
||||
if (labels.length === 1 && typeof labels[0] === "object") {
|
||||
labels = labels[0];
|
||||
for (let name of metricLabels) {
|
||||
if (!Object.hasOwnProperty.call(labels, name)) {
|
||||
throw new Error(`Missing label '${name}'`);
|
||||
}
|
||||
if (typeof labels[name] !== "string") {
|
||||
throw new Error(
|
||||
`Expected value for label '${name}' to be a string`
|
||||
);
|
||||
}
|
||||
items.push(`${name}="${escapeLabelValue(labels[name])}"`);
|
||||
}
|
||||
|
||||
for (let name of Object.keys(labels)) {
|
||||
if (!metricLabels.includes(name)) {
|
||||
throw new Error(`Extra label '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
if (metricLabels.length > labels.length) {
|
||||
throw new Error(`Missing label '${metricLabels[labels.length]}'`);
|
||||
}
|
||||
|
||||
if (labels.length > metricLabels.length) {
|
||||
throw new Error(`Extra positional label`);
|
||||
}
|
||||
|
||||
for (let i=0; i < metricLabels.length; ++i) {
|
||||
if (typeof labels[i] !== "string") {
|
||||
throw new Error(
|
||||
`Expected value for label '${metricLabels[i]}' to be a string`
|
||||
);
|
||||
}
|
||||
items.push(`${metricLabels[i]}="${escapeLabelValue(labels[i])}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return items.join(',');
|
||||
}
|
||||
|
||||
function keyToLabels(key) {
|
||||
let labels = new Map();
|
||||
|
||||
if (key !== '') for (let pair of key.split(',')) {
|
||||
let [name, value] = pair.split('=', 2);
|
||||
labels.set(name, value
|
||||
.slice(1, -1)
|
||||
.replace(/\\"/g, "\"")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\\\/g, "\\")
|
||||
);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
class ValueCollector extends Collector {
|
||||
constructor(type, name, help, options, childClass) {
|
||||
let labels = [];
|
||||
let register = true;
|
||||
let callback = null;
|
||||
for (let [name, value] of Object.entries(options)) {
|
||||
if (name === 'labels') { labels = value; }
|
||||
else if (name === 'register') { register = value; }
|
||||
else if (name === 'callback') { callback = value; }
|
||||
else { throw new Error(`Unrecognized option '${name}'`); }
|
||||
}
|
||||
|
||||
// Make sure we don't register to the default registry if metric throws.
|
||||
let metric = new Metric(type, name, help, labels);
|
||||
|
||||
super(register);
|
||||
|
||||
this.callback = callback;
|
||||
this.metric = metric;
|
||||
this._values = new Map();
|
||||
this._children = new Map();
|
||||
this._childClass = childClass;
|
||||
this._defaultChild = labels.length ? null : this.labels({});
|
||||
}
|
||||
|
||||
async* collect() {
|
||||
if (this.callback) { await this.callback(this); }
|
||||
yield { metric: this.metric, samples: this._values };
|
||||
}
|
||||
|
||||
get() {
|
||||
return this._defaultChild.get();
|
||||
}
|
||||
|
||||
labels(...labels) {
|
||||
let key = labelsToKey(labels, this.metric.labels);
|
||||
let child = this._children.get(key);
|
||||
if (child === undefined) {
|
||||
child = new this._childClass(this._values, key);
|
||||
this._children.set(key, child);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
remove(...labels) {
|
||||
let key = labelsToKey(labels, this.metric.labels);
|
||||
if (key === '') {
|
||||
throw new Error("labels cannot be empty");
|
||||
}
|
||||
this._children.delete(key);
|
||||
this._values.delete(key);
|
||||
}
|
||||
|
||||
removeAll(labels) {
|
||||
if (!Object.keys(labels).length) {
|
||||
throw new Error("labels cannot be empty");
|
||||
}
|
||||
|
||||
function removeMatchingLabels(mapping, labels) {
|
||||
for (let key of mapping.keys()) {
|
||||
let candidate = keyToLabels(key);
|
||||
let hasLabels = Object.entries(labels).every(([name, value]) => {
|
||||
return candidate.get(name) === value;
|
||||
});
|
||||
if (hasLabels) {
|
||||
mapping.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeMatchingLabels(this._values, labels);
|
||||
removeMatchingLabels(this._children, labels);
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.metric.labels.length) {
|
||||
throw new Error("Cannot clear unlabeled metric");
|
||||
}
|
||||
this._children = new Map();
|
||||
this._values = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
class ValueCollectorChild {
|
||||
constructor(values, key) {
|
||||
this._values = values;
|
||||
this._key = key;
|
||||
|
||||
this._values.set(key, 0);
|
||||
}
|
||||
|
||||
get() {
|
||||
return this._values.get(this._key);
|
||||
}
|
||||
}
|
||||
|
||||
class CounterChild extends ValueCollectorChild {
|
||||
inc(value = 1) {
|
||||
// Note: Inverted to also catch NaN
|
||||
if (!(value >= 0)) {
|
||||
throw new Error("Expected value to be a positive number");
|
||||
}
|
||||
|
||||
this._values.set(this._key, this._values.get(this._key) + value)
|
||||
}
|
||||
}
|
||||
|
||||
class Counter extends ValueCollector {
|
||||
constructor(name, help, options = {}) {
|
||||
super("counter", name, help, options, CounterChild);
|
||||
}
|
||||
|
||||
inc(value = 1) {
|
||||
this._defaultChild.inc(value);
|
||||
}
|
||||
}
|
||||
|
||||
class GaugeChild extends ValueCollectorChild {
|
||||
inc(value = 1) {
|
||||
this._values.set(this._key, this._values.get(this._key) + value)
|
||||
}
|
||||
|
||||
dec(value = 1) {
|
||||
this._values.set(this._key, this._values.get(this._key) - value)
|
||||
}
|
||||
|
||||
set(value) {
|
||||
this._values.set(this._key, value)
|
||||
}
|
||||
|
||||
setToCurrentTime() {
|
||||
this._values.set(this._key, Date.now() / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
class Gauge extends ValueCollector {
|
||||
constructor(name, help, options = {}) {
|
||||
super("gauge", name, help, options, GaugeChild);
|
||||
}
|
||||
|
||||
inc(value = 1) {
|
||||
this._defaultChild.inc(value);
|
||||
}
|
||||
|
||||
dec(value = 1) {
|
||||
this._defaultChild.dec(value);
|
||||
}
|
||||
|
||||
set(value) {
|
||||
this._defaultChild.set(value);
|
||||
}
|
||||
|
||||
setToCurrentTime() {
|
||||
this._defaultChild.setToCurrentTime();
|
||||
}
|
||||
}
|
||||
|
||||
class CollectorRegistry {
|
||||
constructor() {
|
||||
this.collectors = [];
|
||||
}
|
||||
|
||||
async* collect() {
|
||||
for (let collector of this.collectors) {
|
||||
for await (let result of collector.collect()) {
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(metric) {
|
||||
let index = this.collectors.lastIndexOf(metric);
|
||||
if (index !== -1) {
|
||||
throw new Error(
|
||||
"Collector is already registered in this registry."
|
||||
);
|
||||
}
|
||||
|
||||
this.collectors.push(metric);
|
||||
}
|
||||
|
||||
unregister(metric) {
|
||||
let index = this.collectors.lastIndexOf(metric);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
"Collector is not registered in this registry."
|
||||
);
|
||||
}
|
||||
|
||||
this.collectors.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
defaultRegistry = new CollectorRegistry();
|
||||
|
||||
function escapeHelp(help) {
|
||||
return help
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\n/g, "\\n")
|
||||
;
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (value === Infinity) {
|
||||
return '+Inf';
|
||||
} else if (value === -Infinity) {
|
||||
return '-Inf';
|
||||
} else {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
async function* expositionLines(resultsIterator) {
|
||||
let first = true;
|
||||
for await (let result of resultsIterator) {
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
yield `\n`
|
||||
}
|
||||
|
||||
yield `# HELP ${result.metric.name} ${escapeHelp(result.metric.help)}\n`;
|
||||
yield `# TYPE ${result.metric.name} ${result.metric.type}\n`;
|
||||
|
||||
for (let [key, value] of result.samples) {
|
||||
if (key === '') {
|
||||
yield `${result.metric.name} ${formatValue(value)}\n`;
|
||||
} else {
|
||||
yield `${result.metric.name}{${key}} ${formatValue(value)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exposition(resultsIterator = defaultRegistry.collect()) {
|
||||
let exposition = '';
|
||||
for await (let line of expositionLines(resultsIterator)) {
|
||||
exposition += line;
|
||||
}
|
||||
return exposition;
|
||||
}
|
||||
|
||||
// HTTP Content-Type for the exposition format that's implemented
|
||||
exposition.contentType = 'text/plain; version=0.0.4';
|
||||
|
||||
function serializeResult(result, options = {}) {
|
||||
let addLabels = null;
|
||||
let metricName = result.metric.name;
|
||||
let metricHelp = result.metric.help;
|
||||
for (let [name, value] of Object.entries(options)) {
|
||||
if (name === 'addLabels') { addLabels = value; }
|
||||
else if (name === 'metricName') { metricName = value; }
|
||||
else if (name === 'metricHelp') { metricHelp = value; }
|
||||
else { throw new Error(`Unrecognized option '${name}'`); }
|
||||
}
|
||||
|
||||
let samples;
|
||||
if (addLabels === null) {
|
||||
samples = result.samples;
|
||||
addLabels = {};
|
||||
|
||||
} else {
|
||||
samples = new Map();
|
||||
let key = labelsToKey([addLabels], [...Object.keys(addLabels)]);
|
||||
for (let [labels, value] of result.samples.entries()) {
|
||||
if (labels === '') { samples.set(key, value); }
|
||||
else { samples.set(`${labels},${key}`, value); }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metric: {
|
||||
type: result.metric.type,
|
||||
name: metricName,
|
||||
help: metricHelp,
|
||||
labels: [...result.metric.labels, ...Object.keys(addLabels)],
|
||||
},
|
||||
samples: [...samples.entries()],
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeResult(serializedResult) {
|
||||
return {
|
||||
metric: new Metric(
|
||||
serializedResult.metric.type,
|
||||
serializedResult.metric.name,
|
||||
serializedResult.metric.help,
|
||||
serializedResult.metric.labels,
|
||||
),
|
||||
samples: new Map(serializedResult.samples),
|
||||
}
|
||||
}
|
||||
|
||||
let defaultCollectors = {};
|
||||
defaultCollectors.processCpuSecondsTotal = new Gauge(
|
||||
'process_cpu_seconds', "Total user and system CPU time spent in seconds.",
|
||||
{
|
||||
callback: async function() {
|
||||
let usage = process.cpuUsage();
|
||||
this.set((usage.user + usage.system) / 1e6);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
defaultCollectors.processStartTimeSeconds = new Gauge(
|
||||
'process_start_time_seconds',
|
||||
"Start time of the process since unix epoch in seconds."
|
||||
);
|
||||
defaultCollectors.processStartTimeSeconds.setToCurrentTime();
|
||||
|
||||
|
||||
module.exports = {
|
||||
Counter,
|
||||
Gauge,
|
||||
CollectorRegistry,
|
||||
Collector,
|
||||
ValueCollector,
|
||||
Metric,
|
||||
|
||||
exposition,
|
||||
defaultRegistry,
|
||||
defaultCollectors,
|
||||
serializeResult,
|
||||
deserializeResult,
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
-- Important: Keep this API in sync with modules/clusterio/api.lua
|
||||
local api = {}
|
||||
|
||||
|
||||
local initialized = false
|
||||
function api.init()
|
||||
if initialized then
|
||||
return
|
||||
end
|
||||
initialized = true
|
||||
|
||||
if remote.interfaces.clusterio_api then
|
||||
api.events = remote.call("clusterio_api", "get_events")
|
||||
else
|
||||
local null_event = script.generate_event_name()
|
||||
api.events = {
|
||||
on_instance_updated = null_event,
|
||||
on_server_startup = null_event,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local function call_api(fn, ...)
|
||||
if remote.interfaces.clusterio_api and remote.interfaces.clusterio_api[fn] then
|
||||
return remote.call("clusterio_api", fn, ...)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
function api.get_instance_name()
|
||||
return call_api("get_instance_name")
|
||||
end
|
||||
|
||||
function api.get_instance_id()
|
||||
return call_api("get_instance_id")
|
||||
end
|
||||
|
||||
|
||||
function api.send_json(channel, data)
|
||||
if not remote.interfaces.clusterio_api then
|
||||
return
|
||||
end
|
||||
|
||||
-- Escape bad characters. The question mark is used for separating the
|
||||
-- channel name from the payload.
|
||||
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||
return "\\x" .. string.format("%02x", match:byte())
|
||||
end)
|
||||
|
||||
data = game.table_to_json(data)
|
||||
|
||||
-- If there's more than about 4kB of data users running with the Windows
|
||||
-- console open will start to experience stuttering, otherwise we could
|
||||
-- output about 1 MB of data at a time through stdout.
|
||||
if #data < 4000 then
|
||||
print("\f$ipc:" .. channel .. "?j" .. data)
|
||||
else
|
||||
local file_no = remote.call("clusterio_api", "get_file_no")
|
||||
local file_name = "clst_" .. file_no .. ".json"
|
||||
game.write_file(file_name, data, false, 0)
|
||||
print("\f$ipc:" .. channel .. "?f" .. file_name)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
return api
|
||||
@@ -1,5 +0,0 @@
|
||||
---------------------------------------------------------------------------------------------------
|
||||
Version: 0.1.0
|
||||
Major Features:
|
||||
- API for mods to interact with Clusterio
|
||||
- Serialization library for converting items to JSON compatible tables.
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "clusterio_lib",
|
||||
"version": "0.1.0",
|
||||
"title": "Clusterio Library (Alpha)",
|
||||
"author": "Clusterio Team",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio",
|
||||
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
|
||||
"factorio_version": "0.17"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
-- Important: Keep this API in sync with mod/api.lua
|
||||
local api = {}
|
||||
|
||||
|
||||
api.events = {
|
||||
on_instance_updated = script.generate_event_name(),
|
||||
on_server_startup = script.generate_event_name(),
|
||||
}
|
||||
|
||||
function api.get_instance_name()
|
||||
return global.clusterio.instance_name
|
||||
end
|
||||
|
||||
function api.get_instance_id()
|
||||
return global.clusterio.instance_id
|
||||
end
|
||||
|
||||
function api.send_json(channel, data)
|
||||
|
||||
-- Escape bad characters. The question mark is used for separating the
|
||||
-- channel name from the payload.
|
||||
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
|
||||
return "\\x" .. string.format("%02x", match:byte())
|
||||
end)
|
||||
|
||||
data = game.table_to_json(data)
|
||||
|
||||
-- If there's more than about 4kB of data users running with the Windows
|
||||
-- console open will start to experience stuttering, otherwise we could
|
||||
-- output about 1 MB of data at a time through stdout.
|
||||
if #data < 4000 then
|
||||
print("\f$ipc:" .. channel .. "?j" .. data)
|
||||
else
|
||||
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
|
||||
local file_name = "clst_" .. global.clusterio_file_no .. ".json"
|
||||
game.write_file(file_name, data, false, 0)
|
||||
print("\f$ipc:" .. channel .. "?f" .. file_name)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
return api
|
||||
@@ -1,61 +0,0 @@
|
||||
local api = require('modules/clusterio/api')
|
||||
|
||||
local impl = {}
|
||||
|
||||
|
||||
impl.events = {}
|
||||
impl.events[defines.events.on_tick] = function()
|
||||
if global.clusterio_patch_number ~= clusterio_patch_number then
|
||||
global.clusterio_patch_number = clusterio_patch_number
|
||||
script.raise_event(api.events.on_server_startup, {})
|
||||
end
|
||||
end
|
||||
|
||||
impl.events[api.events.on_server_startup] = function()
|
||||
if not global.clusterio then
|
||||
global.clusterio = {
|
||||
instance_id = nil,
|
||||
instance_name = nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Internal API
|
||||
clusterio_private = {}
|
||||
function clusterio_private.update_instance(new_id, new_name)
|
||||
global.clusterio.instance_id = new_id
|
||||
global.clusterio.instance_name = new_name
|
||||
script.raise_event(api.events.on_instance_updated, {
|
||||
instance_id = new_id,
|
||||
instance_name = new_name,
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
-- This is not part of the add_remote_interface callback to ensure it is
|
||||
-- available when the clusterio_lib mod is loaded. The reason this is
|
||||
-- neccessary is that on_init for newly added mods happen before on_load
|
||||
-- for existing mods, and the add_remote_interface callback is done in
|
||||
-- on_load. See https://forums.factorio.com/viewtopic.php?f=25&t=81552 for
|
||||
-- more details.
|
||||
remote.add_interface('clusterio_api', {
|
||||
get_events = function()
|
||||
return api.events
|
||||
end,
|
||||
|
||||
get_instance_id = function()
|
||||
return global.clusterio.instance_id
|
||||
end,
|
||||
|
||||
get_instance_name = function()
|
||||
return global.clusterio.instance_name
|
||||
end,
|
||||
|
||||
get_file_no = function()
|
||||
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
|
||||
return global.clusterio_file_no
|
||||
end,
|
||||
})
|
||||
|
||||
|
||||
return impl
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "clusterio",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {},
|
||||
"load": ["impl.lua"]
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
-- Library for serializing items in Factorio
|
||||
-- Based on code from playerManager and trainTeleports
|
||||
local serialize = {}
|
||||
|
||||
-- Equipment Grids are serialized into an array of equipment entries
|
||||
-- where ench entry is a table with the following fields:
|
||||
-- n: name
|
||||
-- p: position (array of 2 numbers corresponding to x and y)
|
||||
-- s: shield (optional)
|
||||
-- e: energy (optional)
|
||||
-- If the equipment is a burner the following is also present:
|
||||
-- i: burner inventory
|
||||
-- r: result inventory
|
||||
-- b: curently burning (optional)
|
||||
-- f: remaining_burning_fuel (optional)
|
||||
function serialize.serialize_equipment_grid(grid)
|
||||
local serialized = {}
|
||||
local processed = {}
|
||||
for y = 0, grid.height - 1 do
|
||||
for x = 0, grid.width - 1 do
|
||||
local equipment = grid.get({x, y})
|
||||
if equipment ~= nil then
|
||||
local pos = equipment.position
|
||||
local combined_pos = pos.x + pos.y * grid.width + 1
|
||||
if not processed[combined_pos] then
|
||||
processed[combined_pos] = true
|
||||
local entry = {
|
||||
n = equipment.name,
|
||||
p = {pos.x, pos.y},
|
||||
}
|
||||
if equipment.shield > 0 then entry.s = equipment.shield end
|
||||
if equipment.energy > 0 then entry.e = equipment.energy end
|
||||
-- TODO: Test with Industrial Revolution
|
||||
if equipment.burner then
|
||||
local burner = equipment.burner
|
||||
entry.i = serialize.serialize_inventory(burner.inventory)
|
||||
entry.r = serialize.serialize_inventory(burner.burnt_result_inventory)
|
||||
if burner.curently_burning then
|
||||
entry.b = {}
|
||||
serialize.serialize_item_stack(burner.curently_burning, entry.b)
|
||||
entry.f = burner.remaining_burning_fuel
|
||||
end
|
||||
end
|
||||
table.insert(serialized, entry)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return serialized
|
||||
end
|
||||
|
||||
function serialize.deserialize_equipment_grid(grid, serialized)
|
||||
grid.clear()
|
||||
for _, entry in ipairs(serialized) do
|
||||
local equipment = grid.put({
|
||||
name = entry.n,
|
||||
position = entry.p,
|
||||
})
|
||||
if equipment then
|
||||
if entry.s then equipment.shield = entry.s end
|
||||
if entry.e then equipment.energy = entry.e end
|
||||
if entry.i then
|
||||
if entry.b then serialize.deserialize_item_stack(burner.currently_burning, entry.b) end
|
||||
if entry.f then burner.remaining_burning_fuel = entry.f end
|
||||
serialize.deserialize_inventory(burner.burnt_result_inventory, entry.r)
|
||||
serialize.deserialize_inventory(burner.inventory, entry.i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Item stacks are serialized into a table with the following fields:
|
||||
-- n: name
|
||||
-- c: count
|
||||
-- h: health (optional)
|
||||
-- d: durability (optional)
|
||||
-- a: ammo count (optional)
|
||||
-- l: label (optional)
|
||||
-- g: equipment grid (optional)
|
||||
-- i: item inventory (optional)
|
||||
-- If the item stack is exportable it has the following property instead
|
||||
-- e: export string
|
||||
-- Label is a table with the following fields:
|
||||
-- t: label text (optional)
|
||||
-- c: color (optional)
|
||||
-- a: allow manual label change
|
||||
function serialize.serialize_item_stack(slot, entry)
|
||||
if
|
||||
slot.is_blueprint
|
||||
or slot.is_blueprint_book
|
||||
or slot.is_upgrade_item
|
||||
or slot.is_deconstruction_item
|
||||
or slot.is_item_with_tags
|
||||
then
|
||||
local call_success, call_return = pcall(slot.export_stack)
|
||||
if not call_success then
|
||||
print("Error: '" .. call_return .. "' thrown exporting '" .. slot.name .. "'")
|
||||
else
|
||||
entry.e = call_return
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
entry.n = slot.name
|
||||
entry.c = slot.count
|
||||
if slot.health < 1 then entry.h = slot.health end
|
||||
if slot.durability then entry.d = slot.durability end
|
||||
if slot.type == "ammo" then entry.a = slot.ammo end
|
||||
if slot.is_item_with_label then
|
||||
local label = {}
|
||||
if slot.label then label.t = slot.label end
|
||||
if slot.label_color then label.c = slot.label_color end
|
||||
label.a = slot.allow_manual_label_change
|
||||
entry.l = label
|
||||
end
|
||||
|
||||
if slot.grid then
|
||||
entry.g = serialize.serialize_equipment_grid(slot.grid)
|
||||
end
|
||||
|
||||
if slot.is_item_with_inventory then
|
||||
local sub_inventory = slot.get_inventory(defines.inventory.item_main)
|
||||
entry.i = serialize.serialize_inventory(sub_inventory)
|
||||
end
|
||||
end
|
||||
|
||||
function serialize.deserialize_item_stack(slot, entry)
|
||||
if entry.e then
|
||||
local success = slot.import_stack(entry.e)
|
||||
if success == 1 then
|
||||
print("Error: import of '" .. entry.e .. "' succeeded with errors")
|
||||
elseif success == -1 then
|
||||
print("Error: import of '" .. entry.e .. "' failed")
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
local item_stack = {
|
||||
name = entry.n,
|
||||
count = entry.c,
|
||||
}
|
||||
if entry.h then item_stack.health = entry.h end
|
||||
if entry.d then item_stack.durability = entry.d end
|
||||
if entry.a then item_stack.ammo = entry.a end
|
||||
|
||||
local call_success, call_return = pcall(slot.set_stack, item_stack)
|
||||
if not call_success then
|
||||
print("Error: '" .. call_return .. "' thrown setting stack ".. serpent.line(entry))
|
||||
|
||||
elseif not call_return then
|
||||
print("Error: Failed to set stack " .. serpent.line(entry))
|
||||
|
||||
else
|
||||
if entry.l then
|
||||
-- TODO test this with AAI's unit-remote-control
|
||||
local label = entry.l
|
||||
if label.t then slot.label = label.t end
|
||||
if label.c then slot.label_color = label.c end
|
||||
slot.allow_manual_label_change = label.a
|
||||
end
|
||||
if entry.g then
|
||||
serialize.deserialize_equipment_grid(slot.grid, entry.g)
|
||||
end
|
||||
if entry.i then
|
||||
local sub_inventory = slot.get_inventory(defines.inventory.item_main)
|
||||
serialize.deserialize_inventory(sub_inventory, entry.i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Inventories are serialized into a table with the following fields:
|
||||
-- i: array of item stack or exportable item entries
|
||||
-- b: bar position (optional)
|
||||
-- Each item entry has the following fields
|
||||
-- s: index (optional, equals to previous plus one if not present)
|
||||
-- r: repeat count (optional)
|
||||
-- f: slot filter (optional)
|
||||
-- Pluss all the fields for item stacks (see deserialize_item_stack)
|
||||
-- It's also possible that the slot is empty but has a slot filter.
|
||||
function serialize.serialize_inventory(inventory)
|
||||
local serialized = {}
|
||||
if inventory.hasbar() and inventory.getbar() <= #inventory then
|
||||
serialized.b = inventory.getbar()
|
||||
end
|
||||
|
||||
serialized.i = {}
|
||||
local previous_index = 0
|
||||
local previous_serialized = nil
|
||||
for i = 1, #inventory do
|
||||
local item = {}
|
||||
local slot = inventory[i]
|
||||
if inventory.supports_filters() then
|
||||
item.f = inventory.get_filter(i)
|
||||
end
|
||||
|
||||
if slot.valid_for_read then
|
||||
serialize.serialize_item_stack(slot, item)
|
||||
end
|
||||
|
||||
if item.n or item.f or item.e then
|
||||
local item_serialized = game.table_to_json(item)
|
||||
if item_serialized == previous_serialized then
|
||||
local previous_item = serialized.i[#serialized.i]
|
||||
previous_item.r = (previous_item.r or 0) + 1
|
||||
previous_index = i
|
||||
|
||||
else
|
||||
if i ~= previous_index + 1 then
|
||||
item.s = i
|
||||
end
|
||||
|
||||
previous_index = i
|
||||
previous_serialized = item_serialized
|
||||
table.insert(serialized.i, item)
|
||||
end
|
||||
|
||||
else
|
||||
-- Either an empty slot or serilization failed
|
||||
previous_index = 0
|
||||
previous_serialized = nil
|
||||
end
|
||||
end
|
||||
|
||||
return serialized
|
||||
end
|
||||
|
||||
function serialize.deserialize_inventory(inventory, serialized)
|
||||
if serialized.b and inventory.hasbar() then
|
||||
inventory.setbar(serialized.b)
|
||||
end
|
||||
|
||||
local last_slot_index = 0
|
||||
for _, entry in ipairs(serialized.i) do
|
||||
local base_index = entry.s or last_slot_index + 1
|
||||
|
||||
local repeat_count = entry.r or 0
|
||||
for offset = 0, repeat_count do
|
||||
-- XXX what if the inventory is smaller on this instance?
|
||||
local index = base_index + offset
|
||||
local slot = inventory[index]
|
||||
if entry.f then
|
||||
local call_success, call_return = pcall(inventory.set_filter, index, entry.f)
|
||||
if not call_success then
|
||||
print("Error: '" .. call_return .. "' thrown setting filter " .. entry.f)
|
||||
|
||||
elseif not call_return then
|
||||
print("Error: Failed to set filter " .. entry.f)
|
||||
end
|
||||
end
|
||||
|
||||
if entry.n or entry.e then
|
||||
serialize.deserialize_item_stack(slot, entry)
|
||||
end
|
||||
end
|
||||
last_slot_index = base_index + repeat_count
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
return serialize
|
||||
@@ -1,17 +1,14 @@
|
||||
{
|
||||
"name": "clusterio",
|
||||
"version": "2.0.0-alpha",
|
||||
"description": "Interserver communication for factorio",
|
||||
"repository": "https://github.com/clusterio/factorioClusterio",
|
||||
"main": "master.js",
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "mocha --exit --check-leaks -R spec --recursive test plugins/*/test",
|
||||
"fast-test": "FAST_TEST=y npm test",
|
||||
"cover": "nyc npm test",
|
||||
"fast-cover": "FAST_TEST=y nyc npm test",
|
||||
"ci-cover": "nyc --reporter=lcovonly npm run-script test",
|
||||
"test": "mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
|
||||
"fast-test": "FAST_TEST=y pnpm test",
|
||||
"cover": "nyc pnpm test",
|
||||
"fast-cover": "FAST_TEST=y nyc pnpm test",
|
||||
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
|
||||
"lint": "eslint \"*.js\" packages plugins test",
|
||||
"docs": "jsdoc --configure docs/jsdoc.json",
|
||||
"install": "node lib/npmPostinstall",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"nyc": {
|
||||
@@ -20,61 +17,25 @@
|
||||
"plugins/*/test/**"
|
||||
]
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
],
|
||||
"author": "Danielv123",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.16 <11 || >= 11.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"as-table": "^1.0.31",
|
||||
"base64url": "^3.0.0",
|
||||
"bcrypt": "=3.0.6",
|
||||
"bcrypt-promise": "^2.0.0",
|
||||
"body-parser": "^1.18.2",
|
||||
"chalk": "^3.0.0",
|
||||
"cli-interact": "^0.1.9",
|
||||
"compression": "^1.7.2",
|
||||
"cookie-parser": "^1.4.3",
|
||||
"deepmerge": "^4.0.0",
|
||||
"ejs": "^2.5.7",
|
||||
"express": "^4.16.2",
|
||||
"express-fileupload": "1.1.5",
|
||||
"follow-redirects": "^1.5.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"ini": "^1.3.5",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
"jszip": "^3.2.2",
|
||||
"lib": "file:./lib",
|
||||
"luamin": "^1.0.4",
|
||||
"minimist": "^1.2.0",
|
||||
"moment": "^2.22.2",
|
||||
"needle": "^2.4.0",
|
||||
"node-factorio-api": "^0.3.8",
|
||||
"node-forge": "^0.7.1",
|
||||
"rcon-client": "^4.2.0",
|
||||
"request": "^2.83.0",
|
||||
"sanitizer": "^0.1.3",
|
||||
"semver": "^7.1.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"simple-git": "^1.96.0",
|
||||
"single-line-log": "^1.1.2",
|
||||
"sync-request": "^4.0.1",
|
||||
"typescript-collections": "^1.3.2",
|
||||
"ws": "^7.2.3",
|
||||
"yargs": "^14.2.0"
|
||||
"node": ">=12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"html5-validator": "^1.2.1",
|
||||
"jsdoc": "^3.6.3",
|
||||
"klaw": "^3.0.0",
|
||||
"mocha": "^6.2.0",
|
||||
"mocha-sinon": "^2.1.0",
|
||||
"mocha.parallel": "^0.15.5",
|
||||
"nock": "^11.3.0",
|
||||
"nyc": "^14.1.1",
|
||||
"sinon": "^7.4.1"
|
||||
"@clusterio/ctl": "workspace:*",
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"@clusterio/master": "workspace:*",
|
||||
"@clusterio/slave": "workspace:*",
|
||||
"eslint": "^8.18.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "^4.16.2",
|
||||
"form-data": "^4.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"jsdoc": "^3.6.6",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
"jszip": "^3.2.2",
|
||||
"mocha": "^9.2.2",
|
||||
"node-forge": "^1.3.1",
|
||||
"nyc": "^15.1.0",
|
||||
"phin": "^3.5.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Clusterio installer
|
||||
|
||||
Installer for Clusterio.
|
||||
|
||||
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
|
||||
@@ -0,0 +1,662 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
"use strict";
|
||||
const child_process = require("child_process");
|
||||
const fs = require("fs-extra");
|
||||
const inquirer = require("inquirer");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const phin = require("phin");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
const yargs = require("yargs");
|
||||
|
||||
const { levels, logger, setLogLevel } = require("./logging");
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
const availablePlugins = [
|
||||
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
|
||||
{ name: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
|
||||
{ name: "Player Auth", value: "@clusterio/plugin-player_auth" },
|
||||
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
|
||||
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
|
||||
|
||||
// Comunity plugins
|
||||
{ name: "Discord Bridge", value: "@hornwitser/discord_bridge" },
|
||||
{ name: "Server Select", value: "@hornwitser/server_select" },
|
||||
];
|
||||
|
||||
const factorioLocations = {
|
||||
win32: [
|
||||
"C:\\Program Files\\Factorio",
|
||||
"C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio",
|
||||
],
|
||||
darwin: [
|
||||
"/Applications/factorio.app/Contents",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
class LineSplitter extends stream.Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, callback) {
|
||||
if (this._partial) {
|
||||
chunk = Buffer.concat([this._partial, chunk]);
|
||||
this._partial = null;
|
||||
}
|
||||
|
||||
while (chunk.length) {
|
||||
let end = chunk.indexOf("\n");
|
||||
if (end === -1) {
|
||||
this._partial = chunk;
|
||||
break;
|
||||
}
|
||||
|
||||
let next = end + 1;
|
||||
// Eat carriage return as well if present
|
||||
if (end >= 1 && chunk[end-1] === "\r".charCodeAt(0)) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
let line = chunk.slice(0, end);
|
||||
chunk = chunk.slice(next);
|
||||
this.push(line);
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback) {
|
||||
if (this._partial) {
|
||||
this.push(this._partial);
|
||||
this._partial = null;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
class InstallError extends Error { }
|
||||
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let temporary = `${file}.tmp`;
|
||||
await fs.outputFile(temporary, data, options);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
let stdout = new LineSplitter({ readableObjectMode: true });
|
||||
stdout.on("data", line => { logger.verbose(line.toString()); });
|
||||
child.stdout.pipe(stdout);
|
||||
let stderr = new LineSplitter({ readableObjectMode: true });
|
||||
stderr.on("data", line => { logger.verbose(`err: ${line.toString()}`); });
|
||||
child.stderr.pipe(stderr);
|
||||
});
|
||||
}
|
||||
|
||||
async function execMaster(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join("packages", "master"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriomaster${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execSlave(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join("packages", "slave"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execCtl(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join("packages", "ctl"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
|
||||
}
|
||||
|
||||
function validateSlaveToken(token) {
|
||||
let parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(parts[1], "base64"));
|
||||
} catch (err) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
|
||||
if (parsed.aud !== "slave" || !Number.isInteger(parsed.slave)) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateInstallDir() {
|
||||
let entries = new Set(await fs.readdir("."));
|
||||
if (entries.size) {
|
||||
if (!entries.has("package.json")) {
|
||||
throw new InstallError("Refusing to install to non-empty directory");
|
||||
}
|
||||
|
||||
let packageData;
|
||||
try {
|
||||
packageData = JSON.parse(await fs.readFile("package.json"));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Failed to read package.json: ${err.message}`);
|
||||
}
|
||||
if (packageData.name !== "clusterio-install" || !packageData.private) {
|
||||
throw new InstallError("Refusing to run on non-clusterio installation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateNotRoot(args) {
|
||||
if (args.allowInstallAsRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (os.userInfo().uid === 0) {
|
||||
throw new InstallError(
|
||||
"Refusing to install as root. Create a separate user account for clusterio " +
|
||||
"and then use su/sudo to switch to it before invoking the installer."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function installClusterio(mode, plugins) {
|
||||
try {
|
||||
await safeOutputFile("package.json", JSON.stringify({
|
||||
name: "clusterio-install",
|
||||
private: true,
|
||||
}, null, 2), { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw new InstallError(`Failed to write package.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let components = [];
|
||||
if (["standalone", "master"].includes(mode)) {
|
||||
components.push("@clusterio/master");
|
||||
}
|
||||
if (["standalone", "slave"].includes(mode)) {
|
||||
components.push("@clusterio/slave");
|
||||
}
|
||||
if (mode === "ctl") {
|
||||
components.push("@clusterio/ctl");
|
||||
}
|
||||
|
||||
logger.info(`Please wait, installing ${mode}`);
|
||||
try {
|
||||
await execFile(`npm${scriptExt}`, ["install", ...components, ...plugins]);
|
||||
} catch (err) {
|
||||
throw new InstallError(`Failed to install: ${err.message}`);
|
||||
}
|
||||
|
||||
if (plugins.length) {
|
||||
logger.info("Setting up plugins");
|
||||
let pluginList;
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile("plugin-list.json")));
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
pluginList = new Map();
|
||||
} else {
|
||||
throw new InstallError(`Error loading plugin-list.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
// eslint-disable-next-line node/global-require
|
||||
let pluginInfo = require(require.resolve(path.posix.join(plugin, "info"), { paths: [process.cwd()] }));
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function groupIdToName(gid) {
|
||||
try {
|
||||
let exec = util.promisify(child_process.exec);
|
||||
let { stdout } = await exec(`getent group ${gid}`);
|
||||
return stdout.split(":")[0];
|
||||
} catch (err) {
|
||||
logger.warn(`getent group ${gid} failed: ${err.message}`);
|
||||
return gid;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScripts(mode) {
|
||||
if (["standalone", "master"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await safeOutputFile(
|
||||
"run-master.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await safeOutputFile(
|
||||
"run-master.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriomaster.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Master
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusteriomaster run --log-level=warn
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
if (["standalone", "slave"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await safeOutputFile(
|
||||
"run-slave.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
|
||||
);
|
||||
} else {
|
||||
await safeOutputFile(
|
||||
"run-slave.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await safeOutputFile(
|
||||
"systemd/clusterioslave.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Slave
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusterioslave run --log-level=warn
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
async function inquirerMissingArgs(args) {
|
||||
let answers = {};
|
||||
if (args.mode) { answers.mode = args.mode; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "mode",
|
||||
message: "Operating mode to install",
|
||||
default: "standalone",
|
||||
choices: [
|
||||
{ name: "Standalone (install both master and slave on this computer)", value: "standalone" },
|
||||
{ name: "Master only", value: "master" },
|
||||
{ name: "Slave only", value: "slave" },
|
||||
{ name: "Ctl only", value: "ctl" },
|
||||
{ name: "Plugins only", value: "plugins" },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (["standalone", "master"].includes(answers.mode)) {
|
||||
if (args.admin) { answers.admin = args.admin; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "admin",
|
||||
message: "Admin account name",
|
||||
validate: input => {
|
||||
if (!input) {
|
||||
return "May not be empty";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "slave") {
|
||||
if (args.slaveName) { answers.slaveName = args.slaveName; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "slaveName",
|
||||
message: "Name of slave",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (["slave", "ctl"].includes(answers.mode)) {
|
||||
if (args.masterUrl) { answers.masterUrl = args.masterUrl; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "masterUrl",
|
||||
message: "Master server URL",
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.masterToken) { answers.masterToken = args.masterToken; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "masterToken",
|
||||
message: "Master authentication Token",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "slave") {
|
||||
validateSlaveToken(answers.masterToken);
|
||||
}
|
||||
|
||||
if (["standalone", "slave"].includes(answers.mode)) {
|
||||
let myIp = "localhost";
|
||||
if (args.publicAddress) {
|
||||
answers.publicAddress = args.publicAddress;
|
||||
} else {
|
||||
try {
|
||||
let result = await phin("https://api.ipify.org/");
|
||||
myIp = result.body.toString();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "publicAddress",
|
||||
message: "Public DNS/IP address of this server",
|
||||
default: myIp,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.factorioDir) { answers.factorioDir = args.factorioDir; }
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
let foundLocations = [];
|
||||
for (let location of locations) {
|
||||
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
foundLocations.push(location);
|
||||
}
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "downloadHeadless",
|
||||
message: "(Linux only) Automatically download latest factorio release?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (answers.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "factorioDir",
|
||||
message: "Path to Factorio installation",
|
||||
choices: [
|
||||
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
|
||||
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
|
||||
{ name: "Provide path manually", value: null },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (answers.factorioDir === null) {
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "factorioDir",
|
||||
message: "Path to Factorio installation",
|
||||
askAnswered: true,
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.ensureDir("factorio");
|
||||
}
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
answers.plugins = [];
|
||||
} else if (args.plugins) {
|
||||
answers.plugins = args.plugins;
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "plugins",
|
||||
message: "Plugins to install",
|
||||
choices: availablePlugins,
|
||||
pageSize: 20,
|
||||
},
|
||||
], answers);
|
||||
|
||||
return answers;
|
||||
}
|
||||
|
||||
async function downloadLinuxServer() {
|
||||
let res = await phin("https://factorio.com/get-download/stable/headless/linux64");
|
||||
|
||||
const url = new URL(res.headers.location);
|
||||
// get the filename of the latest factorio archive from redirected url
|
||||
const filename = path.posix.basename(url.pathname);
|
||||
const version = filename.match(/(?<=factorio_headless_x64_).*(?=\.tar\.xz)/)[0];
|
||||
|
||||
const tmpDir = "temp/create-temp/";
|
||||
const archivePath = tmpDir + filename;
|
||||
const tmpArchivePath = `${archivePath}.tmp`;
|
||||
const factorioDir = `factorio/${version}/`;
|
||||
const tmpFactorioDir = tmpDir + version;
|
||||
|
||||
if (await fs.pathExists(factorioDir)) {
|
||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||
} else {
|
||||
await fs.ensureDir(tmpDir);
|
||||
|
||||
// follow the redirect
|
||||
res = await phin({
|
||||
url: url.href,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||
const writeStream = fs.createWriteStream(tmpArchivePath);
|
||||
res.pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
await fs.ensureDir(tmpFactorioDir);
|
||||
await execFile("tar", [
|
||||
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("error executing command- do you have 'xz-utils' installed?");
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let args = yargs
|
||||
.option("log-level", {
|
||||
nargs: 1, describe: "Log level to print to stdout", default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)), type: "string",
|
||||
})
|
||||
.option("dev", {
|
||||
nargs: 0, describe: "Initialize development repository", hidden: true, default: false, type: "boolean",
|
||||
})
|
||||
.option("mode", {
|
||||
nargs: 1, describe: "Operating mode to install",
|
||||
choices: ["standalone", "master", "slave", "ctl", "plugins"],
|
||||
})
|
||||
.option("admin", {
|
||||
nargs: 1, describe: "Admin account name [standalone/master]", type: "string",
|
||||
})
|
||||
.option("slave-name", {
|
||||
nargs: 1, describe: "Slave name [slave]", type: "string",
|
||||
})
|
||||
.option("master-url", {
|
||||
nargs: 1, describe: "Master URL [slave/ctl]", type: "string",
|
||||
})
|
||||
.option("master-token", {
|
||||
nargs: 1, describe: "Master authentication token [slave/ctl]", type: "string",
|
||||
})
|
||||
.option("public-address", {
|
||||
nargs: 1, describe: "DNS/IP Address to connect to this server [standalone/slave]", type: "string",
|
||||
})
|
||||
.option("factorio-dir", {
|
||||
nargs: 1, describe: "Path to Factorio installation [standalone/slave]", type: "string",
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
})
|
||||
.strict()
|
||||
;
|
||||
|
||||
if (process.platform === "linux") {
|
||||
args = args
|
||||
.option("allow-install-as-root", {
|
||||
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
||||
})
|
||||
.option("download-headless", {
|
||||
nargs: 0,
|
||||
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
|
||||
"Can be set to false using --no-download-headless.",
|
||||
type: "boolean",
|
||||
})
|
||||
.conflicts("factorio-dir", "download-headless");
|
||||
}
|
||||
|
||||
args = args.argv;
|
||||
|
||||
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
|
||||
dev = args.dev;
|
||||
|
||||
if (!dev) {
|
||||
await validateInstallDir();
|
||||
await validateNotRoot(args);
|
||||
}
|
||||
|
||||
let answers = await inquirerMissingArgs(args);
|
||||
if (answers.downloadHeadless) {
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
logger.verbose(JSON.stringify(answers));
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
|
||||
let adminToken = null;
|
||||
if (["standalone", "master"].includes(answers.mode)) {
|
||||
logger.info("Setting up master");
|
||||
await execMaster(["bootstrap", "create-admin", answers.admin]);
|
||||
let result = await execMaster(["bootstrap", "generate-user-token", answers.admin]);
|
||||
adminToken = result.stdout.split("\n").slice(-2)[0];
|
||||
}
|
||||
|
||||
if (answers.mode === "standalone") {
|
||||
logger.info("Setting up slave");
|
||||
await execSlave(["config", "set", "slave.name", "local"]);
|
||||
|
||||
let result = await execSlave(["config", "show", "slave.id"]);
|
||||
let slaveId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
|
||||
|
||||
result = await execMaster(["bootstrap", "generate-slave-token", slaveId]);
|
||||
let slaveToken = result.stdout.split("\n").slice(-2)[0];
|
||||
|
||||
await execSlave(["config", "set", "slave.master_token", slaveToken]);
|
||||
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
|
||||
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
|
||||
}
|
||||
|
||||
if (answers.mode === "slave") {
|
||||
logger.info("Setting up slave");
|
||||
let slaveId = JSON.parse(Buffer.from(answers.masterToken.split(".")[1], "base64")).slave;
|
||||
await execSlave(["config", "set", "slave.id", slaveId]);
|
||||
await execSlave(["config", "set", "slave.name", answers.slaveName]);
|
||||
await execSlave(["config", "set", "slave.master_url", answers.masterUrl]);
|
||||
await execSlave(["config", "set", "slave.master_token", answers.masterToken]);
|
||||
await execSlave(["config", "set", "slave.public_address", answers.publicAddress]);
|
||||
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
|
||||
}
|
||||
|
||||
if (!dev && ["standalone", "master", "slave"].includes(answers.mode)) {
|
||||
logger.info("Writing run scripts");
|
||||
await writeScripts(answers.mode);
|
||||
}
|
||||
|
||||
if (answers.mode === "ctl") {
|
||||
await execCtl(["control-config", "set", "control.master_url", answers.masterUrl]);
|
||||
await execCtl(["control-config", "set", "control.master_token", answers.masterToken]);
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log(`Successfully installed ${answers.mode}`);
|
||||
if (adminToken) {
|
||||
console.log(`Admin authentication token: ${adminToken}`);
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
main().catch(err => {
|
||||
if (err instanceof InstallError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
logger.fatal(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured installing clusterio, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable no-console */
|
||||
"use strict";
|
||||
const chalk = require("chalk");
|
||||
|
||||
const levels = Object.freeze({
|
||||
fatal: 0,
|
||||
error: 1,
|
||||
warn: 2,
|
||||
audit: 3,
|
||||
info: 4,
|
||||
verbose: 6,
|
||||
});
|
||||
|
||||
const colors = {
|
||||
fatal: "inverse red",
|
||||
error: "bold redBright",
|
||||
warn: "bold yellowBright",
|
||||
audit: "bold greenBright",
|
||||
info: "bold blueBright",
|
||||
verbose: "bold grey",
|
||||
};
|
||||
|
||||
let maxLogLevel = levels.info;
|
||||
const logger = {};
|
||||
for (let [name, level] of Object.entries(levels)) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
logger[name] = message => {
|
||||
if (level > maxLogLevel) {
|
||||
return;
|
||||
}
|
||||
let style = chalk;
|
||||
for (let part of colors[name].split(" ")) {
|
||||
style = style[part];
|
||||
}
|
||||
if (["fatal", "error"].includes(name)) {
|
||||
console.error(`${style(name)} ${message}`);
|
||||
} else if (["warn"].includes(name)) {
|
||||
console.warn(`${style(name)} ${message}`);
|
||||
} else {
|
||||
console.log(`${style(name)} ${message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
colors,
|
||||
levels,
|
||||
setLogLevel: (level) => { maxLogLevel = level; },
|
||||
logger,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@clusterio/create",
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio",
|
||||
"clusterio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"inquirer": "^8.1.0",
|
||||
"phin": "^3.5.1",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Clusterio Command Line Interface
|
||||
|
||||
|
||||
Provides a command line interface for managing a Clusterio cluster.
|
||||
|
||||
Note that this document only describes the options and commands for managing clusterioctl itself, see [Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
npx clusterioctl <command>
|
||||
|
||||
Common options:
|
||||
|
||||
* `--plugin-list <file>` JSON file to use for storing the list of plugins that are available to ctl.
|
||||
Defaults to `plugin-list.json` and will be created if it does not exist.
|
||||
See the `plugin` command for managing this list.
|
||||
|
||||
* `--config <file>` JSON file to use for storing configuration for ctl.
|
||||
Defaults to `config-control.json` and will be created if it does not exist.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
|
||||
### `plugin <command>`
|
||||
|
||||
Configure plugins available to be loaded by ctl.
|
||||
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
|
||||
|
||||
|
||||
#### `plugin add <path>`
|
||||
|
||||
Add plugin either by require path or relative/absolute path to the plugin directory.
|
||||
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin remove <name>`
|
||||
|
||||
Remove a plugin by its name.
|
||||
This should be done before uninstalling the plugin, otherwise there will be an error when Clusterio tries to load the info from the plugin.
|
||||
Removing and unistalling a plugin is usually not neccessary as the functions provided by the plugin can be disabled in the config.
|
||||
|
||||
For example, uninstalling the Subspace Storage plugin:
|
||||
|
||||
npx clusterioctl plugin remove subspace_storage
|
||||
npm uninstall @clusterio/plugin-subspace_storage
|
||||
|
||||
Since the `plugin-list.json` is shared between master, slave and ctl you usually only need to do this once per machine.
|
||||
|
||||
|
||||
#### `plugin list`
|
||||
|
||||
Lists the plugins set up to be available by name followed by path.
|
||||
|
||||
|
||||
### `config-control`
|
||||
|
||||
Manage the ctl configuration.
|
||||
This allows setting the url and token to the master server to manage.
|
||||
|
||||
|
||||
#### `config-control set <config-entry> [value]`
|
||||
|
||||
Set a config entry to the given value.
|
||||
If value is not provided the entry is set to null.
|
||||
If the config entry is of type object the value must be a valid JSON serialization of an object.
|
||||
|
||||
See docs/configuration.md in the main repositiory for the available configuration.
|
||||
|
||||
|
||||
#### `config-control show <config-entry>`
|
||||
|
||||
Shows the value for a single config entry.
|
||||
|
||||
|
||||
#### `config-control list`
|
||||
|
||||
Lists up all configuration entries with their currently configured values.
|
||||
|
||||
|
||||
## See Also
|
||||
|
||||
[Managing a cluster](/docs/managing-a-cluster.md) for the available commands used for managing a cluster.
|
||||
|
||||
[The Clusterio repository](https://github.com/clusterio/clusterio) for instructions on how to set up a cluster.
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Command line interface for controlling a Clusterio cluster
|
||||
* @module ctl/ctl
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const yargs = require("yargs");
|
||||
const version = require("./package").version;
|
||||
const setBlocking = require("set-blocking");
|
||||
|
||||
const libLink = require("@clusterio/lib/link");
|
||||
const libErrors = require("@clusterio/lib/errors");
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
const libPluginLoader = require("@clusterio/lib/plugin_loader");
|
||||
const libCommand = require("@clusterio/lib/command");
|
||||
const libSharedCommands = require("@clusterio/lib/shared_commands");
|
||||
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
|
||||
const libLoggingUtils = require("@clusterio/lib/logging_utils");
|
||||
|
||||
const commands = require("./src/commands");
|
||||
|
||||
|
||||
/**
|
||||
* Connector for control connection to master server
|
||||
* @private
|
||||
*/
|
||||
class ControlConnector extends libLink.WebSocketClientConnector {
|
||||
constructor(url, maxReconnectDelay, tlsCa, token) {
|
||||
super(url, maxReconnectDelay, tlsCa);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
register() {
|
||||
logger.verbose("Connector | registering control");
|
||||
this.sendHandshake("register_control", {
|
||||
token: this._token,
|
||||
agent: "clusterioctl",
|
||||
version: version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the master server over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
class Control extends libLink.Link {
|
||||
|
||||
constructor(connector, controlConfig, tlsCa, controlPlugins) {
|
||||
super("control", "master", connector);
|
||||
libLink.attachAllMessages(this);
|
||||
|
||||
/**
|
||||
* Control config used for connecting to the master.
|
||||
* @type {module:lib/config.ControlConfig}
|
||||
*/
|
||||
this.config = controlConfig;
|
||||
/**
|
||||
* Certificate authority used to validate TLS connections to the master.
|
||||
* @type {?string}
|
||||
*/
|
||||
this.tlsCa = tlsCa;
|
||||
/**
|
||||
* Mapping of plugin names to their instance for loaded plugins.
|
||||
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
|
||||
*/
|
||||
this.plugins = controlPlugins;
|
||||
for (let controlPlugin of controlPlugins.values()) {
|
||||
libPlugin.attachPluginMessages(this, controlPlugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the control connection alive after the command completes.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.keepOpen = false;
|
||||
}
|
||||
|
||||
async accountUpdateEventHandler() { }
|
||||
|
||||
async slaveUpdateEventHandler() { }
|
||||
|
||||
async instanceUpdateEventHandler() { }
|
||||
|
||||
async saveListUpdateEventHandler() { }
|
||||
|
||||
async userUpdateEventHandler() { }
|
||||
|
||||
async setLogSubscriptions({
|
||||
all = false,
|
||||
master = false,
|
||||
slave_ids = [],
|
||||
instance_ids = [],
|
||||
max_level = null,
|
||||
}) {
|
||||
await libLink.messages.setLogSubscriptions.send(this, {
|
||||
all, master, slave_ids, instance_ids, max_level,
|
||||
});
|
||||
}
|
||||
|
||||
async logMessageEventHandler(message) {
|
||||
logger.log(message.data.info);
|
||||
}
|
||||
|
||||
async debugWsMessageEventHandler(message) {
|
||||
print("WS", message.data.direction, message.data.content);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
try {
|
||||
await libLink.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
if (!(err instanceof libErrors.SessionLost)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await this.connector.close(1000, "Control Quit");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlugins(pluginList) {
|
||||
let pluginInfos = await libPluginLoader.loadPluginInfos(pluginList);
|
||||
libConfig.registerPluginConfigGroups(pluginInfos);
|
||||
libConfig.finalizeConfigs();
|
||||
|
||||
let controlPlugins = new Map();
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
if (!pluginInfo.controlEntrypoint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ControlPluginClass = await libPluginLoader.loadControlPluginClass(pluginInfo);
|
||||
let controlPlugin = new ControlPluginClass(pluginInfo, logger);
|
||||
controlPlugins.set(pluginInfo.name, controlPlugin);
|
||||
await controlPlugin.init();
|
||||
}
|
||||
return controlPlugins;
|
||||
}
|
||||
|
||||
async function startControl() {
|
||||
yargs
|
||||
.scriptName("clusterioctl")
|
||||
.usage("$0 <command> [options]")
|
||||
.option("log-level", {
|
||||
nargs: 1,
|
||||
describe: "Log level to print to stderr",
|
||||
default: "server",
|
||||
choices: Object.keys(levels),
|
||||
type: "string",
|
||||
})
|
||||
.option("config", {
|
||||
nargs: 1,
|
||||
describe: "config file to get credentails from",
|
||||
default: "config-control.json",
|
||||
defaultDescription: "auto",
|
||||
type: "string",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.command("plugin", "Manage available plugins", libSharedCommands.pluginCommand)
|
||||
.command("control-config", "Manage Control config", libSharedCommands.configCommand)
|
||||
.wrap(yargs.terminalWidth())
|
||||
.help(false) // Disable help to avoid triggering it on the first parse.
|
||||
;
|
||||
|
||||
// Parse the args first to get the configured plugin list.
|
||||
let args = yargs.parse();
|
||||
|
||||
// Log stream for the ctl session.
|
||||
logger.add(new ConsoleTransport({
|
||||
errorLevels: Object.keys(levels),
|
||||
level: args.logLevel,
|
||||
format: new libLoggingUtils.TerminalFormat(),
|
||||
}));
|
||||
|
||||
logger.verbose(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = new Map();
|
||||
try {
|
||||
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList)));
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (args._[0] === "plugin") {
|
||||
await libSharedCommands.handlePluginCommand(args, pluginList, args.pluginList);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.verbose("Loading Plugins");
|
||||
let controlPlugins = await loadPlugins(pluginList);
|
||||
|
||||
// Add all cluster management commands including ones from plugins
|
||||
let rootCommands = await commands.registerCommands(controlPlugins, yargs);
|
||||
|
||||
// Reparse after commands have been added with help and strict checking.
|
||||
args = yargs
|
||||
.help()
|
||||
.strict()
|
||||
.parse()
|
||||
;
|
||||
|
||||
logger.verbose(`Loading config from ${args.config}`);
|
||||
let controlConfig = new libConfig.ControlConfig("control");
|
||||
try {
|
||||
await controlConfig.load(JSON.parse(await fs.readFile(args.config)));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.verbose("Config not found, initializing new config");
|
||||
await controlConfig.init();
|
||||
|
||||
} else {
|
||||
throw new libErrors.StartupError(`Failed to load ${args.config}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (args._.length === 0) {
|
||||
yargs.showHelp();
|
||||
yargs.exit();
|
||||
}
|
||||
|
||||
// Handle the control-config command before trying to connect.
|
||||
if (args._[0] === "control-config") {
|
||||
await libSharedCommands.handleConfigCommand(args, controlConfig, args.config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which command is being executed.
|
||||
let commandPath = [...args._];
|
||||
let targetCommand = rootCommands;
|
||||
while (commandPath.length && targetCommand instanceof libCommand.CommandTree) {
|
||||
targetCommand = targetCommand.get(commandPath.shift());
|
||||
}
|
||||
|
||||
// The remaining commands require connecting to the master server.
|
||||
if (!controlConfig.get("control.master_url") || !controlConfig.get("control.master_token")) {
|
||||
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let tlsCa = null;
|
||||
let tlsCaPath = controlConfig.get("control.tls_ca");
|
||||
if (tlsCaPath) {
|
||||
tlsCa = await fs.readFile(tlsCaPath);
|
||||
}
|
||||
|
||||
let controlConnector = new ControlConnector(
|
||||
controlConfig.get("control.master_url"),
|
||||
controlConfig.get("control.max_reconnect_delay"),
|
||||
tlsCa,
|
||||
controlConfig.get("control.master_token")
|
||||
);
|
||||
let control = new Control(controlConnector, controlConfig, tlsCa, controlPlugins);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch (err) {
|
||||
if (err instanceof libErrors.AuthenticationFailed) {
|
||||
throw new libErrors.StartupError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
// eslint-disable-next-line node/no-process-exit
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await targetCommand.run(args, control);
|
||||
|
||||
} catch (err) {
|
||||
control.keepOpen = false;
|
||||
if (err instanceof libErrors.CommandError) {
|
||||
logger.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof libErrors.RequestError) {
|
||||
logger.error(`Error sending request: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (!control.keepOpen) {
|
||||
await control.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Control,
|
||||
};
|
||||
|
||||
|
||||
if (module === require.main) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`
|
||||
+==========================================================+
|
||||
I WARNING: This is the development branch for the 2.0 I
|
||||
I version of clusterio. Expect things to break. I
|
||||
+==========================================================+
|
||||
`
|
||||
);
|
||||
startControl().catch(err => {
|
||||
if (!(err instanceof libErrors.StartupError)) {
|
||||
logger.fatal(`
|
||||
+------------------------------------------------------------+
|
||||
| Unexpected error occured while starting control, please |
|
||||
| report it to https://github.com/clusterio/clusterio/issues |
|
||||
+------------------------------------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
} else {
|
||||
logger.error(`
|
||||
+---------------------------------+
|
||||
| Unable to to start clusterioctl |
|
||||
+---------------------------------+
|
||||
${err.stack}`
|
||||
);
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@clusterio/ctl",
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.13",
|
||||
"main": "ctl.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusterioctl": "./ctl.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "^2.0.0-alpha.13",
|
||||
"as-table": "^1.0.31",
|
||||
"fs-extra": "^8.1.0",
|
||||
"phin": "^3.5.1",
|
||||
"set-blocking": "^2.0.0",
|
||||
"winston": "^3.3.3",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
/**
|
||||
* Helper class for implementing the exponential backoff algorithm
|
||||
*
|
||||
* Provides a convenient interface for implementing an exponential backoff
|
||||
* when reconnecting or resending in a distributed network.
|
||||
*
|
||||
* Once instantiated the delay method will return the next interval to wait
|
||||
* for in milliseconds before making another attempt. For each attempt the
|
||||
* delay chosen is a random value between 0 and the current maximum delay
|
||||
* wwhich starts at a value of 2 * `base` and doubles for ever attempt up to
|
||||
* the passed value of `max`. The value for the delay will reset back to
|
||||
* `base` if more time than the value for `reset` has passed between two
|
||||
* invocations of delay.
|
||||
*
|
||||
* @alias module:lib/ExponentialBackoff
|
||||
*/
|
||||
class ExponentialBackoff {
|
||||
/**
|
||||
* Construct ExponentialBackoff helper
|
||||
*
|
||||
* The options passed may also be modified on the instance at runtime.
|
||||
*
|
||||
* @param {object=} options -
|
||||
* Options for this exponential backoff helper
|
||||
* @param {number=} [options.base=1] -
|
||||
* Base delay in seconds. The first delay will on average be this
|
||||
* long, but can be anywhere between 0 and 2 times this value.
|
||||
* @param {number=} [options.max=60] -
|
||||
* Maximum delay in seconds. A delay will not take more than this
|
||||
* value.
|
||||
* @param {number=} [options.reset=2*max] -
|
||||
* Time in seconds between two invocations of delay which will cause
|
||||
* it to start over with `base` instead of doubling.
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
/**
|
||||
* Base delay in seconds. The first delay will on average be this
|
||||
* long, but can be anywhere between 0 and 2 times this value.
|
||||
* @type {number}
|
||||
*/
|
||||
this.base = options.base || 1;
|
||||
/**
|
||||
* Maximum delay in seconds. A delay will not take more than this
|
||||
* value.
|
||||
* @type {number}
|
||||
*/
|
||||
this.max = options.max || 60;
|
||||
/**
|
||||
* Time in seconds between two invocations of delay which will cause
|
||||
* it to start over with `base` instead of doubling.
|
||||
* @type {number}
|
||||
*/
|
||||
this.reset = options.reset || 2 * this.max;
|
||||
|
||||
this._exp = 0;
|
||||
this._lastInvocationTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next delay to wait
|
||||
*
|
||||
* Returns the next delay to wait according to the exponential backoff
|
||||
* algorithm. This is a value between 0 and min(2 * options.base *
|
||||
* 2<sup>retries</sup>, optinos.max).
|
||||
*
|
||||
* @returns {number} time in milliseconds to wait.
|
||||
*/
|
||||
delay() {
|
||||
let invocationTime = Date.now();
|
||||
let interval = (invocationTime - this._lastInvocationTime) / 1000;
|
||||
this._lastInvocationTime = invocationTime;
|
||||
|
||||
if (interval > this.reset) {
|
||||
this._exp = 0;
|
||||
}
|
||||
|
||||
this._exp = Math.min(this._exp + 1, Math.log2(this.max));
|
||||
return Math.random() * this.base * 2 ** this._exp * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ExponentialBackoff;
|
||||
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Stastics collected about a player.
|
||||
* @alias module:lib/PlayerStats
|
||||
*/
|
||||
class PlayerStats {
|
||||
/**
|
||||
* Count of the number of times this player has been seen joining this server.
|
||||
* @type {number}
|
||||
*/
|
||||
joinCount = 0;
|
||||
|
||||
/**
|
||||
* Time in ms this player has been seen online on this instance.
|
||||
* @type {number}
|
||||
*/
|
||||
onlineTimeMs = 0;
|
||||
|
||||
/**
|
||||
* Timestamp the player was last seen joining.
|
||||
* @type {Date=}
|
||||
*/
|
||||
lastJoinAt;
|
||||
|
||||
/**
|
||||
* Timestamp the player was last seen leaving.
|
||||
* @type {Date=}
|
||||
*/
|
||||
lastLeaveAt;
|
||||
|
||||
/**
|
||||
* Reason the player was last seen leaving with.
|
||||
* @type {string=}
|
||||
*/
|
||||
lastLeaveReason;
|
||||
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
"join_count": { type: "integer" },
|
||||
"online_time_ms": { type: "number" },
|
||||
"last_join_at": { type: "number" },
|
||||
"last_leave_at": { type: "number" },
|
||||
"last_leave_reason": { type: "string" },
|
||||
},
|
||||
};
|
||||
|
||||
constructor(json = {}) {
|
||||
if (json["join_count"]) {
|
||||
this.joinCount = json["join_count"];
|
||||
}
|
||||
if (json["online_time_ms"]) {
|
||||
this.onlineTimeMs = json["online_time_ms"];
|
||||
}
|
||||
if (json["last_join_at"]) {
|
||||
this.lastJoinAt = new Date(json["last_join_at"]);
|
||||
}
|
||||
if (json["last_leave_at"]) {
|
||||
this.lastLeaveAt = new Date(json["last_leave_at"]);
|
||||
this.lastLeaveReason = json["last_leave_reason"] || "quit";
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
let json = {};
|
||||
if (this.joinCount) {
|
||||
json["join_count"] = this.joinCount;
|
||||
}
|
||||
if (this.onlineTimeMs) {
|
||||
json["online_time_ms"] = this.onlineTimeMs;
|
||||
}
|
||||
if (this.lastJoinAt) {
|
||||
json["last_join_at"] = this.lastJoinAt.getTime();
|
||||
}
|
||||
if (this.lastLeaveAt) {
|
||||
json["last_leave_at"] = this.lastLeaveAt.getTime();
|
||||
json["last_leave_reason"] = this.lastLeaveReason;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlayerStats;
|
||||
@@ -0,0 +1,5 @@
|
||||
# Common Library for Clusterio
|
||||
|
||||
This package contains functionality shared between master, slave, ctl and/or plugins of Clusterio.
|
||||
|
||||
See [the Clusterio repository](https://github.com/clusterio/clusterio) for more information on Clusterio.
|
||||
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
/**
|
||||
* Helper class for rate limiting actions
|
||||
*
|
||||
* Provides a simple interface for implementing actions that are rate
|
||||
* limited on invocations.
|
||||
*
|
||||
* @example
|
||||
* const rateLimiter = RateLimiter({
|
||||
* action: () => { console.log("action"); },
|
||||
* maxRate: 1,
|
||||
* });
|
||||
*
|
||||
* for (let i = 0; i < 100; i++) {
|
||||
* rateLimiter.activate();
|
||||
* // Prints action twice to the console, once for the first activation
|
||||
* // and one more time 1 second later for 99 other activations that were
|
||||
* // exceeding the rate limit.
|
||||
* }
|
||||
* @alias module:lib/RateLimiter
|
||||
*/
|
||||
class RateLimiter {
|
||||
/**
|
||||
* Construct RateLimiter helper
|
||||
*
|
||||
* The options passed may also be modified on the instance at runtime.
|
||||
*
|
||||
* @param {object=} options - Options for this rate limiter
|
||||
* @param {number=} options.maxRate -
|
||||
* Maximum rate of activations per second.
|
||||
* @param {function()=} options.action -
|
||||
* Callback invoked at maste `maxRate` per second on activation.
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
/**
|
||||
* Maximum rate of activations per second.
|
||||
* @type {number}
|
||||
*/
|
||||
this.maxRate = options.maxRate || 1;
|
||||
|
||||
/**
|
||||
* Callback invoked at most `maxRate` per second on activation.
|
||||
* @type {function()}
|
||||
*/
|
||||
this.action = options.action || null;
|
||||
|
||||
this._lastRun = 0;
|
||||
this._runTimeout = null;
|
||||
this.lastProgressBroadcast = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the rate limited action
|
||||
*
|
||||
* Checks if the invocation of the rate limited action now would fall
|
||||
* within the rate limit. If it is within the limit then the action is
|
||||
* invoked immediatly, otherwise a timeout is registered to invoke the
|
||||
* action at next point in time where it will be within the rate limit.
|
||||
*
|
||||
* @returns {boolean} true if activation was within the rate limit.
|
||||
*/
|
||||
activate() {
|
||||
let now = Date.now();
|
||||
if (now < this._lastRun + 1000 / this.maxRate) {
|
||||
if (!this._runTimeout && this.action !== null) {
|
||||
this._runTimeout = setTimeout(() => {
|
||||
this._runTimeout = null;
|
||||
this._lastRun = Date.now();
|
||||
this.action();
|
||||
}, this._lastRun + 1000 / this.maxRate - now);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// We may end up activating before the timeout does.
|
||||
if (this._runTimeout) {
|
||||
clearTimeout(this._runTimeout);
|
||||
this._runTimeout = null;
|
||||
}
|
||||
|
||||
this._lastRun = now;
|
||||
if (this.action !== null) {
|
||||
this.action();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending action
|
||||
*
|
||||
* Cancel pending action invocation. This is necessary when shutting
|
||||
* down the use of the rate limit as a timeout is registered to invoke
|
||||
* the next action when activated faster than the rate limit allows.
|
||||
*/
|
||||
cancel() {
|
||||
if (this._runTimeout) {
|
||||
clearTimeout(this._runTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RateLimiter;
|
||||
@@ -0,0 +1,12 @@
|
||||
export { default as libConfig } from "./config";
|
||||
export { default as libErrors } from "./errors";
|
||||
export { default as libFactorio } from "./factorio/exchange_string";
|
||||
export { default as libHelpers } from "./helpers";
|
||||
export { default as libLink } from "./link";
|
||||
export { default as libLogging } from "./logging";
|
||||
export { default as libPlugin } from "./plugin";
|
||||
export { default as libSchema } from "./schema";
|
||||
export { default as libUsers } from "./users";
|
||||
|
||||
export { default as ExponentialBackoff } from "./ExponentialBackoff";
|
||||
export { default as RateLimiter } from "./RateLimiter";
|
||||
@@ -0,0 +1,135 @@
|
||||
/* eslint-disable no-console */
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const JSZip = require("jszip");
|
||||
const klaw = require("klaw");
|
||||
const path = require("path");
|
||||
const stream = require("stream");
|
||||
const util = require("util");
|
||||
const yargs = require("yargs");
|
||||
|
||||
const finished = util.promisify(stream.finished);
|
||||
|
||||
|
||||
async function buildMod(args, info) {
|
||||
if (args.build) {
|
||||
await fs.ensureDir(args.outputDir);
|
||||
let modName = `${info.name}_${info.version}`;
|
||||
|
||||
if (args.pack) {
|
||||
let zip = new JSZip();
|
||||
let walker = klaw(args.sourceDir)
|
||||
.on("data", item => {
|
||||
if (item.stats.isFile()) {
|
||||
// On Windows the path created uses backslashes as the directory sepparator
|
||||
// but the zip file needs to use forward slashes. We can't use the posix
|
||||
// version of relative here as it doesn't work with Windows style paths.
|
||||
let basePath = path.relative(args.sourceDir, item.path).replace(/\\/g, "/");
|
||||
zip.file(path.posix.join(modName, basePath), fs.createReadStream(item.path));
|
||||
}
|
||||
})
|
||||
;
|
||||
await events.once(walker, "end");
|
||||
|
||||
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
|
||||
let filePath = path.join(args.sourceDir, ...pathParts);
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
throw new Error(`Additional file ${filePath} does not exist`);
|
||||
}
|
||||
zip.file(path.posix.join(modName, fileName), fs.createReadStream(filePath));
|
||||
}
|
||||
delete info.additional_files;
|
||||
|
||||
zip.file(path.posix.join(modName, "info.json"), JSON.stringify(info, null, 4));
|
||||
|
||||
let modPath = path.join(args.outputDir, `${modName}.zip`);
|
||||
console.log(`Writing ${modPath}`);
|
||||
let writeStream = zip.generateNodeStream().pipe(fs.createWriteStream(modPath));
|
||||
await finished(writeStream);
|
||||
|
||||
} else {
|
||||
let modDir = path.join(args.outputDir, modName);
|
||||
if (await fs.exists(modDir)) {
|
||||
console.log(`Removing existing build ${modDir}`);
|
||||
await fs.remove(modDir);
|
||||
}
|
||||
console.log(`Building ${modDir}`);
|
||||
await fs.copy(args.sourceDir, modDir);
|
||||
for (let [fileName, pathParts] of Object.entries(info.additional_files) || []) {
|
||||
let filePath = path.join(...pathParts);
|
||||
await fs.copy(filePath, path.join(modDir, fileName));
|
||||
}
|
||||
delete info.additional_files;
|
||||
|
||||
await fs.writeFile(path.join(modDir, "info.json"), JSON.stringify(info, null, 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function build(args) {
|
||||
let info = JSON.parse(await fs.readFile(path.join(args.sourceDir, "info.json")));
|
||||
|
||||
if (args.factorioVersion) {
|
||||
info.factorio_version = args.factorioVersion;
|
||||
}
|
||||
|
||||
if (args.dependencies) {
|
||||
info.dependencies = args.dependencies;
|
||||
}
|
||||
|
||||
if (args.clean) {
|
||||
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
|
||||
for (let entry of await fs.readdir(args.outputDir)) {
|
||||
let match = splitter.exec(entry);
|
||||
if (match) {
|
||||
let name = match[1];
|
||||
if (name === info.name) {
|
||||
let modPath = path.join(args.outputDir, entry);
|
||||
console.log(`Removing ${modPath}`);
|
||||
await fs.remove(modPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (info.variants) {
|
||||
for (let variantOverrides of info.variants) {
|
||||
let variantInfo = {
|
||||
...info,
|
||||
...variantOverrides,
|
||||
};
|
||||
delete variantInfo.variants;
|
||||
await buildMod(args, variantInfo);
|
||||
}
|
||||
} else {
|
||||
await buildMod(args, info);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function main() {
|
||||
const args = yargs
|
||||
.scriptName("build")
|
||||
.options({
|
||||
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
|
||||
"build": { describe: "Build mod(s)", type: "boolean", default: true },
|
||||
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
|
||||
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
|
||||
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
|
||||
"factorio-version": { describe: "Override factorio_version", type: "string" },
|
||||
})
|
||||
.strict()
|
||||
.argv
|
||||
;
|
||||
|
||||
await build(args);
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
main().catch(err => { console.log(err); });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
build,
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Command library
|
||||
*
|
||||
* Data types and utilites for commands to clusterioctl's CLI.
|
||||
*
|
||||
* @author Hornwitser
|
||||
* @module lib/command
|
||||
*/
|
||||
"use strict";
|
||||
const libLink = require("./link");
|
||||
const libErrors = require("./errors");
|
||||
|
||||
/**
|
||||
* Represents a command that can be runned by clusterioctl
|
||||
* @static
|
||||
*/
|
||||
class Command {
|
||||
/**
|
||||
* Define an executable command.
|
||||
*
|
||||
* @param {Object} cmd - Command definiton.
|
||||
* @param {Array} cmd.definiton -
|
||||
* Arguments to pass to yargs .command method to define this
|
||||
* command.
|
||||
* @param {function(args, control)} handler -
|
||||
* Async function invoked when the command is executed. Is given
|
||||
* the parsed args and a reference to the {@link
|
||||
* module:ctl/ctl.Control} instance.
|
||||
*/
|
||||
constructor({ definition, handler }) {
|
||||
if (definition[0] instanceof Array) {
|
||||
this.name = definition[0][0].split(" ")[0];
|
||||
this.alias = definition[0].slice(1);
|
||||
} else {
|
||||
this.name = definition[0].split(" ")[0];
|
||||
this.alias = [];
|
||||
}
|
||||
this._definition = definition;
|
||||
this._handler = handler;
|
||||
}
|
||||
|
||||
register(yargs) {
|
||||
yargs.command(...this._definition);
|
||||
}
|
||||
|
||||
async run(args, control) {
|
||||
await this._handler(args, control);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A node in the command tree that can hold commands and sub trees
|
||||
*
|
||||
* Container which can contain {@link module:lib/command.Command}s and other
|
||||
* CommandTrees. This is used by clusterioctl to hold the full tree of
|
||||
* available commands, you may extend this tree by using a control plugin,
|
||||
* see {@link module:lib/plugin.BaseControlPlugin#addCommands}
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
class CommandTree {
|
||||
/**
|
||||
* Define a command containing sub commands.
|
||||
*
|
||||
* @param {Object} cmd - Command definition
|
||||
* @param {string} cmd.name - Name of the command.
|
||||
* @param {Array<string>} cmd.alias - Aliases for this tree.
|
||||
* @param {description} cmd.description -
|
||||
* Descripton to provide for this command tree node.
|
||||
*/
|
||||
constructor({ name, alias, description }) {
|
||||
if (typeof name !== "string") {
|
||||
throw new Error("name must be a string");
|
||||
}
|
||||
|
||||
this.name = name;
|
||||
this.alias = alias || [];
|
||||
this.description = description;
|
||||
this.subCommands = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a command or command tree to this tree
|
||||
*
|
||||
* @param {module:lib/command.Command|module:lib/command.CommandTree} command -
|
||||
* The command to add to this command tree.
|
||||
*/
|
||||
add(command) {
|
||||
if (this.subCommands.has(command.name)) {
|
||||
throw new Error(`Command ${command.name} already exists`);
|
||||
}
|
||||
this.subCommands.set(command.name, command);
|
||||
for (let alias of command.alias) {
|
||||
if (this.subCommands.has(alias)) {
|
||||
throw new Error(`Alias ${alias} for command ${command.name} already exists`);
|
||||
}
|
||||
this.subCommands.set(alias, command);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a command or command tree from this tree
|
||||
*
|
||||
* @param {string} name -
|
||||
* The name of the command or command tree to retrieve.
|
||||
* @return {?module:lib/command.Command|module:lib/command.CommandTree}
|
||||
* The command to add to this command tree.
|
||||
*/
|
||||
get(name) {
|
||||
return this.subCommands.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the registered commands into the given yargs parser
|
||||
*
|
||||
* Traverses the the tree of sub-commands recursively and adds all of
|
||||
* them to the given yargs parser.
|
||||
*
|
||||
* @param {Object} yargs - yargs parser to register with.
|
||||
*/
|
||||
register(yargs) {
|
||||
yargs.command([this.name].concat(this.alias), this.description, (yargs) => {
|
||||
for (let [name, command] of this.subCommands) {
|
||||
// Check if the entry is not an alias.
|
||||
if (name === command.name) {
|
||||
command.register(yargs);
|
||||
}
|
||||
}
|
||||
}, () => {
|
||||
yargs.showHelp();
|
||||
yargs.exit();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a string into a slave ID
|
||||
*
|
||||
* Resolves a string with either an slave name or an id into an integer with
|
||||
* the slave ID.
|
||||
*
|
||||
* @param {module:lib/link.Link} client - link to master server to query slave on.
|
||||
* @param {string} slaveName - string with name or id of slave.
|
||||
* @returns {number} slave ID.
|
||||
* @static
|
||||
*/
|
||||
async function resolveSlave(client, slaveName) {
|
||||
let slaveId;
|
||||
if (/^-?\d+$/.test(slaveName)) {
|
||||
slaveId = parseInt(slaveName, 10);
|
||||
} else {
|
||||
let response = await libLink.messages.listSlaves.send(client);
|
||||
for (let slave of response.list) {
|
||||
if (slave.name === slaveName) {
|
||||
slaveId = slave.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (slaveId === undefined) {
|
||||
throw new libErrors.CommandError(`No slave named ${slaveName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return slaveId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a string to an instance ID
|
||||
*
|
||||
* Resolves a string with either an instance name or an id into an integer
|
||||
* with the instance ID.
|
||||
*
|
||||
* @param {module:lib/link.Link} client - link to master server to query instance on.
|
||||
* @param {string} instanceName - string with name or id of instance.
|
||||
* @returns {number} instance ID.
|
||||
* @static
|
||||
*/
|
||||
async function resolveInstance(client, instanceName) {
|
||||
let instanceId;
|
||||
if (/^-?\d+$/.test(instanceName)) {
|
||||
instanceId = parseInt(instanceName, 10);
|
||||
} else {
|
||||
let response = await libLink.messages.listInstances.send(client);
|
||||
for (let instance of response.list) {
|
||||
if (instance.name === instanceName) {
|
||||
instanceId = instance.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceId === undefined) {
|
||||
throw new libErrors.CommandError(`No instance named ${instanceName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve role object from string
|
||||
*
|
||||
* Resolves a string with either a role name or an id into an object
|
||||
* representing the role.
|
||||
*
|
||||
* @param {module:lib/link.Link} client - link to master server to query role on.
|
||||
* @param {string} roleName - string with name or id of role.
|
||||
* @returns {Object} Role info.
|
||||
* @static
|
||||
*/
|
||||
async function retrieveRole(client, roleName) {
|
||||
let response = await libLink.messages.listRoles.send(client);
|
||||
|
||||
let resolvedRole;
|
||||
if (/^-?\d+$/.test(roleName)) {
|
||||
let roleId = parseInt(roleName, 10);
|
||||
for (let role of response.list) {
|
||||
if (role.id === roleId) {
|
||||
resolvedRole = role;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
for (let role of response.list) {
|
||||
if (role.name === roleName) {
|
||||
resolvedRole = role;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedRole) {
|
||||
throw new libErrors.CommandError(`No role named ${roleName}`);
|
||||
}
|
||||
|
||||
return resolvedRole;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Command,
|
||||
CommandTree,
|
||||
|
||||
resolveSlave,
|
||||
resolveInstance,
|
||||
retrieveRole,
|
||||
};
|
||||
@@ -1,17 +1,31 @@
|
||||
/**
|
||||
* @file Configuration classes
|
||||
* @author Hornwitser
|
||||
*/
|
||||
// Configuration classes
|
||||
"use strict";
|
||||
|
||||
const { basicType } = require("lib/helpers");
|
||||
const events = require("events");
|
||||
|
||||
const isDeepStrictEqual = require("../is_deep_strict_equal");
|
||||
const { basicType } = require("../helpers");
|
||||
|
||||
|
||||
/**
|
||||
* Invalid Access exception
|
||||
*
|
||||
* Exception class for when an access attempt is made to a field that
|
||||
* does not permitt this access is done.
|
||||
*
|
||||
* @extends Error
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class InvalidAccess extends Error { };
|
||||
|
||||
/**
|
||||
* Invalid Value exception
|
||||
*
|
||||
* Exception class for when invalid values are attempted to be set on a
|
||||
* field.
|
||||
*
|
||||
* @extends Error
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class InvalidValue extends Error { };
|
||||
|
||||
@@ -20,23 +34,31 @@ class InvalidValue extends Error { };
|
||||
*
|
||||
* Exception class for when a group or field that does not exist is
|
||||
* attempted to be accessed.
|
||||
*
|
||||
* @extends Error
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class InvalidField extends Error { };
|
||||
|
||||
/**
|
||||
* Split the given string on the first instance of separator
|
||||
*
|
||||
* Splits @p string on the first instance of @p separator and returns an
|
||||
* Splits `string` on the first instance of `separator` and returns an
|
||||
* array consisting of the string up to the separator and the string
|
||||
* after the separator. Returns an array with the string and an empty
|
||||
* string if the separator is not present.
|
||||
*
|
||||
* @param {string} separator - Separator to split string by.
|
||||
* @param {string} string - String to split
|
||||
* @returns {Array} string split on separator.
|
||||
* @memberof module:lib/config
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
function splitOn(separator, string) {
|
||||
let index = string.indexOf(separator);
|
||||
if (index === -1) {
|
||||
return [string, ''];
|
||||
return [string, ""];
|
||||
}
|
||||
return [string.slice(0, index), string.slice(index + separator.length)];
|
||||
}
|
||||
@@ -46,13 +68,36 @@ function splitOn(separator, string) {
|
||||
*
|
||||
* Represents a collection of ConfigGroup instances that can be
|
||||
* managed as one.
|
||||
*
|
||||
* This is an EventEmitter with the following events:
|
||||
* - fieldChanged -
|
||||
* invoked after a field has been updated in one of the config groups
|
||||
* belonging to this config. Takes three parameters: group, field,
|
||||
* prev.
|
||||
*
|
||||
* @extends events.EventEmitter
|
||||
*
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class Config {
|
||||
constructor() {
|
||||
class Config extends events.EventEmitter {
|
||||
/**
|
||||
* Create a new instance of the given config
|
||||
*
|
||||
* @param {string} location -
|
||||
* Location to evaluate access for this instance from
|
||||
*/
|
||||
constructor(location) {
|
||||
super();
|
||||
|
||||
if (!this.constructor._finalized) {
|
||||
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`)
|
||||
throw new Error(`Cannot instantiate incomplete Config class ${this.constructor.name}`);
|
||||
}
|
||||
|
||||
if (typeof location !== "string") {
|
||||
throw new Error("location must be a string");
|
||||
}
|
||||
|
||||
this.location = location;
|
||||
this._groups = new Map();
|
||||
this._unknownGroups = new Map();
|
||||
}
|
||||
@@ -60,7 +105,7 @@ class Config {
|
||||
async init() {
|
||||
for (let [name, GroupClass] of this.constructor._groups) {
|
||||
if (!this._groups.has(name)) {
|
||||
let group = new GroupClass();
|
||||
let group = new GroupClass(this);
|
||||
await group.init();
|
||||
this._groups.set(name, group);
|
||||
}
|
||||
@@ -78,7 +123,13 @@ class Config {
|
||||
}
|
||||
}
|
||||
|
||||
async load(serializedConfig) {
|
||||
/**
|
||||
* Load config from a serialized object
|
||||
*
|
||||
* @param {object} serializedConfig - Serialized config to load.
|
||||
* @param {string=} location - Location used for access control.
|
||||
*/
|
||||
async load(serializedConfig, location = this.location) {
|
||||
this._validate(serializedConfig);
|
||||
for (let serializedGroup of serializedConfig.groups) {
|
||||
let GroupClass = this.constructor._groups.get(serializedGroup.name);
|
||||
@@ -87,9 +138,9 @@ class Config {
|
||||
continue;
|
||||
}
|
||||
|
||||
let group = new GroupClass();
|
||||
await group.load(serializedGroup);
|
||||
this._groups.set(GroupClass.groupName, group);
|
||||
let group = new GroupClass(this);
|
||||
await group.load(serializedGroup, location);
|
||||
this._groups.set(group.name, group);
|
||||
}
|
||||
|
||||
await this.init();
|
||||
@@ -97,11 +148,14 @@ class Config {
|
||||
|
||||
/**
|
||||
* Serialize the config to a plain JavaScript object
|
||||
*
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @returns {Object} JSON serializable representation of the config.
|
||||
*/
|
||||
serialize() {
|
||||
serialize(location = this.location) {
|
||||
let groups = [...this._unknownGroups.values()];
|
||||
for (let group of this._groups.values()) {
|
||||
groups.push(group.serialize());
|
||||
groups.push(group.serialize(location));
|
||||
}
|
||||
|
||||
return { groups };
|
||||
@@ -112,13 +166,17 @@ class Config {
|
||||
*
|
||||
* Updates all groups in the config with groups in the serialized
|
||||
* config passed.
|
||||
*
|
||||
* @param {Object} serializedConfig - Output from .serialize().
|
||||
* @param {boolean} notify - Invoke fieldChanged events if true.
|
||||
* @param {string=} location - Location used for access control.
|
||||
*/
|
||||
update(serializedConfig) {
|
||||
update(serializedConfig, notify, location = this.location) {
|
||||
this._validate(serializedConfig);
|
||||
for (let serializedGroup of serializedConfig.groups) {
|
||||
let group = this._groups.get(serializedGroup.name);
|
||||
if (group) {
|
||||
group.update(serializedGroup);
|
||||
group.update(serializedGroup, notify, location);
|
||||
} else {
|
||||
this._unknownGroups.set(serializedGroup.name, serializedGroup);
|
||||
}
|
||||
@@ -126,27 +184,63 @@ class Config {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a config field
|
||||
* Check if field can be accessed from the given location
|
||||
*
|
||||
* @param {string} name - Name of the field to check.
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @returns {boolean} true if field is accessible
|
||||
*/
|
||||
get(name) {
|
||||
let [group, field] = splitOn('.', name);
|
||||
return this.group(group).get(field);
|
||||
canAccess(name, location = this.location) {
|
||||
let [group, field] = splitOn(".", name);
|
||||
return this.group(group).canAccess(field, location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a config field
|
||||
*
|
||||
* @param {string} name - Name of field to get.
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @returns {*} the value for the field.
|
||||
*/
|
||||
get(name, location = this.location) {
|
||||
let [group, field] = splitOn(".", name);
|
||||
return this.group(group).get(field, location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value for a config field
|
||||
*
|
||||
* @param {string} name - Name of field to set.
|
||||
* @param {*} value - the value to set on field
|
||||
* @param {string=} location - Location used for access control.
|
||||
*/
|
||||
set(name, value) {
|
||||
let [group, field] = splitOn('.', name);
|
||||
return this.group(group).set(field, value);
|
||||
set(name, value, location = this.location) {
|
||||
let [group, field] = splitOn(".", name);
|
||||
this.group(group).set(field, value, location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set property of an object value for a config field
|
||||
*
|
||||
* @param {string} name - Name of field to set property on.
|
||||
* @param {string} prop - Name of property to set on field.
|
||||
* @param {*} value - the value to set the property to.
|
||||
* @param {string=} location - Location used for access control.
|
||||
*/
|
||||
setProp(name, prop, value, location = this.location) {
|
||||
let [group, field] = splitOn(".", name);
|
||||
this.group(group).setProp(field, prop, value, location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the config group instance with the given name
|
||||
*
|
||||
* @param {string} name - Name of group to get.
|
||||
* @returns {module:lib/config.ConfigGroup} config group.
|
||||
*/
|
||||
group(name) {
|
||||
if (!this._initialized) {
|
||||
throw new Error(`${this.constructor.name} instance is uninitialized`)
|
||||
throw new Error(`${this.constructor.name} instance is uninitialized`);
|
||||
}
|
||||
let group = this._groups.get(name);
|
||||
if (!group) {
|
||||
@@ -192,6 +286,8 @@ class Config {
|
||||
*
|
||||
* Adds the ConfigGroup to this config as a group that will get
|
||||
* initialized, loaded and serialized with this config class.
|
||||
*
|
||||
* @param {module:lib/config.ConfigGroup} group - class to register.
|
||||
*/
|
||||
static registerGroup(group) {
|
||||
this._initSubclass();
|
||||
@@ -215,6 +311,13 @@ class Config {
|
||||
|
||||
/**
|
||||
* Collection of related config entries
|
||||
*
|
||||
* Type checks and stores a collection of related config entries. Attempt
|
||||
* to set invalid values for fields will result in an error thrown while
|
||||
* loading a config with invalid values will result in those being replaced
|
||||
* by the innitial value for those fields.
|
||||
*
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class ConfigGroup {
|
||||
/**
|
||||
@@ -222,14 +325,42 @@ class ConfigGroup {
|
||||
*
|
||||
* After the creation of the new config group you have to call
|
||||
* either .init() or .load() on it in order to fully initialize it.
|
||||
*
|
||||
* @param {module:lib/config.Config} config -
|
||||
* Parent config for this group instance.
|
||||
*/
|
||||
constructor(serializedGroup) {
|
||||
constructor(config) {
|
||||
if (!this.constructor._finalized) {
|
||||
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`)
|
||||
throw new Error(`Cannot instantiate incomplete ConfigGroup class ${this.constructor.name}`);
|
||||
}
|
||||
|
||||
this._fields = new Map();
|
||||
this._unknownFields = new Map();
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check access for a field
|
||||
*
|
||||
* @param {object} def - definition for for the field to check.
|
||||
* @param {string} remote - location of remote access to check for.
|
||||
* @param {boolean} error - throw on failure to pass check.
|
||||
* @throws {module:lib/config.InvalidAccess}
|
||||
* if access is not granted and error is true.
|
||||
* @returns {boolean} true if access is granted
|
||||
* @private
|
||||
*/
|
||||
_checkAccess(def, remote, error) {
|
||||
for (let loc of [remote, this.config.location]) {
|
||||
if (!def.access.includes(loc)) {
|
||||
if (error) {
|
||||
throw new InvalidAccess(`Field '${def.name}' is not accessible from ${loc}`);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,10 +374,13 @@ class ConfigGroup {
|
||||
if (this._fields.has(name)) {
|
||||
continue;
|
||||
}
|
||||
if (!def.access.includes(this.config.location)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (typeof def.initial_value === "function") {
|
||||
value = await def.initial_value()
|
||||
value = await def.initial_value();
|
||||
|
||||
} else if (def.initial_value !== undefined) {
|
||||
value = def.initial_value;
|
||||
@@ -262,19 +396,53 @@ class ConfigGroup {
|
||||
* Loads all the values from the passed serialized version of the
|
||||
* group and then initializes any possible missing fields from it to
|
||||
* their default values.
|
||||
*
|
||||
* @param {Object} serializedGroup -
|
||||
* Result from a previous call to .serialize().
|
||||
* @param {string=} location - Location used for access control.
|
||||
*/
|
||||
async load(serializedGroup) {
|
||||
this.update(serializedGroup);
|
||||
async load(serializedGroup, location = this.config.location) {
|
||||
this.update(serializedGroup, false, location);
|
||||
await this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value for field
|
||||
* Name of the group
|
||||
*
|
||||
* Shortcut for `group.constructor.groupName`
|
||||
*/
|
||||
get(name) {
|
||||
if (!this.constructor._definitions.has(name)) {
|
||||
get name() {
|
||||
return this.constructor.groupName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if field can be accessed from the given location
|
||||
*
|
||||
* @param {string} name - Name of field to check.
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @returns {boolean} true if field is accessible
|
||||
*/
|
||||
canAccess(name, location = this.config.location) {
|
||||
let def = this.constructor._definitions.get(name);
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
return this._checkAccess(def, location, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value for field
|
||||
*
|
||||
* @param {string} name - Name of field to get.
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @returns {*} Value stored for the field.
|
||||
*/
|
||||
get(name, location = this.config.location) {
|
||||
let def = this.constructor._definitions.get(name);
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
this._checkAccess(def, location, true);
|
||||
|
||||
return this._fields.get(name);
|
||||
}
|
||||
@@ -285,13 +453,18 @@ class ConfigGroup {
|
||||
* The field must be defined for the config group and the value is
|
||||
* type checked against the field definition.
|
||||
*
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
* @param {string} name - Name of field to set.
|
||||
* @param {*} value - Value to set for field.
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @throws {module:lib/config.InvalidField} if field is not defined.
|
||||
* @throws {module:lib/config.InvalidValue} if value is not allowed for the field.
|
||||
*/
|
||||
set(name, value) {
|
||||
set(name, value, location = this.config.location) {
|
||||
let def = this.constructor._definitions.get(name);
|
||||
if (!def) {
|
||||
throw new Error(`No field named '${name}'`);
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
this._checkAccess(def, location, true);
|
||||
|
||||
// Empty strings are treates as null
|
||||
if (value === "") {
|
||||
@@ -299,13 +472,16 @@ class ConfigGroup {
|
||||
}
|
||||
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(', ')}], not ${value}`);
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
}
|
||||
|
||||
if (basicType(value) === "string") {
|
||||
if (def.type === "boolean") {
|
||||
if (value === "true") { value = true; }
|
||||
else if (value === "false") { value = false; }
|
||||
if (value === "true") {
|
||||
value = true;
|
||||
} else if (value === "false") {
|
||||
value = false;
|
||||
}
|
||||
|
||||
} else if (def.type === "number") {
|
||||
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
|
||||
@@ -313,27 +489,67 @@ class ConfigGroup {
|
||||
value = Number.parseFloat(value);
|
||||
}
|
||||
|
||||
} else if (def.type === "object" ) {
|
||||
} else if (def.type === "object") {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err) {
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`)
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
if (!def.optional) {
|
||||
throw new InvalidValue(`Field ${name} cannot be null`)
|
||||
throw new InvalidValue(`Field ${name} cannot be null`);
|
||||
}
|
||||
|
||||
} else {
|
||||
if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
} else if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
let prev = this._fields.get(name);
|
||||
this._fields.set(name, value);
|
||||
if (this.config && !isDeepStrictEqual(value, prev)) {
|
||||
this.config.emit("fieldChanged", this, name, prev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set property of object field
|
||||
*
|
||||
* Update value of stored object field by setting the specified property
|
||||
* on it. The field must be defined as an object for the config group.
|
||||
*
|
||||
* @param {string} name - Name of field to set property on.
|
||||
* @param {string} prop - Name of property to set on field.
|
||||
* @param {*} value - the value to set the property to.
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @throws {module:lib/config.InvalidField} if field is not defined.
|
||||
* @throws {module:lib/config.InvalidValue} if field is not an object.
|
||||
*/
|
||||
setProp(name, prop, value, location = this.config.location) {
|
||||
let def = this.constructor._definitions.get(name);
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
}
|
||||
this._checkAccess(def, location, true);
|
||||
|
||||
if (def.type !== "object") {
|
||||
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
||||
}
|
||||
|
||||
let prev = this._fields.get(name);
|
||||
let updated = {...prev || {}};
|
||||
|
||||
if (value !== undefined) {
|
||||
updated[prop] = value;
|
||||
} else {
|
||||
delete updated[prop];
|
||||
}
|
||||
this._fields.set(name, updated);
|
||||
if (this.config && !isDeepStrictEqual(updated, prev)) {
|
||||
this.config.emit("fieldChanged", this, name, prev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,14 +557,19 @@ class ConfigGroup {
|
||||
*
|
||||
* Overwrites the values of all the fields in this config group that
|
||||
* has a valid value in the passed serialized group with that value.
|
||||
*
|
||||
* @param {Object} serializedGroup -
|
||||
* Result from a previous call to .serialize().
|
||||
* @param {boolean} notify - Invoke fieldChanged events if true.
|
||||
* @param {string=} location - Location used for access control.
|
||||
*/
|
||||
update(serializedGroup) {
|
||||
update(serializedGroup, notify, location = this.config.location) {
|
||||
if (basicType(serializedGroup) !== "object") {
|
||||
throw new Error(`Expected object, not ${basicType(serializedGroup)} for ConfigGroup`);
|
||||
}
|
||||
|
||||
if (serializedGroup.name !== this.constructor.groupName) {
|
||||
throw new Error(`Expected group name ${this.constructor.groupName}, not ${serializedGroup.name}`);
|
||||
if (serializedGroup.name !== this.name) {
|
||||
throw new Error(`Expected group name ${this.name}, not ${serializedGroup.name}`);
|
||||
}
|
||||
|
||||
if (basicType(serializedGroup.fields) !== "object") {
|
||||
@@ -362,30 +583,45 @@ class ConfigGroup {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this._checkAccess(def, location, false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.optional) {
|
||||
if (value !== null && basicType(value) !== def.type) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (basicType(value) !== def.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
} else if (basicType(value) !== def.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let prev = this._fields.get(name);
|
||||
this._fields.set(name, value);
|
||||
if (notify && this.config && !isDeepStrictEqual(value, prev)) {
|
||||
this.config.emit("fieldChanged", this, name, prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the config to a plain JavaScript object
|
||||
*
|
||||
* @param {string=} location - Location used for access control.
|
||||
* @returns {Object} JSON serializable representation of the group.
|
||||
*/
|
||||
serialize() {
|
||||
serialize(location = this.config.location) {
|
||||
let fields = {};
|
||||
for (let [name, value] of this._fields) {
|
||||
let def = this.constructor._definitions.get(name);
|
||||
if (!this._checkAccess(def, location, false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fields[name] = value;
|
||||
}
|
||||
|
||||
@@ -394,7 +630,7 @@ class ConfigGroup {
|
||||
}
|
||||
|
||||
return {
|
||||
name: this.constructor.groupName,
|
||||
name: this.name,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
@@ -424,34 +660,37 @@ class ConfigGroup {
|
||||
/**
|
||||
* Define a new configuration field
|
||||
*
|
||||
* The following properties are supported:
|
||||
* - type:
|
||||
* @param {Object} item - Field definition.
|
||||
* @param {Array<string>=} item.access -
|
||||
* Array of strings declaring where this config entry can be read
|
||||
* and modified from.
|
||||
* @param {string} item.type -
|
||||
* string declaring the type of the config value, supports
|
||||
* boolean, string, number and object.
|
||||
* - name:
|
||||
* @param {string} item.name -
|
||||
* Name of the configuration field. Should follow the
|
||||
* lower_case_underscore style.
|
||||
* - title:
|
||||
* @param {string} item.title -
|
||||
* Text used to identify the config field in user interfaces.
|
||||
* - description:
|
||||
* @param {string} item.description -
|
||||
* Text used to describe the config field in user interfaces.
|
||||
* - enum (optional):
|
||||
* @param {Array=} item.enum -
|
||||
* Array of all values that are acceptible. Useful for making
|
||||
* enumerated values.
|
||||
* - optional (optional):
|
||||
* @param {boolean=} item.optional -
|
||||
* True if this field can be set to null. Defaults to false.
|
||||
* - initial_value (optional):
|
||||
* @param {*=} item.initial_value -
|
||||
* Value this config field should take in a newly initialized
|
||||
* config. This can also be an async function returning the
|
||||
* value to use.
|
||||
*/
|
||||
static define(item) {
|
||||
if (this._finalized) {
|
||||
throw new Error(`Cannot define field for ConfigGroup class ${this.name} after it has been finalized`)
|
||||
throw new Error(`Cannot define field for ConfigGroup class ${this.name} after it has been finalized`);
|
||||
}
|
||||
|
||||
if (basicType(this.groupName) !== "string") {
|
||||
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`)
|
||||
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`);
|
||||
}
|
||||
|
||||
this._initSubclass();
|
||||
@@ -463,7 +702,7 @@ class ConfigGroup {
|
||||
if (!itemKeys.includes(prop)) {
|
||||
throw new Error(`${prop} is required when defining an field`);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Validate properties
|
||||
let validTypes = ["boolean", "string", "number", "object"];
|
||||
@@ -477,6 +716,12 @@ class ConfigGroup {
|
||||
}
|
||||
break;
|
||||
|
||||
case "access":
|
||||
if (valueType !== "array") {
|
||||
throw new Error("access must be an array");
|
||||
}
|
||||
break;
|
||||
|
||||
case "enum":
|
||||
if (valueType !== "array") {
|
||||
throw new Error("enum must be an array");
|
||||
@@ -522,10 +767,16 @@ class ConfigGroup {
|
||||
throw new Error(`Config field ${item.name} has already been defined`);
|
||||
}
|
||||
|
||||
let computed = Object.assign({
|
||||
let computed = {
|
||||
access: this.defaultAccess,
|
||||
optional: false,
|
||||
fullName: `${this.groupName}.${item.name}`,
|
||||
}, item)
|
||||
...item,
|
||||
};
|
||||
|
||||
if (!computed.access) {
|
||||
throw new Error(`access not set and no defaultAccess defined for ${this.name}`);
|
||||
}
|
||||
|
||||
if (!computed.optional && computed.initial_value === undefined) {
|
||||
throw new Error(`Non-optional field ${item.name} needs an initial_value`);
|
||||
@@ -539,7 +790,7 @@ class ConfigGroup {
|
||||
*/
|
||||
static finalize() {
|
||||
if (basicType(this.groupName) !== "string") {
|
||||
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`)
|
||||
throw new Error(`Expected ConfigGroup class ${this.name} to have the groupName property set to a string`);
|
||||
}
|
||||
|
||||
this._initSubclass();
|
||||
@@ -553,14 +804,17 @@ class ConfigGroup {
|
||||
* Grouping that includes per plugin fields clusterio uses to manage
|
||||
* plugins. This is currently only the enabled field. It is otherwise
|
||||
* identical to the ConfigGroup class.
|
||||
*
|
||||
* @extends module:lib/config.ConfigGroup
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class PluginConfigGroup extends ConfigGroup {
|
||||
static _initSubclass() {
|
||||
if (!this._initialized) {
|
||||
super._initSubclass();
|
||||
this.define({
|
||||
name: "enabled",
|
||||
description: "Enable plugin",
|
||||
name: "load_plugin",
|
||||
title: "Load Plugin",
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
@@ -570,10 +824,11 @@ class PluginConfigGroup extends ConfigGroup {
|
||||
|
||||
|
||||
module.exports = {
|
||||
InvalidAccess,
|
||||
InvalidField,
|
||||
InvalidValue,
|
||||
|
||||
Config,
|
||||
ConfigGroup,
|
||||
PluginConfigGroup,
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,468 @@
|
||||
// Core definitions for the configuration system
|
||||
"use strict";
|
||||
|
||||
const classes = require("./classes");
|
||||
|
||||
|
||||
/**
|
||||
* Master config group for {@link module:lib/config.MasterConfig}
|
||||
* @extends module:lib/config.ConfigGroup
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class MasterGroup extends classes.ConfigGroup { }
|
||||
MasterGroup.defaultAccess = ["master", "slave", "control"];
|
||||
MasterGroup.groupName = "master";
|
||||
MasterGroup.define({
|
||||
name: "name",
|
||||
title: "Name",
|
||||
description: "Name of the cluster.",
|
||||
type: "string",
|
||||
initial_value: "Your Cluster",
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "database_directory",
|
||||
title: "Database directory",
|
||||
description: "Directory where item and configuration data is stored.",
|
||||
type: "string",
|
||||
initial_value: "database",
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "http_port",
|
||||
title: "HTTP Port",
|
||||
description: "Port to listen for HTTP connections on, set to null to not listen for HTTP connections.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
initial_value: 8080,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "https_port",
|
||||
title: "HTTPS Port",
|
||||
description: "Port to listen for HTTPS connection on, set to null to not listen for HTTPS connections.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "bind_address",
|
||||
title: "Bind Address",
|
||||
description: "IP address to bind the HTTP and HTTPS ports on.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "external_address",
|
||||
title: "External Address",
|
||||
description: "Public facing address the master server is hosted on.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "tls_certificate",
|
||||
title: "TLS Certificate",
|
||||
description: "Path to the certificate to use for HTTPS.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "tls_private_key",
|
||||
title: "TLS Private Key",
|
||||
description: "Path to the private key to use for HTTPS.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
MasterGroup.define({
|
||||
access: ["master"],
|
||||
name: "auth_secret",
|
||||
title: "Master Authentication Secret",
|
||||
description:
|
||||
"Secret used to generate and verify authentication tokens. " +
|
||||
"Should be a long string of random letters and numbers. " +
|
||||
"Do not share this.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "heartbeat_interval",
|
||||
title: "Heartbeat Interval",
|
||||
description: "Interval heartbeats are sent out on WebSocket connections.",
|
||||
type: "number",
|
||||
initial_value: 15,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "session_timeout",
|
||||
title: "Session Timeout",
|
||||
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
|
||||
type: "number",
|
||||
initial_value: 60,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "metrics_timeout",
|
||||
title: "Metrics Timeout",
|
||||
description: "Timeout in seconds for metrics gathering from slaves.",
|
||||
type: "number",
|
||||
initial_value: 8,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "proxy_stream_timeout",
|
||||
title: "Proxy Stream Timeout",
|
||||
description: "Timeout in seconds for proxy streams to start flowing.",
|
||||
type: "number",
|
||||
initial_value: 15,
|
||||
});
|
||||
MasterGroup.define({
|
||||
name: "default_role_id",
|
||||
title: "Default role",
|
||||
description: "ID of role assigned by default to new users.",
|
||||
type: "number",
|
||||
optional: true,
|
||||
initial_value: 1,
|
||||
});
|
||||
MasterGroup.finalize();
|
||||
|
||||
/**
|
||||
* Master Config class
|
||||
* @extends module:lib/config.Config
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class MasterConfig extends classes.Config { }
|
||||
MasterConfig.registerGroup(MasterGroup);
|
||||
|
||||
|
||||
/**
|
||||
* Slave config group for {@link module:lib/config.SlaveConfig}
|
||||
* @extends module:lib/config.ConfigGroup
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class SlaveGroup extends classes.ConfigGroup {}
|
||||
SlaveGroup.defaultAccess = ["master", "slave", "control"];
|
||||
SlaveGroup.groupName = "slave";
|
||||
SlaveGroup.define({
|
||||
name: "name",
|
||||
description: "Name of the slave",
|
||||
type: "string",
|
||||
initial_value: "New Slave",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "id",
|
||||
description: "ID of the slave",
|
||||
type: "number",
|
||||
initial_value: () => Math.random() * 2**31 | 0,
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "factorio_directory",
|
||||
description: "Path to directory to look for factorio installs",
|
||||
type: "string",
|
||||
initial_value: "factorio",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "instances_directory",
|
||||
description: "Path to directory to store instances in.",
|
||||
type: "string",
|
||||
initial_value: "instances",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "master_url",
|
||||
description: "URL to connect to the master server at",
|
||||
type: "string",
|
||||
initial_value: "http://localhost:8080/",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "master_token",
|
||||
description: "Token to authenticate to master server with.",
|
||||
type: "string",
|
||||
initial_value: "enter token here",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "tls_ca",
|
||||
description: "Path to Certificate Authority to validate TLS connection to master against.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "public_address",
|
||||
description: "Public facing address players should connect to in order to join instances on this slave",
|
||||
type: "string",
|
||||
initial_value: "localhost",
|
||||
});
|
||||
SlaveGroup.define({
|
||||
name: "max_reconnect_delay",
|
||||
title: "Max Reconnect Delay",
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initial_value: 60,
|
||||
});
|
||||
SlaveGroup.finalize();
|
||||
|
||||
/**
|
||||
* Slave Config class
|
||||
* @extends module:lib/config.Config
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class SlaveConfig extends classes.Config { }
|
||||
SlaveConfig.registerGroup(SlaveGroup);
|
||||
|
||||
|
||||
/**
|
||||
* Instance config group for {@link module:lib/config.InstanceConfig}
|
||||
* @extends module:lib/config.ConfigGroup
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class InstanceGroup extends classes.ConfigGroup { }
|
||||
InstanceGroup.defaultAccess = ["master", "slave", "control"];
|
||||
InstanceGroup.groupName = "instance";
|
||||
InstanceGroup.define({
|
||||
name: "name",
|
||||
type: "string",
|
||||
initial_value: "New Instance",
|
||||
});
|
||||
InstanceGroup.define({
|
||||
name: "id",
|
||||
description: "ID of the slave",
|
||||
type: "number",
|
||||
initial_value: () => Math.random() * 2**31 | 0,
|
||||
});
|
||||
InstanceGroup.define({
|
||||
name: "assigned_slave",
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
InstanceGroup.define({
|
||||
name: "auto_start",
|
||||
description: "Automatically start this instance when the slave hosting it is started up",
|
||||
type: "boolean",
|
||||
initial_value: false,
|
||||
});
|
||||
InstanceGroup.finalize();
|
||||
|
||||
/**
|
||||
* Factorio config group for {@link module:lib/config.InstanceConfig}
|
||||
* @extends module:lib/config.ConfigGroup
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class FactorioGroup extends classes.ConfigGroup { }
|
||||
FactorioGroup.defaultAccess = ["master", "slave", "control"];
|
||||
FactorioGroup.groupName = "factorio";
|
||||
FactorioGroup.define({
|
||||
name: "version",
|
||||
description: "Version of the game to run, use latest to run the latest installed version",
|
||||
type: "string",
|
||||
initial_value: "latest",
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "game_port",
|
||||
description: "UDP port to run game on, uses a random port if null",
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "rcon_port",
|
||||
description: "TCP port to run RCON on, uses a random port if null",
|
||||
type: "number",
|
||||
optional: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "rcon_password",
|
||||
description: "Password for RCON, randomly generated if null",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "player_online_autosave_slots",
|
||||
description:
|
||||
"Rename autosaves where players have been online since the previous autosave into a separate autosave " +
|
||||
"pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
|
||||
type: "number",
|
||||
initial_value: 5,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "enable_save_patching",
|
||||
description: "Patch saves with Lua code. Required for Clusterio integrations, lua modules, and most plugins.",
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "enable_whitelist",
|
||||
description: "Turn on whitelist for joining the server.",
|
||||
type: "boolean",
|
||||
initial_value: false,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "enable_authserver_bans",
|
||||
description: "Turn on Factorio.com based multiplayer bans.",
|
||||
type: "boolean",
|
||||
initial_value: false,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "settings",
|
||||
description: "Settings overridden in server-settings.json",
|
||||
type: "object",
|
||||
initial_value: {}, // See create instance handler in master.
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "verbose_logging",
|
||||
description: "Enable verbose logging on the Factorio server",
|
||||
type: "boolean",
|
||||
initial_value: false,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "strip_paths",
|
||||
description: "Strip down instance paths in the log",
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "sync_adminlist",
|
||||
description: "Synchronize adminlist with master server",
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "sync_whitelist",
|
||||
description: "Synchronize whitelist with master server",
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "sync_banlist",
|
||||
description: "Synchronize banlist with master server",
|
||||
type: "boolean",
|
||||
initial_value: true,
|
||||
});
|
||||
FactorioGroup.define({
|
||||
name: "max_concurrent_commands",
|
||||
description: "Maximum number of RCON commands trasmitted in parallel",
|
||||
type: "number",
|
||||
initial_value: 5,
|
||||
});
|
||||
FactorioGroup.finalize();
|
||||
|
||||
/**
|
||||
* Instance config class
|
||||
* @extends module:lib/config.Config
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class InstanceConfig extends classes.Config { }
|
||||
InstanceConfig.registerGroup(InstanceGroup);
|
||||
InstanceConfig.registerGroup(FactorioGroup);
|
||||
|
||||
|
||||
/**
|
||||
* Control config group for {@link module:lib/config.ControlConfig}
|
||||
* @extends module:lib/config.ConfigGroup
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class ControlGroup extends classes.ConfigGroup {}
|
||||
ControlGroup.defaultAccess = ["control"];
|
||||
ControlGroup.groupName = "control";
|
||||
ControlGroup.define({
|
||||
name: "master_url",
|
||||
description: "URL to connect to the master server at",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "master_token",
|
||||
description: "Token to authenticate to master server with.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "tls_ca",
|
||||
description: "Path to Certificate Authority to validate TLS connection to master against.",
|
||||
type: "string",
|
||||
optional: true,
|
||||
});
|
||||
ControlGroup.define({
|
||||
name: "max_reconnect_delay",
|
||||
title: "Max Reconnect Delay",
|
||||
description: "Maximum delay to wait before attempting to reconnect WebSocket",
|
||||
type: "number",
|
||||
initial_value: 60,
|
||||
});
|
||||
ControlGroup.finalize();
|
||||
|
||||
/**
|
||||
* Control config class
|
||||
* @extends module:lib/config.Config
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
class ControlConfig extends classes.Config { }
|
||||
ControlConfig.registerGroup(ControlGroup);
|
||||
|
||||
|
||||
function validateGroup(pluginInfo, groupName) {
|
||||
if (!(pluginInfo[groupName].prototype instanceof classes.PluginConfigGroup)) {
|
||||
throw new Error(
|
||||
`Expected ${groupName} for ${pluginInfo.name} to be a subclass of PluginConfigGroup`
|
||||
);
|
||||
}
|
||||
|
||||
if (pluginInfo[groupName].groupName !== pluginInfo.name) {
|
||||
throw new Error(
|
||||
`Expected ${groupName} for ${pluginInfo.name} to be named after the plugin`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the config groups for the provided plugin infos
|
||||
*
|
||||
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
function registerPluginConfigGroups(pluginInfos) {
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
if (pluginInfo.MasterConfigGroup) {
|
||||
validateGroup(pluginInfo, "MasterConfigGroup");
|
||||
MasterConfig.registerGroup(pluginInfo.MasterConfigGroup);
|
||||
|
||||
} else {
|
||||
class MasterConfigGroup extends classes.PluginConfigGroup { }
|
||||
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
|
||||
MasterConfigGroup.groupName = pluginInfo.name;
|
||||
MasterConfigGroup.finalize();
|
||||
MasterConfig.registerGroup(MasterConfigGroup);
|
||||
}
|
||||
|
||||
if (pluginInfo.instanceEntrypoint) {
|
||||
if (pluginInfo.InstanceConfigGroup) {
|
||||
validateGroup(pluginInfo, "InstanceConfigGroup");
|
||||
InstanceConfig.registerGroup(pluginInfo.InstanceConfigGroup);
|
||||
|
||||
} else {
|
||||
class InstanceConfigGroup extends classes.PluginConfigGroup { }
|
||||
InstanceConfigGroup.defaultAccess = ["master", "slave", "control"];
|
||||
InstanceConfigGroup.groupName = pluginInfo.name;
|
||||
InstanceConfigGroup.finalize();
|
||||
InstanceConfig.registerGroup(InstanceConfigGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock configs from adding more groups and make them usable
|
||||
*
|
||||
* @memberof module:lib/config
|
||||
*/
|
||||
function finalizeConfigs() {
|
||||
MasterConfig.finalize();
|
||||
SlaveConfig.finalize();
|
||||
InstanceConfig.finalize();
|
||||
ControlConfig.finalize();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MasterGroup,
|
||||
SlaveGroup,
|
||||
InstanceGroup,
|
||||
FactorioGroup,
|
||||
ControlGroup,
|
||||
|
||||
MasterConfig,
|
||||
SlaveConfig,
|
||||
InstanceConfig,
|
||||
ControlConfig,
|
||||
|
||||
registerPluginConfigGroups,
|
||||
finalizeConfigs,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Configuration System
|
||||
* @author Hornwitser
|
||||
* @module lib/config
|
||||
*/
|
||||
"use strict";
|
||||
const classes = require("./classes");
|
||||
const definitions = require("./definitions");
|
||||
|
||||
module.exports = {
|
||||
...classes,
|
||||
...definitions,
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Implementation of master databases
|
||||
*
|
||||
* @module
|
||||
* @module lib/database
|
||||
* @author Hornwitser
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
|
||||
const { basicType } = require("lib/helpers");
|
||||
const libFileOps = require("./file_ops");
|
||||
const { basicType } = require("./helpers");
|
||||
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,7 @@ const { basicType } = require("lib/helpers");
|
||||
function mapToObject(map) {
|
||||
let obj = {};
|
||||
for (let [key, value] of map) {
|
||||
if (typeof key !== 'string') {
|
||||
if (typeof key !== "string") {
|
||||
throw new Error(
|
||||
`Expected all keys to be string but got ${typeof key}`
|
||||
);
|
||||
@@ -55,13 +56,12 @@ async function loadJsonAsMap(filePath) {
|
||||
return new Map(Object.entries(parsed));
|
||||
|
||||
} catch (err) {
|
||||
if (err.code == "ENOENT") {
|
||||
if (err.code === "ENOENT") {
|
||||
// File does not exist, return empty map
|
||||
return new Map();
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ async function loadJsonAsMap(filePath) {
|
||||
*/
|
||||
async function saveMapAsJson(filePath, map) {
|
||||
let obj = mapToObject(map);
|
||||
await fs.outputFile(filePath, JSON.stringify(obj, null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, 4));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,11 +105,11 @@ async function loadJsonArrayAsMap(filePath) {
|
||||
let map = new Map();
|
||||
for (let element of parsed) {
|
||||
if (basicType(element) !== "object") {
|
||||
throw new Error(`Expected all elements to be objects`);
|
||||
throw new Error("Expected all elements to be objects");
|
||||
}
|
||||
|
||||
if (element.id === undefined) {
|
||||
throw new Error(`Expected all elements to have an id property`);
|
||||
throw new Error("Expected all elements to have an id property");
|
||||
}
|
||||
|
||||
map.set(element.id, element);
|
||||
@@ -117,13 +117,12 @@ async function loadJsonArrayAsMap(filePath) {
|
||||
return map;
|
||||
|
||||
} catch (err) {
|
||||
if (err.code == "ENOENT") {
|
||||
if (err.code === "ENOENT") {
|
||||
// File does not exist, return empty map
|
||||
return new Map();
|
||||
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,11 +139,10 @@ async function loadJsonArrayAsMap(filePath) {
|
||||
* @throws {Error} if an error occured writing to the file.
|
||||
*/
|
||||
async function saveMapAsJsonArray(filePath, map) {
|
||||
await fs.outputFile(filePath, JSON.stringify([...map.values()], null, 4));
|
||||
await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, 4));
|
||||
}
|
||||
|
||||
|
||||
|
||||
function checkName(name) {
|
||||
if (typeof name !== "string") {
|
||||
throw new Error("name must be a string");
|
||||
@@ -232,7 +230,7 @@ class ItemDatabase {
|
||||
checkName(name);
|
||||
|
||||
if (!this._items.has(name)) {
|
||||
return 0
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this._items.get(name);
|
||||
@@ -287,4 +285,4 @@ module.exports = {
|
||||
loadJsonArrayAsMap,
|
||||
saveMapAsJsonArray,
|
||||
ItemDatabase,
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Errors thrown in Clusterio
|
||||
* @module lib/errors
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Thrown by commands when they fail
|
||||
* @static
|
||||
*/
|
||||
class CommandError extends Error { }
|
||||
|
||||
/**
|
||||
* Thrown from requests sent when an error occured handling it
|
||||
* @static
|
||||
*/
|
||||
class RequestError extends Error { }
|
||||
|
||||
/**
|
||||
* Thrown when a permission check fails.
|
||||
*
|
||||
* Is a subclass of RequestError to prevent logging stack traces when
|
||||
* requests fail due to permission denied.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
class PermissionError extends RequestError { }
|
||||
|
||||
/**
|
||||
* Signal for messages that fail validation
|
||||
* @static
|
||||
*/
|
||||
class InvalidMessage extends Error {
|
||||
constructor(msg, errors) {
|
||||
super(msg);
|
||||
this.errors = errors || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown from requests when the session it was sent on was lost
|
||||
* @static
|
||||
*/
|
||||
class SessionLost extends Error { }
|
||||
|
||||
/**
|
||||
* Thrown when WebSocket authentication failed
|
||||
* @static
|
||||
*/
|
||||
class AuthenticationFailed extends Error { }
|
||||
|
||||
/**
|
||||
* Errror class for known errors occuring during startup
|
||||
* @static
|
||||
*/
|
||||
class StartupError extends Error { }
|
||||
|
||||
/**
|
||||
* Errors outside of our control
|
||||
* @static
|
||||
*/
|
||||
class EnvironmentError extends Error { }
|
||||
|
||||
/**
|
||||
* Errors caused by plugins
|
||||
* @static
|
||||
*/
|
||||
class PluginError extends Error {
|
||||
constructor(pluginName, original) {
|
||||
super(`PluginError: ${original.message}`);
|
||||
this.pluginName = pluginName;
|
||||
this.original = original;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CommandError,
|
||||
RequestError,
|
||||
PermissionError,
|
||||
InvalidMessage,
|
||||
SessionLost,
|
||||
AuthenticationFailed,
|
||||
StartupError,
|
||||
EnvironmentError,
|
||||
PluginError,
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
"use strict";
|
||||
const zlib = require("zlib");
|
||||
|
||||
|
||||
class MapReaderState {
|
||||
constructor(buf) {
|
||||
this.pos = 0;
|
||||
this.buf = buf;
|
||||
this.last_position = { x: 0, y: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function readUInt8(state) {
|
||||
let value = state.buf.readUInt8(state.pos);
|
||||
state.pos += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readBool(state) {
|
||||
let value = readUInt8(state) !== 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readInt16(state) {
|
||||
let value = state.buf.readInt16LE(state.pos);
|
||||
state.pos += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readUInt16(state) {
|
||||
let value = state.buf.readUInt16LE(state.pos);
|
||||
state.pos += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readInt32(state) {
|
||||
let value = state.buf.readInt32LE(state.pos);
|
||||
state.pos += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readUInt32(state) {
|
||||
let value = state.buf.readUInt32LE(state.pos);
|
||||
state.pos += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readUInt32so(state) {
|
||||
let value = readUInt8(state);
|
||||
if (value === 0xff) {
|
||||
return readUInt32(state);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function readFloat(state) {
|
||||
let value = state.buf.readFloatLE(state.pos);
|
||||
state.pos += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readDouble(state) {
|
||||
let value = state.buf.readDoubleLE(state.pos);
|
||||
state.pos += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
function readString(state) {
|
||||
let size = readUInt32so(state);
|
||||
let data = state.buf.slice(state.pos, state.pos + size).toString("utf-8");
|
||||
state.pos += size;
|
||||
return data;
|
||||
}
|
||||
|
||||
function readOptional(state, readValue) {
|
||||
let load = readUInt8(state) !== 0;
|
||||
if (!load) {
|
||||
return null;
|
||||
}
|
||||
return readValue(state);
|
||||
}
|
||||
|
||||
function readArray(state, readItem) {
|
||||
let size = readUInt32so(state);
|
||||
|
||||
let array = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
let item = readItem(state);
|
||||
array.push(item);
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
function readDict(state, readKey, readValue) {
|
||||
let size = readUInt32so(state);
|
||||
|
||||
let mapping = new Map();
|
||||
for (let i = 0; i < size; i++) {
|
||||
let key = readKey(state);
|
||||
let value = readValue(state);
|
||||
mapping.set(key, value);
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function readVersion(state) {
|
||||
let major = readUInt16(state);
|
||||
let minor = readUInt16(state);
|
||||
let patch = readUInt16(state);
|
||||
let developer = readUInt16(state);
|
||||
return [major, minor, patch, developer];
|
||||
}
|
||||
|
||||
function readFrequencySizeRichness(state) {
|
||||
return {
|
||||
frequency: readFloat(state),
|
||||
size: readFloat(state),
|
||||
richness: readFloat(state),
|
||||
};
|
||||
}
|
||||
|
||||
function readAutoplaceSetting(state) {
|
||||
return {
|
||||
treat_missing_as_default: readBool(state),
|
||||
settings: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
|
||||
};
|
||||
}
|
||||
|
||||
function readMapPosition(state) {
|
||||
let x, y;
|
||||
let x_diff = readInt16(state) / 256;
|
||||
if (x_diff === 0x7fff / 256) {
|
||||
x = readInt32(state) / 256;
|
||||
y = readInt32(state) / 256;
|
||||
} else {
|
||||
let y_diff = readInt16(state) / 256;
|
||||
x = state.last_position.x + x_diff;
|
||||
y = state.last_position.y + y_diff;
|
||||
}
|
||||
state.last_position.x = x;
|
||||
state.last_position.x = y;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function readBoundingBox(state) {
|
||||
return {
|
||||
left_top: readMapPosition(state),
|
||||
right_bottom: readMapPosition(state),
|
||||
orientation: {
|
||||
x: readInt16(state),
|
||||
y: readInt16(state),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readCliffSettings(state) {
|
||||
return {
|
||||
name: readString(state),
|
||||
elevation_0: readFloat(state),
|
||||
elevation_interval: readFloat(state),
|
||||
richness: readFloat(state),
|
||||
};
|
||||
}
|
||||
|
||||
function readMapGenSettings(state) {
|
||||
return {
|
||||
terrain_segmentation: readFloat(state),
|
||||
water: readFloat(state),
|
||||
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
|
||||
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
|
||||
default_enable_all_autoplace_controls: readBool(state),
|
||||
seed: readUInt32(state),
|
||||
width: readUInt32(state),
|
||||
height: readUInt32(state),
|
||||
area_to_generate_at_start: readBoundingBox(state),
|
||||
starting_area: readFloat(state),
|
||||
peaceful_mode: readBool(state),
|
||||
starting_points: readArray(state, readMapPosition),
|
||||
property_expression_names: Object.fromEntries(readDict(state, readString, readString)),
|
||||
cliff_settings: readCliffSettings(state),
|
||||
};
|
||||
}
|
||||
|
||||
function readPollution(state) {
|
||||
return {
|
||||
enabled: readOptional(state, readBool),
|
||||
diffusion_ratio: readOptional(state, readDouble),
|
||||
min_to_diffuse: readOptional(state, readDouble),
|
||||
ageing: readOptional(state, readDouble),
|
||||
expected_max_per_chunk: readOptional(state, readDouble),
|
||||
min_to_show_per_chunk: readOptional(state, readDouble),
|
||||
min_pollution_to_damage_trees: readOptional(state, readDouble),
|
||||
pollution_with_max_forest_damage: readOptional(state, readDouble),
|
||||
pollution_per_tree_damage: readOptional(state, readDouble),
|
||||
pollution_restored_per_tree_damage: readOptional(state, readDouble),
|
||||
max_pollution_to_restore_trees: readOptional(state, readDouble),
|
||||
enemy_attack_pollution_consumption_modifier: readOptional(state, readDouble),
|
||||
};
|
||||
}
|
||||
|
||||
function readSteeringValue(state) {
|
||||
return {
|
||||
radius: readOptional(state, readDouble),
|
||||
separation_factor: readOptional(state, readDouble),
|
||||
separation_force: readOptional(state, readDouble),
|
||||
force_unit_fuzzy_goto_behavior: readOptional(state, readBool),
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function readSteering(state) {
|
||||
return {
|
||||
default: readSteeringValue(state),
|
||||
moving: readSteeringValue(state),
|
||||
};
|
||||
}
|
||||
|
||||
function readEnemyEvolution(state) {
|
||||
return {
|
||||
enabled: readOptional(state, readBool),
|
||||
time_factor: readOptional(state, readDouble),
|
||||
destroy_factor: readOptional(state, readDouble),
|
||||
pollution_factor: readOptional(state, readDouble),
|
||||
};
|
||||
}
|
||||
|
||||
function readEnemyExpansion(state) {
|
||||
return {
|
||||
enabled: readOptional(state, readBool),
|
||||
max_expansion_distance: readOptional(state, readUInt32),
|
||||
friendly_base_influence_radius: readOptional(state, readUInt32),
|
||||
enemy_building_influence_radius: readOptional(state, readUInt32),
|
||||
building_coefficient: readOptional(state, readDouble),
|
||||
other_base_coefficient: readOptional(state, readDouble),
|
||||
neighbouring_chunk_coefficient: readOptional(state, readDouble),
|
||||
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
|
||||
max_colliding_tiles_coefficient: readOptional(state, readDouble),
|
||||
settler_group_min_size: readOptional(state, readUInt32),
|
||||
settler_group_max_size: readOptional(state, readUInt32),
|
||||
min_expansion_cooldown: readOptional(state, readUInt32),
|
||||
max_expansion_cooldown: readOptional(state, readUInt32),
|
||||
};
|
||||
}
|
||||
|
||||
function readUnitGroup(state) {
|
||||
return {
|
||||
min_group_gathering_time: readOptional(state, readUInt32),
|
||||
max_group_gathering_time: readOptional(state, readUInt32),
|
||||
max_wait_time_for_late_members: readOptional(state, readUInt32),
|
||||
max_group_radius: readOptional(state, readDouble),
|
||||
min_group_radius: readOptional(state, readDouble),
|
||||
max_member_speedup_when_behind: readOptional(state, readDouble),
|
||||
max_member_slowdown_when_ahead: readOptional(state, readDouble),
|
||||
max_group_slowdown_factor: readOptional(state, readDouble),
|
||||
max_group_member_fallback_factor: readOptional(state, readDouble),
|
||||
member_disown_distance: readOptional(state, readDouble),
|
||||
tick_tolerance_when_member_arrives: readOptional(state, readUInt32),
|
||||
max_gathering_unit_groups: readOptional(state, readUInt32),
|
||||
max_unit_group_size: readOptional(state, readUInt32),
|
||||
};
|
||||
}
|
||||
|
||||
function readPathFinder(state) {
|
||||
return {
|
||||
fwd2bwd_ratio: readOptional(state, readInt32),
|
||||
goal_pressure_ratio: readOptional(state, readDouble),
|
||||
use_path_cache: readOptional(state, readBool),
|
||||
max_steps_worked_per_tick: readOptional(state, readDouble),
|
||||
max_work_done_per_tick: readOptional(state, readUInt32),
|
||||
short_cache_size: readOptional(state, readUInt32),
|
||||
long_cache_size: readOptional(state, readUInt32),
|
||||
short_cache_min_cacheable_distance: readOptional(state, readDouble),
|
||||
short_cache_min_algo_steps_to_cache: readOptional(state, readUInt32),
|
||||
long_cache_min_cacheable_distance: readOptional(state, readDouble),
|
||||
cache_max_connect_to_cache_steps_multiplier: readOptional(state, readUInt32),
|
||||
cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
|
||||
cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
|
||||
negative_cache_accept_path_start_distance_ratio: readOptional(state, readDouble),
|
||||
negative_cache_accept_path_end_distance_ratio: readOptional(state, readDouble),
|
||||
cache_path_start_distance_rating_multiplier: readOptional(state, readDouble),
|
||||
cache_path_end_distance_rating_multiplier: readOptional(state, readDouble),
|
||||
stale_enemy_with_same_destination_collision_penalty: readOptional(state, readDouble),
|
||||
ignore_moving_enemy_collision_distance: readOptional(state, readDouble),
|
||||
enemy_with_different_destination_collision_penalty: readOptional(state, readDouble),
|
||||
general_entity_collision_penalty: readOptional(state, readDouble),
|
||||
general_entity_subsequent_collision_penalty: readOptional(state, readDouble),
|
||||
extended_collision_penalty: readOptional(state, readDouble),
|
||||
max_clients_to_accept_any_new_request: readOptional(state, readUInt32),
|
||||
max_clients_to_accept_short_new_request: readOptional(state, readUInt32),
|
||||
direct_distance_to_consider_short_request: readOptional(state, readUInt32),
|
||||
short_request_max_steps: readOptional(state, readUInt32),
|
||||
short_request_ratio: readOptional(state, readDouble),
|
||||
min_steps_to_check_path_find_termination: readOptional(state, readUInt32),
|
||||
start_to_goal_cost_multiplier_to_terminate_path_find: readOptional(state, readDouble),
|
||||
overload_levels: readOptional(state, (p) => readArray(p, readUInt32)),
|
||||
overload_multipliers: readOptional(state, (p) => readArray(p, readDouble)),
|
||||
negative_path_cache_delay_interval: readOptional(state, readUInt32),
|
||||
};
|
||||
}
|
||||
|
||||
function readDifficultySettings(state) {
|
||||
return {
|
||||
recipe_difficulty: readUInt8(state),
|
||||
technology_difficulty: readUInt8(state),
|
||||
technology_price_multiplier: readDouble(state),
|
||||
research_queue_setting: ["always", "after-victory", "never"][readUInt8(state)],
|
||||
};
|
||||
}
|
||||
|
||||
function readMapSettings(state) {
|
||||
return {
|
||||
pollution: readPollution(state),
|
||||
steering: readSteering(state),
|
||||
enemy_evolution: readEnemyEvolution(state),
|
||||
enemy_expansion: readEnemyExpansion(state),
|
||||
unit_group: readUnitGroup(state),
|
||||
path_finder: readPathFinder(state),
|
||||
max_failed_behavior_count: readUInt32(state),
|
||||
difficulty_settings: readDifficultySettings(state),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} module:lib/factorio.MapExchangeResult
|
||||
* @property {array} version -
|
||||
* Version of Factorio the string was created with.
|
||||
* @property {object} map_gen_settings -
|
||||
* Decoded map generator settings in the format the --map-gen-settings
|
||||
* command line option to Factorio expect.
|
||||
* @property {object} map_settings -
|
||||
* Decoded map settings in the format the --map-settings command line option
|
||||
* to Factorio expects.
|
||||
* @property {number} checksum - checksum for the exchange string.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a Map Exchange String
|
||||
*
|
||||
* Reads and decodes the data in the given map exchange string and returns
|
||||
* data structures that can be fed into the Factorio server when creating a
|
||||
* save to set the map gen settings and the map settings for the save.
|
||||
*
|
||||
* @param {string} exchangeString - Max Exchange String to parse.
|
||||
* @returns {module:lib/factorio.MapExchangeResult} Parsed result.
|
||||
* @memberof module:lib/factorio
|
||||
*/
|
||||
function readMapExchangeString(exchangeString) {
|
||||
exchangeString = exchangeString.replace(/[ \t\n\r]+/g, "");
|
||||
if (!/>>>[0-9a-zA-Z\/+]+={0,3}<<</.test(exchangeString)) {
|
||||
throw new Error("Not a map exchange string");
|
||||
}
|
||||
|
||||
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
|
||||
try {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
buf = zlib.inflateSync(buf);
|
||||
} catch (err) {
|
||||
if (err.code.startsWith("Z_")) {
|
||||
throw new Error("Malformed map exchange string: zlib inflate failed");
|
||||
}
|
||||
}
|
||||
|
||||
let state = new MapReaderState(buf);
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = {
|
||||
version: readVersion(state),
|
||||
unknown: readUInt8(state),
|
||||
map_gen_settings: readMapGenSettings(state),
|
||||
map_settings: readMapSettings(state),
|
||||
checksum: readUInt32(state),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code === "ERR_OUT_OF_RANGE") {
|
||||
throw new Error("Malformed map exchange string: reached end before finishing parsing");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (state.pos !== buf.length) {
|
||||
throw new Error("Malformed map exchange string: data after end");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readMapExchangeString,
|
||||
};
|
||||
@@ -0,0 +1,418 @@
|
||||
// Export of item icons and locale
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const Jimp = require("jimp");
|
||||
const JSZip = require("jszip");
|
||||
|
||||
const libIni = require("../ini");
|
||||
const libBuildMod = require("../build_mod");
|
||||
|
||||
/**
|
||||
* Generate the export mod needed for exportData
|
||||
*
|
||||
* Packs the lua export mod located in lua/export into the mods folder for
|
||||
* the server given, with dependencies generated for all the mods present in
|
||||
* the server's mods folder.
|
||||
*
|
||||
* @param {module:lib/factorio.FactorioServer} server -
|
||||
* The server to generate the export mod for.
|
||||
* @memberof module:lib/factorio
|
||||
*/
|
||||
async function generateExportMod(server) {
|
||||
let dependencies = [];
|
||||
let splitter = /^(.*)_(\d+\.\d+\.\d+)(\.zip)?$/;
|
||||
for (let entry of await fs.readdir(server.writePath("mods"))) {
|
||||
let match = splitter.exec(entry);
|
||||
if (match && match[1] !== "export") {
|
||||
dependencies.push(`? ${match[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
await libBuildMod.build({
|
||||
clean: false,
|
||||
build: true,
|
||||
pack: true,
|
||||
// XXX fix path when moving lib/factorio to slave
|
||||
sourceDir: path.join(__dirname, "..", "..", "slave", "lua", "export"),
|
||||
outputDir: server.writePath("mods"),
|
||||
bumpPatch: false,
|
||||
factorioVersion: server.version.replace(/\.\d+$/, ""),
|
||||
dependencies,
|
||||
});
|
||||
}
|
||||
|
||||
let zipCache = new Map();
|
||||
async function loadZip(server, modVersions, mod) {
|
||||
let modVersion = modVersions.get(mod);
|
||||
if (!modVersion) {
|
||||
throw new Error(`Got path for unknown mod ${mod}`);
|
||||
}
|
||||
|
||||
let zipPath = server.writePath("mods", `${mod}_${modVersion}.zip`);
|
||||
let zip = zipCache.get(zipPath);
|
||||
if (!zip) {
|
||||
zip = await JSZip.loadAsync(await fs.readFile(zipPath));
|
||||
zipCache.set(zipPath, zip);
|
||||
}
|
||||
|
||||
return zip.folder(`${mod}_${modVersion}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the given Factorio file path into a Buffer
|
||||
*
|
||||
* @param {module:lib/factorio.FactorioServer} server -
|
||||
* The server to load the file from.
|
||||
* @param {Map<string, string>} modVersions - Mapping of mod to version used.
|
||||
* @param {string} modPath - Factorio style path to the file to load.
|
||||
* @returns {?Buffer} The content of the file or null if not found.
|
||||
* @memberof module:lib/factorio
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
async function loadFile(server, modVersions, modPath) {
|
||||
let match = /^__([^\/]+)__\/(.*)$/.exec(modPath);
|
||||
if (!match) {
|
||||
throw new Error(`Bad mod path ${match}`);
|
||||
}
|
||||
|
||||
let [, mod, filePath] = match;
|
||||
|
||||
if (["core", "base"].includes(mod)) {
|
||||
try {
|
||||
return await fs.readFile(server.dataPath(mod, filePath));
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let zip;
|
||||
try {
|
||||
zip = await loadZip(server, modVersions, mod);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let file = zip.file(filePath);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await file.async("nodebuffer");
|
||||
}
|
||||
|
||||
async function loadIcon(server, modVersions, iconPath, iconSize, iconMipmaps, iconCache) {
|
||||
let icon = iconCache.get(iconPath);
|
||||
if (icon === undefined) {
|
||||
let fileContent = await loadFile(server, modVersions, iconPath);
|
||||
if (fileContent) {
|
||||
icon = await Jimp.read(fileContent);
|
||||
icon.crop(0, 0, iconSize, iconSize);
|
||||
iconCache.set(iconPath, icon);
|
||||
} else {
|
||||
icon = null;
|
||||
server._logger.warn(`${iconPath} not found`);
|
||||
}
|
||||
iconCache.set(iconPath, icon);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
async function loadSimpleIcon(server, modVersions, item, size, iconCache) {
|
||||
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size, 0, iconCache);
|
||||
if (icon) {
|
||||
let iconScale = size / item.icon_size;
|
||||
if (iconScale !== 1) {
|
||||
icon = icon.clone();
|
||||
icon.scale(iconScale);
|
||||
}
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
async function loadLayeredIcon(server, modVersions, item, size, iconCache) {
|
||||
let baseLayerSize = item.icons[0].icon_size || item.icon_size;
|
||||
let icon = await Jimp.create(size, size);
|
||||
|
||||
// The scaling factor of the base layer
|
||||
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
|
||||
|
||||
// The size in pixels of one unit
|
||||
let baseUnit = size / (baseLayerSize * baseLayerScale);
|
||||
|
||||
for (let layer of item.icons) {
|
||||
let layerSize = layer.icon_size || item.icon_size;
|
||||
let iconLayer = await loadIcon(server, modVersions, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache);
|
||||
|
||||
if (!iconLayer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
iconLayer = iconLayer.clone();
|
||||
|
||||
let tint;
|
||||
if (layer.tint) {
|
||||
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
|
||||
tint = {
|
||||
r: (layer.tint.r || 0) / divisor,
|
||||
g: (layer.tint.g || 0) / divisor,
|
||||
b: (layer.tint.b || 0) / divisor,
|
||||
a: layer.tint.a || 1,
|
||||
};
|
||||
} else {
|
||||
tint = { r: 1, b: 1, g: 1, a: 1 };
|
||||
}
|
||||
|
||||
let layerScale = layer.scale || 32 / layerSize;
|
||||
let [xs, ys] = layer.shift || [0, 0];
|
||||
let realScale = layerScale * baseUnit;
|
||||
let sizeShift = (size - layerSize * realScale) / 2;
|
||||
|
||||
xs = xs * baseUnit + sizeShift;
|
||||
ys = ys * baseUnit + sizeShift;
|
||||
|
||||
if (realScale !== 1) {
|
||||
iconLayer.scale(realScale);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-loop-func
|
||||
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
|
||||
x += xs;
|
||||
y += ys;
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) {
|
||||
return;
|
||||
}
|
||||
let sdata = iconLayer.bitmap.data;
|
||||
let ddata = icon.bitmap.data;
|
||||
let didx = icon.getPixelIndex(x, y);
|
||||
|
||||
let sa = sdata[sidx + 3] / 255;
|
||||
let da = ddata[didx + 3] / 255;
|
||||
let db = da * (1 - sa * tint.a);
|
||||
let cb = sa + da * (1 - sa);
|
||||
ddata[didx + 0] = Math.min(255, (sdata[sidx + 0] * sa * tint.r + ddata[didx + 0] * db) / cb);
|
||||
ddata[didx + 1] = Math.min(255, (sdata[sidx + 1] * sa * tint.g + ddata[didx + 1] * db) / cb);
|
||||
ddata[didx + 2] = Math.min(255, (sdata[sidx + 2] * sa * tint.b + ddata[didx + 2] * db) / cb);
|
||||
ddata[didx + 3] = Math.min(255, sdata[sidx + 3] + ddata[didx + 3] * (1 - sa));
|
||||
});
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export item icons and data
|
||||
*
|
||||
* Assembles and packs the icons for the item prototypes given into a single
|
||||
* spritesheet and json file with meta data.
|
||||
*
|
||||
* @param {module:lib/factorio.FactorioServer} server -
|
||||
* The server to generate the export mod for.
|
||||
* @param {Map<string, string>} modVersions -
|
||||
* Mapping of mod name to versions to get icons from.
|
||||
* @param {Array<Object>} items - Array of item prototypes.
|
||||
* @return {{itemSheet: Jimp, itemData: Map<string,Object>}}
|
||||
* Item spritesheet and metadata.
|
||||
* @memberof module:lib/factorio
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
async function exportItems(server, modVersions, items) {
|
||||
|
||||
// Size to render icons at
|
||||
let size = 32;
|
||||
|
||||
// Width of spritesheet
|
||||
let width = 1024;
|
||||
|
||||
let rows = Math.ceil(items.length / (width / size));
|
||||
let iconSheet = await Jimp.create(width, rows * size);
|
||||
let itemData = new Map();
|
||||
let pos = 0;
|
||||
|
||||
let iconCache = new Map();
|
||||
let simpleIcons = new Map();
|
||||
for (let item of items) {
|
||||
let icon;
|
||||
let iconPos;
|
||||
if (item.icons) {
|
||||
icon = await loadLayeredIcon(server, modVersions, item, size, iconCache);
|
||||
iconPos = pos;
|
||||
|
||||
} else {
|
||||
iconPos = simpleIcons.get(item.icon);
|
||||
if (iconPos === undefined) {
|
||||
icon = await loadSimpleIcon(server, modVersions, item, size, iconCache);
|
||||
if (icon) {
|
||||
iconPos = pos;
|
||||
simpleIcons.set(item.icon, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (iconPos !== undefined) {
|
||||
itemData.set(item.name, {
|
||||
x: iconPos * size % width,
|
||||
y: Math.floor(iconPos / (width / size)) * size,
|
||||
size,
|
||||
localised_name: item.localised_name,
|
||||
localised_description: item.localised_description,
|
||||
});
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
iconSheet.composite(icon, pos * size % width, Math.floor(pos / (width / size)) * size);
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
iconSheet.crop(0, 0, width, Math.ceil(pos / (width / size)) * size);
|
||||
|
||||
return { iconSheet, itemData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the locale files for the base game and the given mods
|
||||
*
|
||||
* Parses and merges all the locales for the all the mods given through
|
||||
* `modVersions` and `modOrder`.
|
||||
*
|
||||
* @param {module:lib/factorio.FactorioServer} server -
|
||||
* The server to export the locale from.
|
||||
* @param {Map<string, string>} modVersions -
|
||||
* Mapping of mod name to version to export locale from.
|
||||
* @param {Array<string>} modOrder - Load order of the mods.
|
||||
* @param {string} languageCode - Language to export locale for.
|
||||
* @returns {Map<string, string>} merged locale information
|
||||
* @memberof module:lib/factorio
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
async function exportLocale(server, modVersions, modOrder, languageCode) {
|
||||
let mergedLocales = new Map();
|
||||
|
||||
function mergeLocale(locale) {
|
||||
for (let [category, entries] of Object.entries(locale)) {
|
||||
if (typeof entries === "string") {
|
||||
mergedLocales.set(category, entries);
|
||||
|
||||
} else {
|
||||
for (let [key, value] of Object.entries(entries)) {
|
||||
mergedLocales.set(`${category}.${key}`, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
|
||||
mergeLocale(libIni.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
|
||||
|
||||
for (let mod of modOrder) {
|
||||
if (["core", "base", "export"].includes(mod)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let zip;
|
||||
try {
|
||||
zip = await loadZip(server, modVersions, mod);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
for (let file of zip.file(new RegExp(`locale\\/${languageCode}\\/.*\\.cfg`))) {
|
||||
let content = await file.async("nodebuffer");
|
||||
mergeLocale(libIni.parse(content.toString("utf8")));
|
||||
}
|
||||
}
|
||||
|
||||
return mergedLocales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the locale and item icons for the given factorio server
|
||||
*
|
||||
* @param {module:lib/factorio.FactorioServer} server -
|
||||
* The server to export the data from.
|
||||
* @returns {JSZip} zip file with exported data.
|
||||
* @memberof module:lib/factorio
|
||||
*/
|
||||
async function exportData(server) {
|
||||
await generateExportMod(server);
|
||||
|
||||
let items = [];
|
||||
let modVersions = new Map();
|
||||
let modOrder = [];
|
||||
|
||||
server.on("ipc-item_export", data => items.push(data));
|
||||
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
|
||||
server.on("output", parsed => {
|
||||
if (parsed.format === "seconds" && parsed.type === "generic") {
|
||||
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
|
||||
if (match) {
|
||||
modOrder.push(match[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await server.startScenario("base/freeplay");
|
||||
await server.kill();
|
||||
} finally {
|
||||
await fs.unlink(server.writePath("mods", "export_0.0.0.zip"));
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
throw new Error("No items got exported");
|
||||
}
|
||||
|
||||
// Some mod authors put leading zeros into the versions of their zip files.
|
||||
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
|
||||
for (let entry of await fs.readdir(server.writePath("mods"))) {
|
||||
let match = splitter.exec(entry);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let modVersion = `${match[2]}.${match[3]}.${match[4]}`;
|
||||
let normalizedVersion =
|
||||
`${Number.parseInt(match[2], 10)}.${Number.parseInt(match[3], 10)}.${Number.parseInt(match[4], 10)}`
|
||||
;
|
||||
|
||||
if (modVersion === normalizedVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (modVersions.get(match[1]) === normalizedVersion) {
|
||||
modVersions.set(match[1], modVersion);
|
||||
}
|
||||
}
|
||||
|
||||
let { iconSheet, itemData } = await exportItems(server, modVersions, items);
|
||||
let locale = await exportLocale(server, modVersions, modOrder, "en");
|
||||
|
||||
// Free up the memory used by zip files loaded during the export.
|
||||
zipCache.clear();
|
||||
|
||||
let zip = new JSZip();
|
||||
zip.file("export/item-spritesheet.png", await iconSheet.getBufferAsync(Jimp.MIME_PNG));
|
||||
zip.file("export/item-metadata.json", JSON.stringify([...itemData.entries()]));
|
||||
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
|
||||
return zip;
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
exportData,
|
||||
|
||||
// For testing only
|
||||
_exportLocale: exportLocale,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Library for interfacing with Factorio Servers and saves.
|
||||
* @module lib/factorio
|
||||
* @author Hornwitser
|
||||
*/
|
||||
"use strict";
|
||||
const exchange_string = require("./exchange_string");
|
||||
const export_ = require("./export");
|
||||
const patch = require("./patch");
|
||||
const server = require("./server");
|
||||
|
||||
module.exports = {
|
||||
...exchange_string,
|
||||
...export_,
|
||||
...patch,
|
||||
...server,
|
||||
};
|
||||
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* Library for patching Factorio saves with scenario code.
|
||||
* @author Hornwitser
|
||||
*/
|
||||
// Library for patching Factorio saves with scenario code.
|
||||
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const fs = require("fs-extra");
|
||||
const jszip = require("jszip");
|
||||
const JSZip = require("jszip");
|
||||
const path = require("path");
|
||||
const semver = require("semver");
|
||||
|
||||
const hash = require("lib/hash");
|
||||
const libHash = require("../hash");
|
||||
|
||||
|
||||
const knownScenarios = {
|
||||
@@ -30,7 +27,11 @@ const knownScenarios = {
|
||||
* Returns the name of the folder that all files in the zip file is
|
||||
* contained in. Throws an error if there are multiple such folders.
|
||||
*
|
||||
* @param {JSZip} zip - Zip to search through.
|
||||
* @returns {string} name of the root folder.
|
||||
* @memberof module:lib/factorio
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
function findRoot(zip) {
|
||||
let root = null;
|
||||
@@ -44,12 +45,12 @@ function findRoot(zip) {
|
||||
if (root === null) {
|
||||
root = pathRoot;
|
||||
} else if (root !== pathRoot) {
|
||||
throw new Error(`Zip contains multiple root folders`);
|
||||
throw new Error("Zip contains multiple root folders");
|
||||
}
|
||||
});
|
||||
|
||||
if (root === null) {
|
||||
throw new Error(`Empty zip file`);
|
||||
throw new Error("Empty zip file");
|
||||
}
|
||||
|
||||
return root;
|
||||
@@ -60,43 +61,47 @@ function findRoot(zip) {
|
||||
* Generates control.lua code for loading the Clusterio modules
|
||||
*
|
||||
* @param {Object} patchInfo - The patch info files's json content
|
||||
* @returns {string} Generated control.lua code.
|
||||
* @memberof module:lib/factorio
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
function generateLoader(patchInfo) {
|
||||
let lines = [
|
||||
'-- Auto generated scenario module loader created by Clusterio',
|
||||
'-- Modifications to this file will be lost when loaded in Clusterio',
|
||||
"-- Auto generated scenario module loader created by Clusterio",
|
||||
"-- Modifications to this file will be lost when loaded in Clusterio",
|
||||
`clusterio_patch_number = ${patchInfo.patch_number}`,
|
||||
'',
|
||||
"",
|
||||
'local event_handler = require("event_handler")',
|
||||
'',
|
||||
'-- Scenario modules',
|
||||
"",
|
||||
"-- Scenario modules",
|
||||
];
|
||||
|
||||
for (let moduleName of patchInfo["scenario"]["modules"]) {
|
||||
lines.push('event_handler.add_lib(require("'+moduleName+'"))');
|
||||
lines.push(`event_handler.add_lib(require("${moduleName}"))`);
|
||||
}
|
||||
|
||||
lines.push(...[
|
||||
'',
|
||||
'-- Clusterio modules',
|
||||
"",
|
||||
"-- Clusterio modules",
|
||||
]);
|
||||
|
||||
for (let module of patchInfo["modules"]) {
|
||||
for (let file of module["files"]) {
|
||||
let requirePath = `modules/${module.name}/${file["path"].slice(0, -4)}`;
|
||||
if (file["load"]) {
|
||||
lines.push('event_handler.add_lib(require("'+requirePath+'"))');
|
||||
lines.push(`event_handler.add_lib(require("${requirePath}"))`);
|
||||
}
|
||||
if (file["require"]) {
|
||||
lines.push('require("'+requirePath+'")');
|
||||
lines.push(`require("${requirePath}")`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End last line with a newline
|
||||
lines.push('');
|
||||
lines.push("");
|
||||
|
||||
return lines.join('\n');
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +112,9 @@ function generateLoader(patchInfo) {
|
||||
* their dependents. Throws an error if this is not possible.
|
||||
*
|
||||
* @param {Array<Object>} modules - Array of modules to reorder
|
||||
* @memberof module:lib/factorio
|
||||
* @private
|
||||
* @inner
|
||||
*/
|
||||
function reorderDependencies(modules) {
|
||||
let index = 0;
|
||||
@@ -144,7 +152,7 @@ function reorderDependencies(modules) {
|
||||
|
||||
// No unmet dependencies, record and continue
|
||||
present.set(module.name, module.version);
|
||||
index++;
|
||||
index += 1;
|
||||
|
||||
if (hold.has(module.name)) {
|
||||
modules.splice(index, 0, ...hold.get(module.name));
|
||||
@@ -161,8 +169,8 @@ function reorderDependencies(modules) {
|
||||
// the module depends on a module that satisfy any of these conditions.
|
||||
|
||||
let remaining = new Map();
|
||||
for (let modules of hold.values()) {
|
||||
for (let module of modules) {
|
||||
for (let heldModules of hold.values()) {
|
||||
for (let module of heldModules) {
|
||||
remaining.set(module.name, module);
|
||||
}
|
||||
}
|
||||
@@ -182,7 +190,7 @@ function reorderDependencies(modules) {
|
||||
|
||||
if (cycle.includes(module.name)) {
|
||||
cycle.push(module.name);
|
||||
cycle.splice(0, cycle.indexOf(module.name))
|
||||
cycle.splice(0, cycle.indexOf(module.name));
|
||||
throw new Error(`Module dependency loop detected: ${cycle.join(" -> ")}`);
|
||||
}
|
||||
|
||||
@@ -202,9 +210,10 @@ function reorderDependencies(modules) {
|
||||
*
|
||||
* @param {string} savePath - Path to the Factorio save to patch.
|
||||
* @param {Array<Object>} modules - Description of the modules to patch.
|
||||
* @memberof module:lib/factorio
|
||||
*/
|
||||
async function patch(savePath, modules) {
|
||||
let zip = await jszip.loadAsync(await fs.readFile(savePath));
|
||||
let zip = await JSZip.loadAsync(await fs.readFile(savePath));
|
||||
let root = zip.folder(findRoot(zip));
|
||||
|
||||
let patchInfoFile = root.file("clusterio.json");
|
||||
@@ -216,7 +225,7 @@ async function patch(savePath, modules) {
|
||||
// No info file present, try to detect if it's a known compatible scenario.
|
||||
} else {
|
||||
let controlStream = root.file("control.lua").nodeStream("nodebuffer");
|
||||
let controlHash = await hash.hashStream(controlStream);
|
||||
let controlHash = await libHash.hashStream(controlStream);
|
||||
|
||||
if (controlHash in knownScenarios) {
|
||||
patchInfo = {
|
||||
@@ -244,9 +253,9 @@ async function patch(savePath, modules) {
|
||||
let moduleInfo = {
|
||||
"name": module.name,
|
||||
"files": [],
|
||||
}
|
||||
};
|
||||
|
||||
let dirs = [[module.path, '']];
|
||||
let dirs = [[module.path, ""]];
|
||||
while (dirs.length) {
|
||||
let [dir, relativeDir] = dirs.pop();
|
||||
for (let entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
@@ -275,9 +284,11 @@ async function patch(savePath, modules) {
|
||||
root.file("clusterio.json", JSON.stringify(patchInfo, null, 4));
|
||||
|
||||
// Write back the save
|
||||
let stream = zip.generateNodeStream({compression: "DEFLATE"});
|
||||
let pipe = stream.pipe(fs.createWriteStream(savePath));
|
||||
let tempSavePath = `${savePath}.tmp`;
|
||||
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
|
||||
let pipe = stream.pipe(fs.createWriteStream(tempSavePath));
|
||||
await events.once(pipe, "finish");
|
||||
await fs.rename(tempSavePath, savePath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -287,4 +298,4 @@ module.exports = {
|
||||
_findRoot: findRoot,
|
||||
_generateLoader: generateLoader,
|
||||
_reorderDependencies: reorderDependencies,
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @module lib/file_ops
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto"); // needed for getTempFile
|
||||
/**
|
||||
* Returns the newest file in a directory
|
||||
*
|
||||
* @param {string} directory - The directory to look for the newest file in.
|
||||
* @param {function(string): boolean} filter -
|
||||
* Optional function to filter out files to ignore. Should return true
|
||||
* if the file is to be considered.
|
||||
* @returns {?string}
|
||||
* Name of the file with the newest timestamp or null if the
|
||||
* directory contains no files.
|
||||
*/
|
||||
async function getNewestFile(directory, filter = (name) => true) {
|
||||
let newestTime = new Date(0);
|
||||
let newestFile = null;
|
||||
for (let entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isFile()) {
|
||||
let stat = await fs.stat(path.join(directory, entry.name));
|
||||
if (filter(entry.name) && stat.mtime > newestTime) {
|
||||
newestTime = stat.mtime;
|
||||
newestFile = entry.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newestFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies name in case it already exisist at the given directory
|
||||
*
|
||||
* Checks the directory passed if it already contains a file or folder with
|
||||
* the given name, if it does not returns name unmodified, otherwise it
|
||||
* returns a modified name that does not exist in the directory.
|
||||
*
|
||||
* Warning: this function should not be relied upon to be accurate for
|
||||
* security sensitive applications. The selection process is inherently
|
||||
* racy and a file or folder may have been created in the folder by the time
|
||||
* this function returns.
|
||||
*
|
||||
* @param {string} directory - directory to check in.
|
||||
* @param {string} name - file name to check for, may have extension.
|
||||
* @param {string} extension - dot extension used for the file name.
|
||||
* @returns {string} modified name with extension that likely does not exist
|
||||
* in the folder
|
||||
*/
|
||||
async function findUnusedName(directory, name, extension = "") {
|
||||
if (extension && name.endsWith(extension)) {
|
||||
name = name.slice(0, -extension.length);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (!await fs.pathExists(path.join(directory, `${name}${extension}`))) {
|
||||
return `${name}${extension}`;
|
||||
}
|
||||
|
||||
let match = /^(.*?)(-(\d+))?$/.exec(name);
|
||||
if (!match[2]) {
|
||||
name = `${match[1]}-2`;
|
||||
} else {
|
||||
name = `${match[1]}-${Number.parseInt(match[3], 10) + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Warning: this function should not be relied upon to be accurate for
|
||||
* security sensitive applications. The selection process is inherently
|
||||
* racy and a file or folder may have been created in the folder by the time
|
||||
* this function returns.
|
||||
*
|
||||
* @param {string} prefix - Prefix for file
|
||||
* @param {string} suffix - Suffix for file
|
||||
* @param {string} tmpdir - Directory for temp file
|
||||
*/
|
||||
async function getTempFile(prefix, suffix, tmpdir) {
|
||||
prefix = (typeof prefix !== "undefined") ? prefix : "tmp.";
|
||||
suffix = (typeof suffix !== "undefined") ? suffix : "";
|
||||
tmpdir = (typeof tmpdir !== "undefined") ? tmpdir : "./";
|
||||
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
|
||||
let freeFile = await findUnusedName(tmpdir, fileName);
|
||||
let fullPath = path.join(tmpdir, freeFile);
|
||||
return fullPath;
|
||||
}
|
||||
/**
|
||||
* Safely write data to a file
|
||||
*
|
||||
* Same as fs-extra.outputFile except the data is written to a temporary
|
||||
* file that's renamed over the target file. The name of the temporary file
|
||||
* is the same as the target file with the suffix `.tmp` added.
|
||||
*
|
||||
* If the operation fails it may leave behind the temporary file. This
|
||||
* should not be too much of an issue as the next time the same file is
|
||||
* written the temporary will be overwritten and renamed to the target file.
|
||||
*
|
||||
* @param {string} file - Path to file to write.
|
||||
* @param {string|Buffer} data - Content to write.
|
||||
* @param {object|string} options - see fs.writeFile, `flag` must not be set.
|
||||
*/
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let temporary = `${file}.tmp`;
|
||||
await fs.outputFile(temporary, data, options);
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNewestFile,
|
||||
findUnusedName,
|
||||
getTempFile,
|
||||
safeOutputFile,
|
||||
};
|
||||
@@ -1,21 +1,27 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
/**
|
||||
* Hashing functions
|
||||
* @module lib/hash
|
||||
*/
|
||||
"use strict";
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to the SHA1 hash of the stream given
|
||||
*
|
||||
* @param stream - Node stream of the content to hash.
|
||||
* @param {Stream} stream - Node stream of the content to hash.
|
||||
* @returns {Promise<string>} hash of the stream.
|
||||
*/
|
||||
function hashStream(stream) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
let hasher = crypto.createHash('sha1')
|
||||
hasher.setEncoding('hex')
|
||||
hasher.on('finish', function() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let hasher = crypto.createHash("sha1");
|
||||
hasher.setEncoding("hex");
|
||||
hasher.on("finish", () => {
|
||||
resolve(hasher.read());
|
||||
});
|
||||
|
||||
stream.on('error', function(error) {
|
||||
stream.on("error", (error) => {
|
||||
// the docs doesn't say anything about what's passed on error
|
||||
reject(error);
|
||||
});
|
||||
@@ -28,6 +34,7 @@ function hashStream(stream) {
|
||||
* Returns a promise that resolves to the SHA1 hash of the file given by path
|
||||
*
|
||||
* @param {string} path - Path to the file to hash.
|
||||
* @returns {Promise<string>} hash of the file given.
|
||||
*/
|
||||
function hashFile(path) {
|
||||
return hashStream(fs.createReadStream(path));
|
||||
@@ -36,4 +43,4 @@ function hashFile(path) {
|
||||
module.exports = {
|
||||
hashStream,
|
||||
hashFile,
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Collection of small utilites that are useful in multiple places.
|
||||
* @module lib/helpers
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Return a string describing the type of the value passed
|
||||
*
|
||||
* Works the same as typeof, excpet that null and array types get their
|
||||
* own string.
|
||||
*
|
||||
* @param {*} value - value to return the type of.
|
||||
* @returns {string} basic type of the value passed.
|
||||
*/
|
||||
function basicType(value) {
|
||||
if (value === null) { return "null"; }
|
||||
if (value instanceof Array) { return "array"; }
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asynchronously wait for the given duration
|
||||
*
|
||||
* @param {number} duration - Time to wait for in milliseconds.
|
||||
*/
|
||||
async function wait(duration) {
|
||||
await new Promise(resolve => { setTimeout(resolve, duration); });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resolve a promise with a timeout.
|
||||
*
|
||||
* @param {Promise} promise - Promise to wait for.
|
||||
* @param {number} time - Maximum time im milliseconds to wait for.
|
||||
* @param {*=} timeoutResult - Value to return if the operation timed out.
|
||||
*/
|
||||
async function timeout(promise, time, timeoutResult) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise(resolve => {
|
||||
timer = setTimeout(() => resolve(timeoutResult), time);
|
||||
}),
|
||||
]);
|
||||
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Read stream to the end and return its content
|
||||
*
|
||||
* Reads the stream given asynchronously until the end is reached and
|
||||
* returns all the data which was read from the stream.
|
||||
*
|
||||
* @param {Readable} stream - byte stream to read to the end.
|
||||
* @returns {Buffer} content of the stream.
|
||||
*/
|
||||
async function readStream(stream) {
|
||||
let chunks = [];
|
||||
for await (let chunk of stream) {
|
||||
// Support using ^Z to end input on Windows
|
||||
if (process.platform === "win32" && stream.isTTY && chunk.toString() === "\x1a\r\n") {
|
||||
break;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Escapes text for inclusion in a RegExp
|
||||
*
|
||||
* Adds \ character in front of special meta characters in the passsed in
|
||||
* text so that it can be embedded into a RegExp and only match the text.
|
||||
*
|
||||
* See https://stackoverflow.com/a/9310752
|
||||
*
|
||||
* @param {string} text - Text to escape RegExp meta chars in.
|
||||
* @returns {string} escaped text.
|
||||
*/
|
||||
function escapeRegExp(text) {
|
||||
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
basicType,
|
||||
wait,
|
||||
timeout,
|
||||
readStream,
|
||||
escapeRegExp,
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
const libBuildMod = require("./build_mod");
|
||||
const libCommand = require("./command");
|
||||
const libConfig = require("./config");
|
||||
const libDatabase = require("./database");
|
||||
const libErrors = require("./errors");
|
||||
const libFactorio = require("./factorio");
|
||||
const libFileOps = require("./file_ops");
|
||||
const libHash = require("./hash");
|
||||
const libHelpers = require("./helpers");
|
||||
const libLink = require("./link");
|
||||
const libLogging = require("./logging");
|
||||
const libLoggingUtils = require("./logging_utils");
|
||||
const libLuaTools = require("./lua_tools");
|
||||
const libPlugin = require("./plugin");
|
||||
const libPluginLoader = require("./plugin_loader");
|
||||
const libPrometheus = require("./prometheus");
|
||||
const libSchema = require("./schema");
|
||||
const libSharedCommands = require("./shared_commands");
|
||||
const libUsers = require("./users");
|
||||
|
||||
const ExponentialBackoff = require("./ExponentialBackoff");
|
||||
const RateLimiter = require("./RateLimiter");
|
||||
|
||||
|
||||
module.exports = {
|
||||
libBuildMod,
|
||||
libCommand,
|
||||
libConfig,
|
||||
libDatabase,
|
||||
libErrors,
|
||||
libFactorio,
|
||||
libFileOps,
|
||||
libHash,
|
||||
libHelpers,
|
||||
libLink,
|
||||
libLogging,
|
||||
libLoggingUtils,
|
||||
libLuaTools,
|
||||
libPlugin,
|
||||
libPluginLoader,
|
||||
libPrometheus,
|
||||
libSchema,
|
||||
libSharedCommands,
|
||||
libUsers,
|
||||
|
||||
ExponentialBackoff,
|
||||
RateLimiter,
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Factorio Compatible INI parser
|
||||
*
|
||||
* @module lib/ini
|
||||
* @author Hornwitser
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Parse INI file into object of sections
|
||||
* @param {string} input - content of INI file to parse.
|
||||
* @returns {object<string, object<string, string>>}
|
||||
* sections parsed from the ini file.
|
||||
*/
|
||||
function parse(input) {
|
||||
const sections = {};
|
||||
let currentSection = sections;
|
||||
const lines = input.split(/\r\n|\n/);
|
||||
for (let number = 1; number <= lines.length; number++) {
|
||||
let line = lines[number - 1].replace(/^[\t ]+/, "");
|
||||
if (!line || line[0] === "#" || line[0] === ";") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read section header
|
||||
if (line[0] === "[") {
|
||||
line = line.replace(/[\t ]+$/, "");
|
||||
if (line.slice(-1) !== "]") {
|
||||
throw new Error(`Unterminated section header on line ${number}`);
|
||||
}
|
||||
const name = line.slice(1, -1);
|
||||
if (Object.prototype.hasOwnProperty.call(sections, name)) {
|
||||
throw new Error(`Duplicated section [${name}] on line ${number}`);
|
||||
}
|
||||
currentSection = {};
|
||||
sections[name] = currentSection;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read key value
|
||||
const equalIndex = line.indexOf("=");
|
||||
if (equalIndex === -1) {
|
||||
throw new Error(`Missing value for key ${line} on line ${number}`);
|
||||
}
|
||||
const key = line.slice(0, equalIndex);
|
||||
if (Object.prototype.hasOwnProperty.call(currentSection, key)) {
|
||||
throw new Error(`Duplicated key ${key} on line ${number}`);
|
||||
}
|
||||
const value = line.slice(equalIndex + 1);
|
||||
currentSection[key] = value;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an object of section definitions into an INI file
|
||||
* @param {object<string, object<string, string>>} sections -
|
||||
* Sections to encode into ini format
|
||||
* @returns {string} sections serialized into INI format
|
||||
*/
|
||||
function stringify(sections) {
|
||||
let serialized = [];
|
||||
let first = true;
|
||||
|
||||
for (let [key, value] of Object.entries(sections)) {
|
||||
if (typeof value === "string") {
|
||||
serialized.push(`${key}=${value}`);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let [section, content] of Object.entries(sections)) {
|
||||
if (typeof content === "object") {
|
||||
if (!first) {
|
||||
serialized.push("");
|
||||
}
|
||||
first = false;
|
||||
serialized.push(`[${section}]`);
|
||||
for (let [key, value] of Object.entries(content)) {
|
||||
serialized.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serialized.length) { serialized.push(""); }
|
||||
return serialized.join("\n");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parse,
|
||||
stringify,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable node/global-require */
|
||||
"use strict";
|
||||
// eslint-disable-next-line node/no-process-env
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
// eslint-disable-next-line node/no-extraneous-require
|
||||
module.exports = require("fast-deep-equal/es6");
|
||||
} else {
|
||||
module.exports = require("util").isDeepStrictEqual;
|
||||
}
|
||||