-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvite.config.ts
More file actions
259 lines (248 loc) · 9.32 KB
/
Copy pathvite.config.ts
File metadata and controls
259 lines (248 loc) · 9.32 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
import fs from "node:fs";
import type { ServerResponse } from "node:http";
import path from "node:path";
import { defineConfig, type Plugin, type ViteDevServer } from "vite";
import { VitePWA } from "vite-plugin-pwa";
/** Handles a Range request and writes the partial response. Returns false if the header is malformed. */
function serveRangedFile(
res: ServerResponse,
filePath: string,
headers: Record<string, string | number>,
total: number,
rangeHeader: string,
): boolean {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (!match) {
return false;
}
const [, startStr, endStr] = match;
const start = parseInt(startStr ?? "0", 10);
const end = endStr ? parseInt(endStr, 10) : total - 1;
res.writeHead(206, {
...headers,
"Content-Range": `bytes ${start}-${end}/${total}`,
"Content-Length": end - start + 1,
});
fs.createReadStream(filePath, { start, end }).pipe(res);
return true;
}
/**
* Vite plugin that serves ./videos/ as /videos/ with proper Range-request
* support (required for video seeking) and copies the directory to dist on build.
*/
function serveVideos(): Plugin {
return {
name: "serve-videos",
configureServer(server: ViteDevServer): void {
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith("/videos/")) {
return next();
}
const filename = decodeURIComponent(req.url.slice("/videos/".length).split("?")[0] ?? "");
const videosDir = path.resolve("videos");
const filePath = path.resolve(videosDir, filename);
// Prevent directory traversal
if (!filePath.startsWith(videosDir + path.sep)) {
return next();
}
if (!fs.existsSync(filePath)) {
return next();
}
const stat = fs.statSync(filePath);
const total = stat.size;
const ext = path.extname(filename).toLowerCase();
const mimeType = ext === ".webm" ? "video/webm" : "video/mp4";
const headers: Record<string, string | number> = {
"Content-Type": mimeType,
"Accept-Ranges": "bytes",
"Cross-Origin-Resource-Policy": "same-origin",
};
const rangeHeader = req.headers.range;
if (rangeHeader && serveRangedFile(res as ServerResponse, filePath, headers, total, rangeHeader)) {
return;
}
res.writeHead(200, { ...headers, "Content-Length": total });
fs.createReadStream(filePath).pipe(res);
});
},
closeBundle(): void {
const src = path.resolve("videos");
if (!fs.existsSync(src)) {
return;
}
// cpSync handles subdirectories; the old flat loop did not
fs.cpSync(src, path.resolve("dist", "videos"), { recursive: true });
},
};
}
/**
* Vite plugin that serves the pre-built OpenCV.js directly from node_modules
* during development and copies it to dist/ on build.
*
* This avoids running the 11 MB Emscripten output through Rollup, which
* externalises Node-only imports (fs, path, crypto) and produces a
* content-hashed chunk that breaks across deployments when the browser or
* service worker caches the old index chunk but the server has new assets.
*/
function serveOpenCV(): Plugin {
const opencvSrc = path.resolve("node_modules/@techstark/opencv-js/dist/opencv.js");
return {
name: "serve-opencv",
configureServer(server: ViteDevServer): void {
server.middlewares.use((req, res, next) => {
if (req.url?.split("?")[0] !== "/opencv.js") {
return next();
}
const stat = fs.statSync(opencvSrc);
res.writeHead(200, {
"Content-Type": "application/javascript",
"Content-Length": stat.size,
"Cross-Origin-Resource-Policy": "same-origin",
});
fs.createReadStream(opencvSrc).pipe(res);
});
},
closeBundle(): void {
fs.copyFileSync(opencvSrc, path.resolve("dist", "opencv.js"));
},
};
}
/**
* Security headers required for:
* - COOP/COEP: SharedArrayBuffer (OpenCV WASM in the tracking worker)
* - CSP: restrict resource loading to same-origin + known blob/data exceptions
* - Referrer / Permissions: tighten default browser leakage
* - X-Content-Type-Options: prevent MIME sniffing
* - X-Frame-Options: prevent clickjacking (belt-and-suspenders alongside frame-ancestors)
*/
const securityHeaders: Record<string, string> = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Content-Security-Policy": [
"default-src 'self'",
// 'wasm-unsafe-eval' is required for the OpenCV WASM module
// TODO(scenerystack): drop 'unsafe-eval' when SceneryStack no longer needs
// Function/eval for query-parameter parsing — reopen a CSP audit then.
// 'unsafe-eval' is required for SceneryStack query parameter parsing
"script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'",
// OpenCV spins up blob: workers
"worker-src blob: 'self'",
// TODO(scenerystack): drop 'unsafe-inline' when SceneryStack stops setting
// element.style / cssText for theming (same CSP revisit as unsafe-eval).
// Inline styles are set via element.style / cssText throughout the UI layer
"style-src 'self' 'unsafe-inline'",
// blob: for video playback and CSV download; data: for icons
"img-src 'self' blob: data:",
// blob: for webcam recordings and loaded video files
"media-src 'self' blob:",
// blob: for fetch inside workers; 'self' for local video middleware
// data: required for @techstark/opencv-js which loads its WASM as a base64 data URI
"connect-src 'self' blob: data:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
].join("; "),
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(self), microphone=(self), geolocation=()",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
};
/** Workbox precache ceiling — SceneryStack bundles exceed the default 2 MB limit. */
const WORKBOX_MAX_FILE_BYTES = 12 * 1024 * 1024;
// https://vite.dev/config/
export default defineConfig({
// So the build can be served from an arbitrary path
base: "./",
build: {
// Requires Vite 8+ / esbuild ≥0.24. Run `npm ci` if build errors on ES2024.
target: "es2024",
// SceneryStack bundles exceed Vite's default 500 kB chunk warning.
chunkSizeWarningLimit: 5000,
},
server: {
headers: securityHeaders,
},
preview: {
headers: securityHeaders,
},
plugins: [
serveVideos(),
serveOpenCV(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "icons/apple-touch-icon.png"],
manifest: {
id: "track-lab",
name: "trackLab",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
short_name: "trackLab",
description: "trackLab simulation",
categories: ["education", "science"],
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
theme_color: "#1a1a2e",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
background_color: "#000000",
display: "standalone",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
display_override: ["window-controls-overlay", "standalone"],
// No `orientation` — leave free so portrait-friendly sims are not forced landscape.
icons: [
{
src: "icons/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "icons/icon-512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "icons/icon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "maskable",
},
],
// Placeholder shots from `npm run icons`; replace with real sim screenshots before shipping.
screenshots: [
{
src: "screenshots/wide.png",
sizes: "1280x720",
type: "image/png",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
form_factor: "wide",
label: "trackLab",
},
{
src: "screenshots/narrow.png",
sizes: "720x1280",
type: "image/png",
// biome-ignore lint/style/useNamingConvention: Web App Manifest spec requires snake_case keys
form_factor: "narrow",
label: "trackLab",
},
],
},
workbox: {
maximumFileSizeToCacheInBytes: WORKBOX_MAX_FILE_BYTES,
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
// opencv.js (≈11 MB) is loaded on-demand; skip precaching to speed up
// the initial service-worker install. It is still cached at runtime by
// the CacheFirst runtimeCaching entry below (matched by the *.js pattern).
globIgnores: ["opencv.js"],
runtimeCaching: [
{
urlPattern: /\.(?:js|css)$/,
handler: "CacheFirst",
options: {
cacheName: "assets",
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
],
},
}),
],
});