Repository navigation
Expand file tree
/
Copy pathengine_server.js
More file actions
309 lines (270 loc) · 12 KB
/
Copy pathengine_server.js
File metadata and controls
309 lines (270 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PORT = Number(process.env.PORT) || 8080;
const ISOLATION_HEADERS = {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Resource-Policy': 'same-origin'
};
const MIME_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.svg': 'image/svg+xml',
'.md': 'text/markdown',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf'
};
const IMMUTABLE_ROUTES = /^\/(r160\.js|assets\/fonts\/)/;
const ALLOWED_SECTOR_FIELDS = new Set(['ambient', 'fog', 'fogColor', 'groundColor']);
const COLOR_FIELDS = new Set(['fogColor', 'groundColor']);
const ALLOWED_LIGHT_FIELDS = new Set(['lightIntensity', 'lightColor', 'lightRange', 'shadowsEnabled', 'shadowRadius', 'rectLightIntensity']);
const LIGHT_COLOR_FIELDS = new Set(['lightColor']);
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const toHexLiteral = (n) => '0x' + (Number(n) >>> 0).toString(16).padStart(6, '0');
function assertBraceBalance(original, patched, label) {
const count = (s, ch) => (s.split(ch).length - 1);
if (count(original, '{') !== count(patched, '{') || count(original, '}') !== count(patched, '}')) {
throw new Error(`Refusing to write ${label}: brace count changed unexpectedly`);
}
}
function patchSectorBlock(text, sectorName, fields, allowedFields = ALLOWED_SECTOR_FIELDS, colorFields = COLOR_FIELDS) {
if (!/^[A-Z0-9_]+$/.test(sectorName)) {
throw new Error(`Invalid sector name "${sectorName}"`);
}
const startRe = new RegExp(`\\b${escapeRe(sectorName)}\\s*:\\s*\\{`);
const m = startRe.exec(text);
if (!m) throw new Error(`Sector "${sectorName}" not found in Sectors.js`);
const blockOpenIdx = m.index + m[0].length - 1;
let depth = 0;
let endIdx = -1;
for (let i = blockOpenIdx; i < text.length; i++) {
if (text[i] === '{') depth++;
else if (text[i] === '}') {
depth--;
if (depth === 0) { endIdx = i; break; }
}
}
if (endIdx === -1) throw new Error(`Unbalanced braces scanning sector "${sectorName}"`);
let block = text.slice(blockOpenIdx, endIdx + 1);
const indentMatch = block.match(/\n(\s+)\S/);
const indent = indentMatch ? indentMatch[1] : ' ';
for (const [key, value] of Object.entries(fields)) {
if (!allowedFields.has(key)) continue;
const valueText = colorFields.has(key) ? toHexLiteral(value) : String(value);
const fieldRe = new RegExp(`\\b${key}\\s*:\\s*[^,}\\n]+`);
if (fieldRe.test(block)) {
block = block.replace(fieldRe, `${key}: ${valueText}`);
} else {
block = block.replace('{', `{\n${indent}${key}: ${valueText},`);
}
}
return text.slice(0, blockOpenIdx) + block + text.slice(endIdx + 1);
}
function patchSkyColor(text, newHex) {
const re = /(new THREE\.HemisphereLight\(\s*)0x[0-9a-fA-F]{6}(\s*,)/;
if (!re.test(text)) throw new Error('Could not find HemisphereLight sky color literal in RenderEngine.js');
return text.replace(re, `$1${newHex}$2`);
}
function writePatched(filePath, patchFn, label) {
const original = fs.readFileSync(filePath, 'utf-8');
const patched = patchFn(original);
assertBraceBalance(original, patched, label);
fs.writeFileSync(filePath, patched);
}
function saveAtmosphere(data) {
const results = [];
const sectorsPath = path.join(__dirname, 'src', 'world', 'Sectors.js');
const enginePath = path.join(__dirname, 'src', 'core', 'RenderEngine.js');
if (data.sector && data.sectorFields && Object.keys(data.sectorFields).length) {
writePatched(sectorsPath, (text) => patchSectorBlock(text, data.sector, data.sectorFields), 'Sectors.js (sector block)');
results.push(`Sectors.js: SECTORS.${data.sector} updated (${Object.keys(data.sectorFields).join(', ')})`);
}
if (data.baseFields && data.baseFields.skyColor !== undefined) {
const hex = toHexLiteral(data.baseFields.skyColor);
writePatched(enginePath, (text) => patchSkyColor(text, hex), 'RenderEngine.js (sky color)');
results.push(`RenderEngine.js: sky color -> ${hex}`);
}
return results;
}
function saveLight(data) {
const results = [];
const sectorsPath = path.join(__dirname, 'src', 'world', 'Sectors.js');
if (data.sector && data.sectorFields && Object.keys(data.sectorFields).length) {
writePatched(
sectorsPath,
(text) => patchSectorBlock(text, data.sector, data.sectorFields, ALLOWED_LIGHT_FIELDS, LIGHT_COLOR_FIELDS),
'Sectors.js (sector block)'
);
results.push(`Sectors.js: SECTORS.${data.sector} updated (${Object.keys(data.sectorFields).join(', ')})`);
}
return results;
}
const cacheControlFor = (route) =>
IMMUTABLE_ROUTES.test(route) ? 'public, max-age=31536000, immutable' : 'no-cache';
const etagFor = (stats) => `W/"${stats.size.toString(16)}-${stats.mtimeMs.toString(16)}"`;
const server = http.createServer((req, res) => {
const route = req.url.split('?')[0];
if (req.method === 'POST' && route === '/export-meta') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const filePath = path.join(__dirname, 'assets', 'textures', 'metadata.json');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, body);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end(err.toString());
}
});
return;
}
if (req.method === 'POST' && route === '/export') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = JSON.parse(body);
const mimeMatch = /^data:image\/(\w+);base64,/.exec(data.image);
const ext = mimeMatch ? mimeMatch[1] : 'png';
const base64Data = data.image.replace(/^data:image\/\w+;base64,/, "");
const buf = Buffer.from(base64Data, 'base64');
const base = path.resolve(__dirname, 'assets', 'textures');
const filePath = path.resolve(base, `${data.name}.${ext}`);
const relative = path.relative(base, filePath);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Invalid file path');
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, buf);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end(err.toString());
}
});
return;
}
if (req.method === 'POST' && route === '/save-atmosphere') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = JSON.parse(body);
const results = saveAtmosphere(data);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(results.length ? results.join('\n') : 'No changes to save.');
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end(err.toString());
}
});
return;
}
if (req.method === 'POST' && route === '/save-light') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = JSON.parse(body);
const results = saveLight(data);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(results.length ? results.join('\n') : 'No changes to save.');
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end(err.toString());
}
});
return;
}
let filePath = path.join(__dirname, route === '/' ? 'index.html' : route);
if (filePath !== __dirname && !filePath.startsWith(__dirname + path.sep)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
return res.end('403 Forbidden');
}
try {
const checkStat = fs.statSync(filePath);
if (checkStat.isDirectory()) {
if (!route.endsWith('/')) {
res.writeHead(301, { 'Location': route + '/' });
return res.end();
}
filePath = path.join(filePath, 'index.html');
}
} catch (_) {
// File does not exist, fs.stat below will handle 404
}
const extname = String(path.extname(filePath)).toLowerCase();
const contentType = MIME_TYPES[extname] || 'application/octet-stream';
fs.stat(filePath, (statErr, stats) => {
if (statErr || !stats.isFile()) {
if (!statErr || statErr.code === 'ENOENT' || statErr.code === 'ENOTDIR') {
res.writeHead(404, { 'Content-Type': 'text/html' });
return res.end(`<h1>404 Not Found</h1><p>The file ${route} was not found on this server.</p>`, 'utf-8');
}
res.writeHead(500);
return res.end(`Sorry, check with the site admin for error: ${statErr.code} ..\n`);
}
const etag = etagFor(stats);
const cacheControl = cacheControlFor(route);
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, {...ISOLATION_HEADERS, 'ETag': etag, 'Cache-Control': cacheControl});
return res.end();
}
fs.readFile(filePath, (error, content) => {
if (error) {
res.writeHead(500);
res.end(`Sorry, check with the site admin for error: ${error.code} ..\n`);
return;
}
let output = content;
if (route === '/engine.html' && contentType === 'text/html') {
try {
const srcDir = path.join(__dirname, 'src');
const getFiles = (dir) => {
const dirents = fs.readdirSync(dir, { withFileTypes: true });
const files = dirents.map((dirent) => {
const res = path.resolve(dir, dirent.name);
return dirent.isDirectory() ? getFiles(res) : res;
});
return Array.prototype.concat(...files);
};
const jsFiles = getFiles(srcDir).filter(f => f.endsWith('.js'));
const links = jsFiles.map(f => `<link rel="modulepreload" href="src${f.replace(srcDir, '').replace(/\\/g, '/')}">`).join('\n ');
let html = output.toString('utf-8');
html = html.replace('<!-- DYNAMIC_PRELOADS -->', links);
output = Buffer.from(html, 'utf-8');
} catch (e) {
console.error('Failed to generate dynamic preloads', e);
}
}
res.writeHead(200, {
...ISOLATION_HEADERS,
'Content-Type': contentType,
'Cache-Control': cacheControl,
'ETag': etag,
'Last-Modified': stats.mtime.toUTCString(),
'Content-Length': Buffer.byteLength(output)
});
res.end(output);
});
});
});
server.listen(PORT, () => {
console.log(`Level 0 Engine running at http://localhost:${PORT}`);
});