Repository navigation
Expand file tree
/
Copy pathdevserver.mjs
More file actions
137 lines (127 loc) · 4.68 KB
/
Copy pathdevserver.mjs
File metadata and controls
137 lines (127 loc) · 4.68 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
#!/usr/bin/env node
// Tiny dev server for Table Cube. No dependencies, just Node 18+.
//
// node devserver.mjs (port 8000)
// node devserver.mjs 8080 (other port)
//
// It serves this folder and receives log lines from the headset at POST /log.
// Every line is printed here and appended to xr-log.txt, so the log survives
// the headset browser crashing.
//
// Magic Leap 2 over USB: adb reverse tcp:8000 tcp:8000
// then open http://localhost:8000 in the Magic Leap browser.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.argv.slice(2).find((a) => /^\d+$/.test(a)) || process.env.PORT || 8000);
const LOG_FILE = path.join(ROOT, 'xr-log.txt');
// --allow-save enables POST /save-asset (used by tools/export-models.html). Only use it on a
// port that is NOT forwarded through a public tunnel: it lets the page write files.
const ALLOW_SAVE = process.argv.includes('--allow-save');
const MODELS_DIR = path.join(ROOT, 'assets', 'models');
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.glb': 'model/gltf-binary',
'.gltf': 'model/gltf+json',
'.bin': 'application/octet-stream',
};
const COLOURS = { info: '\x1b[37m', warn: '\x1b[33m', error: '\x1b[31;1m' };
const RESET = '\x1b[0m';
function writeLog(text) {
try { fs.appendFileSync(LOG_FILE, text + '\n'); } catch { /* ignore */ }
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/log/ping') {
res.writeHead(200, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
res.end('tablecube-log-server');
return;
}
if (url.pathname === '/log' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 1_000_000) req.destroy();
});
req.on('end', () => {
try {
for (const l of JSON.parse(body)) {
const text = `${l.t} #${l.n ?? '?'} [${l.level}] ${l.text}`;
console.log((COLOURS[l.level] || '') + text + RESET);
writeLog(text);
}
} catch (err) {
console.warn('Could not read log body:', err.message);
}
res.writeHead(204);
res.end();
});
return;
}
if (url.pathname === '/save-asset' && req.method === 'POST') {
const name = url.searchParams.get('name') || '';
if (!ALLOW_SAVE || !/^[a-z0-9-]+\.glb$/.test(name)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end(ALLOW_SAVE ? 'Bad name (use lowercase-name.glb)' : 'Saving is off (start with --allow-save)');
return;
}
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > 50_000_000) req.destroy();
else chunks.push(c);
});
req.on('end', () => {
fs.mkdirSync(MODELS_DIR, { recursive: true });
fs.writeFileSync(path.join(MODELS_DIR, name), Buffer.concat(chunks));
console.log(`Saved assets/models/${name} (${size} bytes)`);
res.writeHead(204);
res.end();
});
return;
}
// Static files from this folder.
let rel = decodeURIComponent(url.pathname);
if (rel.endsWith('/')) rel += 'index.html';
const file = path.normalize(path.join(ROOT, rel));
if (!file.startsWith(ROOT)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
return;
}
res.writeHead(200, {
'Content-Type': TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream',
'Cache-Control': 'no-store', // always load the newest files while developing
});
res.end(data);
});
});
server.listen(PORT, () => {
const stamp = new Date().toISOString();
writeLog(`\n===== devserver started ${stamp} =====`);
console.log(`Serving ${ROOT}`);
console.log(` On this laptop: http://localhost:${PORT}`);
console.log(` Magic Leap over USB: adb reverse tcp:${PORT} tcp:${PORT}`);
console.log(` then open http://localhost:${PORT} on the headset`);
if (ALLOW_SAVE) console.log(`Model export enabled: open http://localhost:${PORT}/tools/export-models.html`);
console.log(`Headset log lines appear below and in ${LOG_FILE}\n`);
});