-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvite.config.js
More file actions
370 lines (362 loc) · 18.7 KB
/
Copy pathvite.config.js
File metadata and controls
370 lines (362 loc) · 18.7 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
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
import { readFileSync } from "node:fs";
// --- Security policy -------------------------------------------------------
//
// Kept in one place and emitted three ways: as a <meta> tag baked into the built
// index.html (so the policy survives being served by a host you do not control), as
// real response headers from `vite preview`, and as public/_headers for static hosts.
// The meta tag is the fallback, not the goal — a real header is stronger because it
// applies before any markup is parsed.
//
// script-src stays strict: no 'unsafe-inline', no 'unsafe-eval'. That is the
// directive that decides whether injected markup can execute, and nothing in the app
// needs either.
//
// style-src had to give up 'unsafe-inline' when the markdown editor landed. Three
// dependencies require it and none can be configured out of it: KaTeX sizes every
// glyph with a style attribute, Mermaid ships a <style> block inside the SVG it
// generates, and CodeMirror injects its theme as a <style> element at runtime. An
// inline *style* cannot execute script; the exposure is CSS injection (think
// overlaying or hiding UI), which is why this is an acceptable trade and script-src
// is not.
//
// 'wasm-unsafe-eval' is what `::python` costs. A WebAssembly module is compiled by
// code the policy has to allow explicitly, and CPython is a WebAssembly module; the
// token exists precisely so that wasm can be permitted *without* opening `eval` and
// `new Function` to injected script, which is the thing script-src is for. Pyodide's
// core is served from this origin (see scripts/copy-pyodide.mjs), so nothing is
// fetched from anybody to run plain Python.
//
// jsdelivr in connect-src is for Pyodide packages, and only documents that ask for
// `packages` ever reach it: numpy and matplotlib are megabytes each and are not
// vendored. A deployment that wants none of it can drop the origin and copy the
// wheels into public/pyodide instead.
//
// The two `https:` grants are what the outward-looking directives cost, and both
// are bounded by what the grant can express. img-src https: is the map tiles
// (::map, any tile provider a document names) — an image can render, not run.
// connect-src https: is ::api-query and ::geocode reading public JSON APIs; a
// document could always exfiltrate through any <img> URL once img-src is open, so
// the marginal exposure is reads, and the directives only ever GET. Script, style,
// wasm and the Trusted Types wall are exactly as strict as before.
//
// api.pirsch.io in script-src is the analytics script, which index.html loads
// straight from Pirsch. It is the only third-party origin script-src grants, and
// granting it is a deliberate widening of the one directive that decides whether
// injected markup can execute: from here on, that decision is also Pirsch's. It
// buys the snippet Pirsch publishes, working unmodified. The alternative is still
// there and still wired — the /pa proxy below, in public/_redirects and in the
// Caddyfile — and rebuilding the snippet as src="/pa/pa.js" plus the three
// data-*-endpoint attributes takes this token back out. The counts themselves need
// nothing extra: connect-src already grants https:.
//
// The two loopback grants in connect-src are the assistant talking to a model
// running on the same machine — Ollama on 11434, or anything else listening there.
// They are the one place this policy allows plain http, and they are bounded to the
// host a browser will not carry a request off the machine for; every other spelling
// of a local address (a LAN address, a hostname resolving to one) is refused in the
// settings form, by core/AiSettings, so it fails where it was typed rather than as a
// console error nobody is looking at. The alternative was proxying the daemon
// through this app's own origin, which works only where the app is served by
// something that can proxy — which is not the static hosts it is built for.
// Note that CSP's host grammar cannot express an IPv6 literal, so `http://[::1]`
// cannot be granted here and is refused there for the same reason.
const CSP_DIRECTIVES = [
"default-src 'self'",
"script-src 'self' 'wasm-unsafe-eval' https://api.pirsch.io",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https: http://localhost:* http://127.0.0.1:*",
"manifest-src 'self'",
"worker-src 'self' blob:",
"base-uri 'none'",
"object-src 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
// Blocks every innerHTML-shaped sink unless the value came from a named policy.
// Three exist: "lit-html" (Spectrum's rendering), "dompurify" (DOMPurify's own,
// which stamps sanitised markdown — see shell/Sanitizer.res), and "default" (an
// exact-match allowlist catching everything else, see shell/TrustedTypes.res).
"require-trusted-types-for 'script'",
"trusted-types lit-html default dompurify",
"upgrade-insecure-requests",
];
// frame-ancestors (and report-*) are ignored when delivered via <meta>; emitting them
// there only produces console noise. The meta variant drops them and leans on
// X-Frame-Options plus the header form.
const META_IGNORED = ["frame-ancestors"];
const cspHeaderValue = CSP_DIRECTIVES.join("; ");
const cspMetaValue = CSP_DIRECTIVES.filter(
(directive) => !META_IGNORED.some((name) => directive.startsWith(name)),
).join("; ");
// No <meta> equivalent exists for these; they must come from the server.
const SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Permissions-Policy":
"accelerometer=(), autoplay=(), camera=(), display-capture=(), encrypted-media=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), usb=(), xr-spatial-tracking=()",
};
// Build only. The dev server needs inline scripts and eval for HMR, so enforcing the
// production CSP there would break the app in a way that teaches nothing;
// `bun run preview` is where the real policy gets exercised.
const contentSecurityPolicy = () => ({
name: "reactivenet:csp",
apply: "build",
transformIndexHtml: {
order: "post",
handler: (html) => ({
html,
tags: [
{
tag: "meta",
attrs: { "http-equiv": "Content-Security-Policy", content: cspMetaValue },
injectTo: "head-prepend",
},
],
}),
},
});
// The version, as a module built here rather than imported from package.json.
//
// `shell/BuildInfo` used to do `@module("../../package.json")`, which is the
// obvious thing and worked until Vite 7 — that version refuses to serve
// package.json to the browser, and no configuration lifts it. The failure was
// the worst kind: the request 404s, the module graph breaks at that one edge,
// nothing mounts, and the console shows a bare 404 for a file no `<script>`
// ever asked for. A blank page with no error in it.
//
// `define` would have been the easy answer and is the wrong one: it reads the
// file once, when the config loads, so a dev server started before
// `scripts/version.mjs` bumped the number keeps showing the old one — exactly
// the drift the footer exists to rule out. This reads the file on every load
// instead, so the dev server serves it fresh and the build inlines it.
const buildInfo = () => ({
name: "reactivenet:build-info",
resolveId: (id) => (id === "virtual:reactivenet/version" ? "\0" + id : undefined),
load: (id) => {
if (id !== "\0virtual:reactivenet/version") return undefined;
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
return `export const version = ${JSON.stringify(pkg.version)};\n`;
},
});
export default defineConfig({
plugins: [
buildInfo(),
tailwindcss(),
react({
include: ["**/*.res.mjs"],
}),
contentSecurityPolicy(),
VitePWA({
registerType: "autoUpdate",
// The injected registration script is a classic <script> that runs before the
// module graph, i.e. before the Trusted Types policy exists. We register the
// worker ourselves in src/shell/ServiceWorker.res instead.
injectRegister: false,
// No service worker while developing: it would cache the ReScript output
// and fight with Vite's HMR on every incremental compile.
devOptions: {
enabled: false,
},
includeAssets: ["logo.svg", "apple-touch-icon-180.png"],
manifest: {
name: "ReactiveNET Platform",
short_name: "ReactiveNET",
description: "ReactiveNET Platform",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#16161d",
icons: [
{ src: "/logo.svg", sizes: "any", type: "image/svg+xml" },
{ src: "/pwa-192.png", sizes: "192x192", type: "image/png" },
{ src: "/pwa-512.png", sizes: "512x512", type: "image/png" },
{
src: "/pwa-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: {
globPatterns: ["**/*.{js,mjs,css,html,svg,png,ico,woff2}"],
// Python's runtime is 13 MB and belongs to the documents that ask for it, not
// to everyone who opens the app: precaching it would triple the install and
// blow past Workbox's per-file ceiling. It is fetched on first use and left
// to the HTTP cache.
// Neither runtime is precached: Pyodide is 13 MB of vendored assets, and
// Automerge's wasm (3.9 MB, hashed into assets/) is fetched only the
// first time an account syncs. Both sit far past Workbox's 2 MB ceiling,
// which fails the build rather than warning.
globIgnores: ["**/pyodide/**", "**/*.wasm"],
cleanupOutdatedCaches: true,
clientsClaim: true,
// Workbox's runtime is inlined into sw.js instead of being imported from a
// second file. By default generateSW emits `importScripts("workbox-<hash>.js")`,
// and a service worker served with this CSP inherits `require-trusted-types-for
// 'script'` — under which importScripts demands a TrustedScriptURL and throws
// "This document requires 'TrustedScriptURL' assignment". The page's own
// Trusted Types policy cannot help: a worker is a separate global with no
// policy of its own. The failure is total and silent from the page's side —
// the worker never installs, so there is no offline app and no navigateFallback
// — and it only happens where the CSP is real, which is never in `vite dev`.
inlineWorkboxRuntime: true,
// The routes are in the path, so /a/<id> is a URL no file answers to. Online
// a rewrite does it (vite dev and preview out of the box, public/_redirects
// for static hosts); offline the service worker has to say the same thing, or
// a bookmarked app is a 404 the moment the network goes.
navigateFallback: "index.html",
},
}),
],
server: {
// Everything except the CSP, which would break HMR.
headers: SECURITY_HEADERS,
// PocketBase on this origin, so connect-src stays 'self'. In production a host
// rewrite does the same job — see public/_redirects.
proxy: {
"/pb": {
target: "http://127.0.0.1:8090",
rewrite: (path) => path.replace(/^\/pb/, ""),
},
// The open-data service (od-* directives), same arrangement: on this origin
// so no document can point the app anywhere else, and connect-src holds.
"/od": {
target: process.env.OD_SERVER || "http://127.0.0.1:8788",
rewrite: (path) => path.replace(/^\/od/, ""),
},
// The MCP server (bun run mcp), which is what the assistant reads the directive
// language out of. Same arrangement again, and here it is load-bearing: the
// model's own endpoint is configurable and the tools' is not, so this one stays
// on the app's own origin where no document and no setting can point it
// elsewhere. /mcp is the endpoint, not the root — see mcp/server.mjs.
"/mcp": {
target: process.env.MCP_SERVER || "http://127.0.0.1:8789",
},
// The catalogue of published apps, on the site — reached on this origin so
// connect-src 'self' holds and no CORS is involved. `/c/<id>` in the app
// fetches /catalog/app/<id>.md from here; in production a host rewrite
// points it at the site, and where neither exists the app says the
// catalogue did not answer.
"/catalog": {
target: process.env.CATALOG_SERVER || "http://localhost:1313",
rewrite: (path) => path.replace(/^\/catalog/, ""),
},
// Pirsch analytics. index.html now loads the script from api.pirsch.io
// directly, which is what the api.pirsch.io token in script-src pays for, so
// nothing currently asks for this route. It is kept because it is the way
// back: a snippet written src="/pa/pa.js" with data-hit-endpoint="/pa/hit",
// data-event-endpoint="/pa/event" and data-session-endpoint="/pa/session"
// makes the whole thing same-origin again and lets script-src go back to
// 'self'. The same route exists in public/_redirects and in the Caddyfile.
// pa.js does not count localhost visits either way, so dev traffic never
// reaches the dashboard.
"/pa": {
target: "https://api.pirsch.io",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/pa/, ""),
},
},
watch: {
// We ignore ReScript build artifacts to avoid unnecessarily triggering HMR on incremental compilation
ignored: ["**/lib/bs/**", "**/lib/ocaml/**", "**/lib/rescript.lock"],
},
},
build: {
// The destination is emptied before every build. Vite does this by default
// while outDir sits inside the project, so this states a guarantee rather
// than changing behaviour — and keeps it if outDir ever moves, where the
// default silently flips to leaving the folder alone. What must not survive
// a build is a hashed asset from the previous one: it is precached by name
// in the old sw.js, deployed by an rsync that has no reason to delete it,
// and indistinguishable from a current file.
emptyOutDir: true,
// Vite inlines any asset under 4 kB as a data: URL, and a font inlined that way
// is refused by `font-src 'self'` — a grant this policy has no other reason to
// widen, since every font here is ours and served from this origin. It bit
// exactly one file: KaTeX ships one woff2 just small enough to qualify (Size4,
// the largest delimiters), so under the production CSP big brackets and radicals
// fell back to whatever the system had, while every other KaTeX face loaded from
// its own file and looked right. A font is precached either way — woff2 is in
// globPatterns — so keeping them as files costs nothing but the request.
assetsInlineLimit: (filePath) => (/\.(woff2?|ttf|otf|eot)$/i.test(filePath) ? false : undefined),
rollupOptions: {
output: {
// Without this the editor libraries land in one chunk that exceeds Workbox's
// 2 MB precache limit and the PWA build fails outright. Splitting by library
// also means a Spectrum or KaTeX bump does not invalidate everything else.
manualChunks(id) {
if (!id.includes("node_modules")) return undefined;
if (id.includes("@blocknote") || id.includes("@mantine") || id.includes("prosemirror"))
return "blocknote";
// Only CodeMirror's core. The per-language grammars reached through
// @codemirror/language-data are loaded on demand, and naming them here
// would drag every one of them into the initial chunk.
if (/@codemirror\/(view|state|language|commands|autocomplete|search|lang-markdown)/.test(id))
return "codemirror";
if (/@lezer\/(common|highlight|lr|markdown)/.test(id)) return "codemirror";
if (id.includes("katex")) return "katex";
// On-demand view engines, one chunk each: loaded by their binders the
// first time a document actually uses them.
if (id.includes("node_modules/chart.js")) return "charts";
if (id.includes("node_modules/leaflet")) return "leaflet";
if (id.includes("node_modules/xlsx")) return "xlsx";
if (id.includes("@duckdb/duckdb-wasm")) return "duckdb";
if (id.includes("@finos/perspective") || id.includes("d3fc") || id.includes("node_modules/d3"))
return "perspective";
if (id.includes("node_modules/apache-arrow") || id.includes("node_modules/flatbuffers"))
return "duckdb";
// The component bundle is 2.6 MB on its own — past Workbox's precache
// ceiling — so the elements are split from the theme and base runtime the
// shell needs at startup. The elements chunk is loaded on demand by
// shell/SpectrumElements.res, only for documents that use a component.
if (id.includes("@spectrum-web-components/theme") || id.includes("node_modules/lit"))
return "spectrum-core";
if (id.includes("@spectrum-web-components")) return undefined;
return undefined;
},
},
},
},
preview: {
headers: { ...SECURITY_HEADERS, "Content-Security-Policy": cspHeaderValue },
proxy: {
"/pb": {
target: "http://127.0.0.1:8090",
rewrite: (path) => path.replace(/^\/pb/, ""),
},
"/od": {
target: process.env.OD_SERVER || "http://127.0.0.1:8788",
rewrite: (path) => path.replace(/^\/od/, ""),
},
// The MCP server (bun run mcp), which is what the assistant reads the directive
// language out of. Same arrangement again, and here it is load-bearing: the
// model's own endpoint is configurable and the tools' is not, so this one stays
// on the app's own origin where no document and no setting can point it
// elsewhere. /mcp is the endpoint, not the root — see mcp/server.mjs.
"/mcp": {
target: process.env.MCP_SERVER || "http://127.0.0.1:8789",
},
// The catalogue, as in `server` above.
"/catalog": {
target: process.env.CATALOG_SERVER || "http://localhost:1313",
rewrite: (path) => path.replace(/^\/catalog/, ""),
},
// Pirsch same-origin, as in `server` above.
"/pa": {
target: "https://api.pirsch.io",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/pa/, ""),
},
},
},
});