holosplat 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +890 -0
- package/bin/holosplat.cjs +374 -0
- package/dist/holosplat.esm.js +766 -0
- package/dist/holosplat.esm.js.map +7 -0
- package/dist/holosplat.iife.js +766 -0
- package/dist/holosplat.iife.js.map +7 -0
- package/holosplat/editor.js +2947 -0
- package/holosplat/index.html +614 -0
- package/holosplat/stats.js +101 -0
- package/package.json +30 -0
- package/server.py +560 -0
- package/src/server.js +198 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HoloSplat API middleware for Node.js servers.
|
|
3
|
+
*
|
|
4
|
+
* Mounts three routes that the /holosplat editor uses to read and write
|
|
5
|
+
* project files. Plug it into any Connect-compatible server at /hs-api.
|
|
6
|
+
*
|
|
7
|
+
* ── Express / Connect ────────────────────────────────────────────────────────
|
|
8
|
+
* import { createHsApiHandler } from 'holosplat/server';
|
|
9
|
+
* app.use('/hs-api', createHsApiHandler());
|
|
10
|
+
*
|
|
11
|
+
* ── Vite (vite.config.js) ────────────────────────────────────────────────────
|
|
12
|
+
* import { createHsApiHandler } from 'holosplat/server';
|
|
13
|
+
* export default defineConfig({
|
|
14
|
+
* plugins: [{
|
|
15
|
+
* name: 'holosplat',
|
|
16
|
+
* configureServer(server) {
|
|
17
|
+
* server.middlewares.use('/hs-api', createHsApiHandler());
|
|
18
|
+
* },
|
|
19
|
+
* }],
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* ── Next.js pages router (pages/api/hs-api/[...route].js) ───────────────────
|
|
23
|
+
* import { createHsApiHandler } from 'holosplat/server';
|
|
24
|
+
* const handler = createHsApiHandler();
|
|
25
|
+
* export default function hsApi(req, res) {
|
|
26
|
+
* req.url = '/' + (req.query.route ?? []).join('/') +
|
|
27
|
+
* (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
|
|
28
|
+
* handler(req, res);
|
|
29
|
+
* }
|
|
30
|
+
* export const config = { api: { bodyParser: false } };
|
|
31
|
+
*
|
|
32
|
+
* ── Routes served ─────────────────────────────────────────────────────────────
|
|
33
|
+
* GET /hs-api/ls List .spz/.ply/.splat and .json files
|
|
34
|
+
* GET /hs-api/file?path=<rel> Read a file (relative to project root)
|
|
35
|
+
* PUT /hs-api/file?path=<rel> Write a file
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'fs';
|
|
39
|
+
import path from 'path';
|
|
40
|
+
|
|
41
|
+
const SCENE_EXTS = new Set(['.spz', '.splat', '.ply', '.spzv']);
|
|
42
|
+
const SKIP_JSON = new Set(['package.json', 'package-lock.json', 'tsconfig.json',
|
|
43
|
+
'tsconfig.node.json', 'jsconfig.json']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} [root=process.cwd()] Project root — all paths resolved relative to this.
|
|
47
|
+
* @returns {(req, res, next?) => void} Connect/Express-compatible middleware.
|
|
48
|
+
*/
|
|
49
|
+
export function createHsApiHandler(root = process.cwd()) {
|
|
50
|
+
|
|
51
|
+
// ── Path safety ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function safePath(rel) {
|
|
54
|
+
if (!rel) return null;
|
|
55
|
+
const full = path.resolve(root, rel);
|
|
56
|
+
const relBack = path.relative(root, full);
|
|
57
|
+
// Reject traversal outside root or attempts to read root itself
|
|
58
|
+
if (relBack.startsWith('..') || path.isAbsolute(relBack)) return null;
|
|
59
|
+
if (full === root) return null;
|
|
60
|
+
return full;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function sendJson(res, status, data) {
|
|
66
|
+
const body = JSON.stringify(data);
|
|
67
|
+
res.writeHead(status, {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'Content-Length': Buffer.byteLength(body),
|
|
70
|
+
});
|
|
71
|
+
res.end(body);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Recursive — scene assets live in subfolders (e.g. scenes/headphones/).
|
|
75
|
+
function scanDir(dir, prefix, out) {
|
|
76
|
+
if (!fs.existsSync(dir)) return;
|
|
77
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
78
|
+
const full = path.join(dir, f);
|
|
79
|
+
const stat = fs.statSync(full);
|
|
80
|
+
const rel = prefix ? `${prefix}/${f}` : f;
|
|
81
|
+
if (stat.isDirectory()) { scanDir(full, rel, out); continue; }
|
|
82
|
+
if (!stat.isFile()) continue;
|
|
83
|
+
const ext = path.extname(f).toLowerCase();
|
|
84
|
+
if (SCENE_EXTS.has(ext)) out.spz.push(rel);
|
|
85
|
+
else if (ext === '.json') out.json.push(rel);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Handler ──────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
return function hsApiHandler(req, res, next) {
|
|
92
|
+
const url = new URL(req.url, 'http://x');
|
|
93
|
+
const route = url.pathname;
|
|
94
|
+
const params = Object.fromEntries(url.searchParams);
|
|
95
|
+
|
|
96
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
97
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, OPTIONS');
|
|
98
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
99
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
100
|
+
|
|
101
|
+
if (req.method === 'OPTIONS') {
|
|
102
|
+
res.writeHead(204); return res.end();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// GET /ls
|
|
106
|
+
if (route === '/ls' && req.method === 'GET') {
|
|
107
|
+
const result = { spz: [], json: [] };
|
|
108
|
+
// Common static asset locations across different project setups
|
|
109
|
+
scanDir(path.join(root, 'public', 'scenes'), 'public/scenes', result);
|
|
110
|
+
scanDir(path.join(root, 'public'), 'public', result);
|
|
111
|
+
scanDir(path.join(root, 'scenes'), 'scenes', result);
|
|
112
|
+
scanDir(path.join(root, 'blender'), 'blender', result);
|
|
113
|
+
// Root-level JSON files (hs-config, blender exports, etc.)
|
|
114
|
+
if (fs.existsSync(root)) {
|
|
115
|
+
for (const f of fs.readdirSync(root).sort()) {
|
|
116
|
+
if (!f.endsWith('.json') || SKIP_JSON.has(f)) continue;
|
|
117
|
+
if (fs.statSync(path.join(root, f)).isFile()) result.json.push(f);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
result.spz.sort();
|
|
121
|
+
result.json.sort();
|
|
122
|
+
return sendJson(res, 200, result);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// GET /file?path=...
|
|
126
|
+
if (route === '/file' && req.method === 'GET') {
|
|
127
|
+
const full = safePath(params.path);
|
|
128
|
+
if (!full || !fs.existsSync(full)) return sendJson(res, 404, { error: 'not found' });
|
|
129
|
+
const body = fs.readFileSync(full);
|
|
130
|
+
const ext = path.extname(full).toLowerCase();
|
|
131
|
+
const mime = ext === '.html' || ext === '.htm' ? 'text/html'
|
|
132
|
+
: ext === '.json' ? 'application/json'
|
|
133
|
+
: 'application/octet-stream';
|
|
134
|
+
res.writeHead(200, { 'Content-Type': mime, 'Content-Length': body.length });
|
|
135
|
+
return res.end(body);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// PUT /file?path=...
|
|
139
|
+
if (route === '/file' && req.method === 'PUT') {
|
|
140
|
+
const full = safePath(params.path);
|
|
141
|
+
if (!full) return sendJson(res, 403, { error: 'forbidden' });
|
|
142
|
+
const chunks = [];
|
|
143
|
+
req.on('data', c => chunks.push(c));
|
|
144
|
+
req.on('end', () => {
|
|
145
|
+
try {
|
|
146
|
+
const buf = Buffer.concat(chunks);
|
|
147
|
+
const dir = path.dirname(full);
|
|
148
|
+
if (dir && dir !== root) fs.mkdirSync(dir, { recursive: true });
|
|
149
|
+
fs.writeFileSync(full, buf);
|
|
150
|
+
sendJson(res, 200, { ok: true });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
sendJson(res, 500, { error: e.message });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// POST /html-attr — patch data-hs-scene / data-hs attributes in an HTML source file
|
|
159
|
+
if (route === '/html-attr' && req.method === 'POST') {
|
|
160
|
+
const chunks = [];
|
|
161
|
+
req.on('data', c => chunks.push(c));
|
|
162
|
+
req.on('end', () => {
|
|
163
|
+
try {
|
|
164
|
+
const { page, id, attrs } = JSON.parse(Buffer.concat(chunks).toString());
|
|
165
|
+
if (!page || !id || !attrs) return sendJson(res, 400, { error: 'missing fields' });
|
|
166
|
+
|
|
167
|
+
// Resolve page path — strip leading slash and query string
|
|
168
|
+
const rel = page.replace(/^\//, '').split('?')[0];
|
|
169
|
+
const full = safePath(rel);
|
|
170
|
+
if (!full || !fs.existsSync(full)) return sendJson(res, 404, { error: 'file not found' });
|
|
171
|
+
|
|
172
|
+
let html = fs.readFileSync(full, 'utf8');
|
|
173
|
+
// Find the opening tag that contains id="<id>"
|
|
174
|
+
const idPat = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
175
|
+
const tagRx = new RegExp(`(<[a-zA-Z][^>]*?\\s(?:id="${idPat}")[^>]*?)(?=\\s*>)`, 's');
|
|
176
|
+
const match = html.match(tagRx);
|
|
177
|
+
if (!match) return sendJson(res, 404, { error: `element #${id} not found in ${rel}` });
|
|
178
|
+
|
|
179
|
+
let tag = match[1];
|
|
180
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
181
|
+
const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
182
|
+
tag = tag.replace(new RegExp(`\\s+${n}(?:="[^"]*"|='[^']*')?`), '');
|
|
183
|
+
if (value != null) tag += ` ${name}="${String(value).replace(/"/g, '"')}"`;
|
|
184
|
+
}
|
|
185
|
+
html = html.slice(0, match.index) + tag + html.slice(match.index + match[0].length);
|
|
186
|
+
fs.writeFileSync(full, html, 'utf8');
|
|
187
|
+
sendJson(res, 200, { ok: true });
|
|
188
|
+
} catch (e) {
|
|
189
|
+
sendJson(res, 500, { error: e.message });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (typeof next === 'function') next();
|
|
196
|
+
else { res.writeHead(404); res.end('Not found'); }
|
|
197
|
+
};
|
|
198
|
+
}
|