-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathvite.config.ts
More file actions
537 lines (489 loc) · 20.9 KB
/
Copy pathvite.config.ts
File metadata and controls
537 lines (489 loc) · 20.9 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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2025 realvirtual GmbH <https://realvirtual.io>
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
// PWA disabled – always serve fresh content, no service worker caching
// import { VitePWA } from 'vite-plugin-pwa';
import { playwright } from '@vitest/browser-playwright';
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, createReadStream, statSync } from 'node:fs';
import { join, resolve, dirname, extname } from 'node:path';
// ─── Private content detection ──────────────────────────────────────────
const PRIVATE_DIR = resolve(__dirname, '../realvirtual-WebViewer-Private~/src');
const HAS_PRIVATE = existsSync(PRIVATE_DIR) && !process.env.VITE_PUBLIC_BUILD;
console.log(`[rv-build] ${HAS_PRIVATE ? 'Private' : 'Public'} build${process.env.VITE_PUBLIC_BUILD ? ' (forced public via VITE_PUBLIC_BUILD)' : ''}`);
import { exec } from 'node:child_process';
/** Vite plugin: exposes /__api/tests endpoints so the app can discover and run vitest tests */
function testRunnerPlugin() {
return {
name: 'rv-test-runner',
apply: 'serve' as const,
configureServer(server: { config: { root: string }; middlewares: { use: Function } }) {
server.middlewares.use((req: { url?: string; method?: string }, res: any, next: Function) => {
if (req.url === '/__api/tests') {
const testsDir = join(server.config.root, 'tests');
let files: string[] = [];
if (existsSync(testsDir)) {
files = readdirSync(testsDir)
.filter((f: string) => f.endsWith('.test.ts') || f.endsWith('.test.tsx'))
.map((f: string) => `tests/${f}`);
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ files }));
return;
}
if (req.url === '/__api/tests/run' && req.method === 'POST') {
res.setHeader('Content-Type', 'application/json');
exec('npx vitest run --reporter=json', {
cwd: server.config.root,
maxBuffer: 10 * 1024 * 1024,
timeout: 180000,
}, (_err: unknown, stdout: string) => {
try {
const jsonStart = stdout.indexOf('{');
const jsonEnd = stdout.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
const json = JSON.parse(stdout.substring(jsonStart, jsonEnd + 1));
res.end(JSON.stringify(json));
} else {
res.end(JSON.stringify({ error: 'No JSON output from vitest' }));
}
} catch (e) {
res.end(JSON.stringify({ error: String(e) }));
}
});
return;
}
next();
});
},
};
}
/** Vite plugin: debug API — bidirectional bridge between browser and Claude Code.
*
* READ: Browser pushes state snapshots via POST, Claude Code reads via GET.
* WRITE: Claude Code pushes commands via POST, browser polls and executes them.
* Also buffers errors and signal changelogs pushed from the browser.
*/
function debugApiPlugin() {
let latestSnapshot = '{"status":"no data yet"}';
let cmdIdCounter = 0;
const cmdQueue: { id: number; cmd: string; [k: string]: unknown }[] = [];
const cmdResults: { id: number; success: boolean; error?: string }[] = [];
function readBody(req: { on: Function }): Promise<string> {
return new Promise((resolve) => {
let body = '';
req.on('data', (chunk: string) => { body += chunk; });
req.on('end', () => resolve(body));
});
}
function json(res: any, data: unknown, status = 200) {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.writeHead(status);
res.end(JSON.stringify(data));
}
return {
name: 'rv-debug-api',
apply: 'serve' as const,
configureServer(server: { middlewares: { use: Function } }) {
server.middlewares.use(async (req: { url?: string; method?: string; on: Function }, res: any, next: Function) => {
const url = req.url ?? '';
// ── Snapshot push/read ──
if (url === '/__api/debug/snapshot' && req.method === 'POST') {
latestSnapshot = await readBody(req);
res.writeHead(200); res.end('ok');
return;
}
// ── Command queue: Claude Code → Browser ──
// POST /__api/debug/cmd — Claude Code pushes a command
if (url === '/__api/debug/cmd' && req.method === 'POST') {
const body = JSON.parse(await readBody(req));
const id = ++cmdIdCounter;
cmdQueue.push({ id, ...body });
json(res, { queued: true, id });
return;
}
// GET /__api/debug/cmd/poll — Browser polls for pending commands
if (url === '/__api/debug/cmd/poll' && req.method === 'GET') {
const commands = cmdQueue.splice(0);
json(res, { commands });
return;
}
// POST /__api/debug/cmd/result — Browser posts execution result
if (url === '/__api/debug/cmd/result' && req.method === 'POST') {
const result = JSON.parse(await readBody(req));
cmdResults.push(result);
if (cmdResults.length > 100) cmdResults.splice(0, cmdResults.length - 100);
res.writeHead(200); res.end('ok');
return;
}
// GET /__api/debug/cmd/results — Claude Code reads results
if (url === '/__api/debug/cmd/results' && req.method === 'GET') {
const results = cmdResults.splice(0);
json(res, { results });
return;
}
// ── GET /__api/debug[/sub] — serve snapshot or sub-route ──
if (url.startsWith('/__api/debug') && req.method === 'GET') {
const fullRoute = url.replace('/__api/debug', '') || '/';
// Split route from query string
const qIdx = fullRoute.indexOf('?');
const route = qIdx >= 0 ? fullRoute.slice(0, qIdx) : fullRoute;
const query = qIdx >= 0 ? new URLSearchParams(fullRoute.slice(qIdx)) : null;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
if (route === '/' || route === '/snapshot') {
res.end(latestSnapshot);
return;
}
try {
const data = JSON.parse(latestSnapshot);
const sub = route.slice(1); // strip leading '/'
// Signal watch: /__api/debug/signals?names=A,B,C
if (sub === 'signals' && query?.get('names')) {
const names = query.get('names')!.split(',');
const filtered: Record<string, unknown> = {};
for (const n of names) {
if (n in (data.signals ?? {})) filtered[n] = data.signals[n];
}
json(res, filtered);
return;
}
// Log buffer: /__api/debug/logs?level=warn&category=signal&limit=20
if (sub === 'logs') {
const LEVELS = ['trace', 'debug', 'info', 'warn', 'error'];
let logs: unknown[] = data.logs ?? [];
const level = query?.get('level');
const category = query?.get('category');
const limit = query?.get('limit');
if (level) {
const minIdx = LEVELS.indexOf(level);
if (minIdx >= 0) logs = logs.filter((e: any) => LEVELS.indexOf(e.level) >= minIdx);
}
if (category) logs = logs.filter((e: any) => e.category === category);
if (limit) logs = logs.slice(-parseInt(limit, 10));
json(res, logs);
return;
}
if (sub in data) {
json(res, data[sub]);
return;
}
} catch { /* snapshot not valid JSON yet */ }
json(res, { error: 'unknown route' }, 404);
return;
}
next();
});
},
};
}
/**
* Vite plugin: Save library thumbnails to disk.
* POST /api/library-thumbnail with { catalogId, dataUrl }
* Writes PNG next to the GLB in public/models/library/.
*/
function thumbnailSavePlugin() {
function readBody(req: { on: Function }): Promise<string> {
return new Promise((resolve) => {
let body = '';
req.on('data', (chunk: string) => { body += chunk; });
req.on('end', () => resolve(body));
});
}
return {
name: 'rv-thumbnail-save',
apply: 'serve' as const,
configureServer(server: { config: { root: string }; middlewares: { use: Function } }) {
server.middlewares.use(async (req: { url?: string; method?: string; on: Function }, res: any, next: Function) => {
if (req.url !== '/api/library-thumbnail' || req.method !== 'POST') return next();
try {
const body = JSON.parse(await readBody(req));
const { catalogId, dataUrl } = body as { catalogId: string; dataUrl: string };
if (!catalogId || !dataUrl) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Missing catalogId or dataUrl' }));
return;
}
// Convert data URL to buffer
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
const buffer = Buffer.from(base64, 'base64');
// Save next to GLB: use catalogId as filename stem
const filename = catalogId.replace(/[^a-zA-Z0-9_-]/g, '_') + '.png';
const outDir = join(server.config.root, 'public/models/library');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, filename);
writeFileSync(outPath, buffer);
const url = `models/library/${filename}`;
console.log(`[rv-thumbnail] Saved ${outPath}`);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ url }));
} catch (e) {
res.writeHead(500);
res.end(JSON.stringify({ error: String(e) }));
}
});
},
};
}
// ─── Private project directory (contains project subfolders with models/) ────
const PRIVATE_PROJECTS_DIR = resolve(__dirname, '../realvirtual-WebViewer-Private~/projects');
/** MIME types for static assets served from private projects. */
const PRIVATE_ASSET_MIME: Record<string, string> = {
'.glb': 'model/gltf-binary',
'.aasx': 'application/asset-administration-shell-package',
'.pdf': 'application/pdf',
'.json': 'application/json',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
};
/**
* Vite plugin: Discover and serve GLB models + AASX/PDF assets from private project folders.
*
* Scans `realvirtual-WebViewer-Private~/projects/<name>/` for:
* - `models/*.glb` → served under `/private-models/<project>/`
* - `aasx/*.aasx` → served under `/private-assets/<project>/aasx/`
* - `pdf/*.pdf` → served under `/private-assets/<project>/pdf/`
*
* Also exposes:
* - `GET /__api/private-models` — JSON manifest of all GLB models
* - `GET /private-assets/<project>/aasx/index.json` — auto-generated AASX index
*/
function privateModelsPlugin() {
if (!HAS_PRIVATE || !existsSync(PRIVATE_PROJECTS_DIR)) return null;
// Build manifest: scan all project subdirs for GLB files
function buildManifest(): Array<{ project: string; filename: string; url: string }> {
const entries: Array<{ project: string; filename: string; url: string }> = [];
try {
for (const project of readdirSync(PRIVATE_PROJECTS_DIR, { withFileTypes: true })) {
if (!project.isDirectory()) continue;
const modelsDir = join(PRIVATE_PROJECTS_DIR, project.name, 'models');
if (!existsSync(modelsDir)) continue;
for (const file of readdirSync(modelsDir)) {
if (!file.toLowerCase().endsWith('.glb')) continue;
entries.push({
project: project.name,
filename: file,
url: `/private-models/${project.name}/${file}`,
});
}
}
} catch { /* ignore scan errors */ }
return entries;
}
/** List files in a private project subfolder. */
function listProjectFiles(project: string, subfolder: string, ext: string): string[] {
const dir = join(PRIVATE_PROJECTS_DIR, project, subfolder);
if (!existsSync(dir)) return [];
try {
return readdirSync(dir).filter(f => f.toLowerCase().endsWith(ext));
} catch { return []; }
}
/** Serve a static file from a private project subfolder with correct MIME type. */
function serveProjectFile(res: any, project: string, subfolder: string, filename: string): boolean {
const filePath = join(PRIVATE_PROJECTS_DIR, project, subfolder, filename);
if (!existsSync(filePath)) return false;
const ext = extname(filename).toLowerCase();
const mime = PRIVATE_ASSET_MIME[ext] ?? 'application/octet-stream';
const stat = statSync(filePath);
res.setHeader('Content-Type', mime);
res.setHeader('Content-Length', stat.size);
res.setHeader('Cache-Control', 'no-store');
createReadStream(filePath).pipe(res);
return true;
}
return {
name: 'rv-private-models',
apply: 'serve' as const,
configureServer(server: { middlewares: { use: Function } }) {
server.middlewares.use((req: { url?: string; method?: string }, res: any, next: Function) => {
const url = req.url ?? '';
// Manifest endpoint
if (url === '/__api/private-models' && req.method === 'GET') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(buildManifest()));
return;
}
// Serve GLB files under /private-models/<project>/<file>.glb
if (url.startsWith('/private-models/') && url.endsWith('.glb')) {
const parts = url.replace('/private-models/', '').split('/');
if (parts.length === 2) {
const [project, file] = parts;
if (serveProjectFile(res, project, 'models', file)) return;
}
res.writeHead(404);
res.end('Not found');
return;
}
// Serve private assets: /private-assets/<project>/<path...>
// Supports arbitrary depth paths (e.g., docs/subfolder/subfolder/file.pdf)
// as well as flat paths (e.g., docs-index.json, aasx/index.json)
if (url.startsWith('/private-assets/')) {
const decoded = decodeURIComponent(url);
const stripped = decoded.replace('/private-assets/', '');
const slashIdx = stripped.indexOf('/');
if (slashIdx > 0) {
const project = stripped.substring(0, slashIdx);
const assetPath = stripped.substring(slashIdx + 1);
// Auto-generate AASX index.json on the fly
if (assetPath === 'aasx/index.json') {
const aasxFiles = listProjectFiles(project, 'aasx', '.aasx');
const index: Record<string, { file: string; idShort: string }> = {};
for (const f of aasxFiles) {
index[f.replace('.aasx', '')] = { file: f, idShort: f.replace('.aasx', '') };
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(index, null, 2));
return;
}
// Serve any file from the project directory
const filePath = join(PRIVATE_PROJECTS_DIR, project, assetPath);
if (existsSync(filePath)) {
try {
const fstat = statSync(filePath);
if (fstat.isFile()) {
const ext = extname(filePath).toLowerCase();
const mime = PRIVATE_ASSET_MIME[ext] ?? 'application/octet-stream';
res.setHeader('Content-Type', mime);
res.setHeader('Content-Length', fstat.size);
res.setHeader('Cache-Control', 'no-store');
createReadStream(filePath).pipe(res);
return;
}
} catch { /* fall through to 404 */ }
}
}
res.writeHead(404);
res.end('Not found');
return;
}
next();
});
},
};
}
/**
* Vite plugin: Resolve bare imports from private folder files via the main project's node_modules.
*
* When HAS_PRIVATE is true, files in realvirtual-WebViewer-Private~/src/ may import npm packages
* (react, @mui/icons-material, etc.). Rollup resolves node_modules by walking up from the
* importing file's directory, which fails because the private folder has no node_modules.
* This plugin intercepts unresolved bare imports from the private folder and resolves them
* from the main project's node_modules instead.
*/
function privateResolverPlugin() {
// Always activate when the private folder exists, even in public builds.
// import.meta.glob discovers private project files on disk regardless of
// VITE_PUBLIC_BUILD, so Rollup still needs to resolve their bare imports.
if (!existsSync(PRIVATE_DIR)) return null;
// A virtual importer inside the main project so Vite/Rollup resolves
// bare npm imports using the main project's node_modules with proper ESM handling.
const mainImporter = resolve(__dirname, 'src/main.ts');
return {
name: 'rv-private-resolver',
enforce: 'pre' as const,
async resolveId(source: string, importer: string | undefined) {
// Only intercept bare imports from files in the private folder
if (!importer) return null;
const normalizedImporter = importer.replace(/\\/g, '/');
if (!normalizedImporter.includes('realvirtual-WebViewer-Private')) return null;
// Skip relative/absolute imports, virtual modules, and already-resolved paths
if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null;
if (/^[A-Za-z]:/.test(source)) return null; // Windows absolute paths like C:\...
// Re-resolve using Vite's own resolver as if the import came from the main project.
// This ensures ESM exports maps are respected (unlike createRequire which returns CJS paths).
const resolved = await this.resolve(source, mainImporter, { skipSelf: true });
return resolved;
},
};
}
export default defineConfig({
base: process.env.VITE_BASE || './',
plugins: [
privateResolverPlugin(),
privateModelsPlugin(),
react(),
// VitePWA disabled – no service worker, always fresh content
testRunnerPlugin(),
debugApiPlugin(),
thumbnailSavePlugin(),
].filter(Boolean),
resolve: {
dedupe: ['three'],
alias: {
'@rv': resolve(__dirname, 'src'),
'@rv-private': HAS_PRIVATE
? PRIVATE_DIR
: resolve(__dirname, 'src/private-stubs'),
'@rv-projects': HAS_PRIVATE
? resolve(__dirname, '../realvirtual-WebViewer-Private~/projects')
: resolve(__dirname, 'src/private-stubs/projects'),
// Explicit aliases for React JSX runtime — needed so that files imported from
// the private folder (outside the project root) resolve the JSX runtime from
// the main project's node_modules, not from the (non-existent) private node_modules.
...(HAS_PRIVATE ? {
'react/jsx-runtime': resolve(__dirname, 'node_modules/react/jsx-runtime.js'),
'react/jsx-dev-runtime': resolve(__dirname, 'node_modules/react/jsx-dev-runtime.js'),
} : {}),
},
},
define: {
__RV_HAS_PRIVATE__: JSON.stringify(HAS_PRIVATE),
__RV_COMMERCIAL__: JSON.stringify(!!process.env.RV_COMMERCIAL),
},
server: {
host: true,
open: true,
https: !!process.env.HTTPS,
// Allow Tailscale MagicDNS hostnames (*.ts.net) when testing via `tailscale serve`.
// Without this, Vite returns 403 due to DNS-rebinding protection on non-localhost Host headers.
allowedHosts: ['.ts.net'],
headers: {
'Cache-Control': 'no-store',
},
proxy: {
// Proxy Unity Asset Manager API to avoid CORS restrictions
'/unity-api': {
target: 'https://services.api.unity.com',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/unity-api/, ''),
secure: true,
},
},
watch: {
usePolling: true,
interval: 100,
},
},
build: {
target: 'esnext',
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
banner: '/* realvirtual WEB | AGPL-3.0-only | Copyright (C) 2025 realvirtual GmbH | https://realvirtual.io */',
manualChunks: {
three: ['three'],
echarts: ['echarts'],
'react-pdf': ['react-pdf', 'pdfjs-dist'],
'gaussian-splat': ['@mkkellogg/gaussian-splats-3d'],
},
},
},
},
test: {
include: ['tests/**/*.test.{ts,tsx}'],
exclude: ['tests/**/*.node.test.ts'],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
api: { port: 5177 },
},
},
});