feat: vendor all browser deps into the image (no runtime CDN)

esm.sh's degraded storage backend served an unpinned transitive
@codemirror/view@^6.27.0 range URL as text/plain, which browsers block for
a disallowed MIME type. A failed top-level module import aborts the whole
<script type=module>, so the language/model dropdowns came up empty despite
a healthy backend — and top-level version pinning can't fix a transitive
range.

Add a frontend/ esbuild stage (Node stage in the Dockerfile) that vendors
everything into static/vendor and serve only from there:
- CodeMirror (view/state/commands) bundled into one self-contained ESM file
- cropperjs (+ CSS) copied from npm dist
- Tesseract.js engine + wasm core + Dutch (nld) language pack; OCR assets
  stay lazy (fetched only when a scan runs) and fully local
- Drop vim mode (@replit/codemirror-vim); keep document-level Shift-H/L

Google Fonts remains the only external <link> (degrades gracefully, not in
the failing path). Bump to 0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
João Pedro Battistella Nadas
2026-07-05 11:59:27 +02:00
parent fd8f0b74aa
commit 53796f3b03
10 changed files with 859 additions and 41 deletions

77
frontend/build.mjs Normal file
View File

@@ -0,0 +1,77 @@
// Vendors finrod's browser dependencies into ../static/vendor/ so the running
// container serves everything locally — no esm.sh / CDN dependency at runtime.
//
// node build.mjs
//
// Outputs:
// static/vendor/codemirror.js — esbuild bundle (view+state+commands)
// static/vendor/cropper.esm.js — cropperjs ESM (dependency-free)
// static/vendor/cropper.min.css — cropperjs stylesheet
// static/vendor/tesseract/tesseract.esm.min.js — Tesseract.js main thread
// static/vendor/tesseract/worker.min.js — Tesseract.js worker
// static/vendor/tesseract/tesseract-core*.{js,wasm} — wasm OCR core
// static/vendor/tesseract/lang/<lang>.traineddata.gz — default language packs
import * as esbuild from "esbuild";
import { createRequire } from "node:module";
import { cp, mkdir, rm, readdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const OUT = join(here, "..", "static", "vendor");
// Only Dutch is baked in — finrod's real use is OCR'ing Dutch source text.
// Any other OCR language falls back to on-demand download from Tesseract's
// default CDN. These packs are served locally but only fetched by the browser
// when OCR is actually used (the OCR code path dynamic-imports Tesseract).
const LANGS = ["nld"];
const TESSDATA_BASE = "https://tessdata.projectnaptha.com/4.0.0";
const pkgDir = (name) => dirname(require.resolve(`${name}/package.json`));
await rm(OUT, { recursive: true, force: true });
await mkdir(join(OUT, "tesseract", "lang"), { recursive: true });
// 1. CodeMirror — bundle the whole graph into one ESM file.
await esbuild.build({
entryPoints: [join(here, "src", "codemirror.js")],
bundle: true,
format: "esm",
minify: true,
legalComments: "none",
outfile: join(OUT, "codemirror.js"),
});
console.log("✓ codemirror.js");
// 2. cropperjs — ESM build + CSS, both dependency-free, just copy.
const cropper = pkgDir("cropperjs");
await cp(join(cropper, "dist", "cropper.esm.js"), join(OUT, "cropper.esm.js"));
await cp(join(cropper, "dist", "cropper.min.css"), join(OUT, "cropper.min.css"));
console.log("✓ cropperjs");
// 3. Tesseract.js — ship the prebuilt worker + wasm core (do NOT bundle; it
// spawns a Web Worker and streams wasm, which expects real dist files).
const tess = pkgDir("tesseract.js");
await cp(join(tess, "dist", "tesseract.esm.min.js"), join(OUT, "tesseract", "tesseract.esm.min.js"));
await cp(join(tess, "dist", "worker.min.js"), join(OUT, "tesseract", "worker.min.js"));
const core = pkgDir("tesseract.js-core");
for (const f of await readdir(core)) {
if (f.endsWith(".js") || f.endsWith(".wasm")) {
await cp(join(core, f), join(OUT, "tesseract", f));
}
}
console.log("✓ tesseract engine + core");
// Language packs — fetched at build time so the runtime image is self-contained.
for (const lang of LANGS) {
const url = `${TESSDATA_BASE}/${lang}.traineddata.gz`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(join(OUT, "tesseract", "lang", `${lang}.traineddata.gz`), buf);
console.log(`✓ tessdata ${lang} (${(buf.length / 1e6).toFixed(1)} MB)`);
}
console.log("vendor build complete →", OUT);