From 5e10be463e99cff608577bc8fae5aac99c72d180 Mon Sep 17 00:00:00 2001 From: Arne Baeumler Date: Sat, 13 Jun 2026 08:40:15 +0200 Subject: [PATCH] feat: bundle browser dependencies locally --- AGENTS.md | 2 ++ README.md | 3 ++- package-lock.json | 16 +++++++++++++ package.json | 6 ++++- src/client/app.ts | 2 +- src/server/client-assets.ts | 39 +++++++++++++++++++++++++++----- src/server/page-template.ts | 9 ++------ src/shared/copy-vendor-assets.ts | 34 ++++++++++++++++++++++++++++ 8 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 src/shared/copy-vendor-assets.ts diff --git a/AGENTS.md b/AGENTS.md index 568e66d..40fd7ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ This repository is a browser-first photo mapping prototype. - Preserve the Nextcloud public-share flow and local proxy behavior. - Keep the timeline zoomable and tied to the selected date range. - Keep the code style consistent with the existing TypeScript/DOM approach. +- Keep browser dependencies bundled locally through npm and served from the app; do not add browser CDN dependencies. - Keep your output during editing as short as possible. - Keep interim chat updates short and to the point when the work is straightforward. @@ -70,5 +71,6 @@ This repository is a browser-first photo mapping prototype. ## Deployment Notes - The app is designed to run in a container. +- The build copies required browser vendor assets into `dist/vendor` so the runtime does not depend on third-party CDNs. - Default host: `0.0.0.0` - Default port: `3000` diff --git a/README.md b/README.md index dc0389a..fb81840 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A browser-based photo mapping prototype that reads EXIF/GPS data and shows image - Fullscreen photo viewer - Timeline view with zoomable time ranges - Progressive loading with cancel support +- Local browser dependency bundle for Leaflet and EXIF parsing ## Start @@ -23,5 +24,5 @@ npm start - Photos are not stored on the server. - The UI and processing run in the browser where possible. +- Browser dependencies are installed through npm and copied into `dist/vendor` during `npm run build`; the runtime serves them locally instead of loading third-party CDNs. - Future auth can be forwarded by a reverse proxy via HTTP headers. - diff --git a/package-lock.json b/package-lock.json index c04fec1..ad6aaf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "mapix", "version": "0.1.0", + "dependencies": { + "exifr": "^7.1.3", + "leaflet": "^1.9.4" + }, "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.0.0" @@ -25,6 +29,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 46e05ca..91ff363 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && node dist/shared/copy-vendor-assets.js", "dev": "node --watch dist/index.js", "start": "node dist/index.js", "test": "npm run build && node --test dist/server/nextcloud-proxy.test.js", @@ -16,5 +16,9 @@ "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.0.0" + }, + "dependencies": { + "exifr": "^7.1.3", + "leaflet": "^1.9.4" } } diff --git a/src/client/app.ts b/src/client/app.ts index 1d67d24..64dba19 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import exifr from "https://cdn.jsdelivr.net/npm/exifr@7.1.3/+esm"; +import exifr from "/vendor/exifr/full.esm.mjs"; import { createPhotoMarkerIcon } from "./map-view.js"; import { parseListing, resolveDavBaseUrl } from "./nextcloud-import.js"; import { diff --git a/src/server/client-assets.ts b/src/server/client-assets.ts index 0575067..949e162 100644 --- a/src/server/client-assets.ts +++ b/src/server/client-assets.ts @@ -2,25 +2,28 @@ import { readFile } from "node:fs/promises"; import type { ServerResponse } from "node:http"; const CLIENT_ASSET_PREFIX = "/client/"; +const VENDOR_ASSET_PREFIX = "/vendor/"; export function isClientAssetPath(pathname: string): boolean { - return pathname.startsWith(CLIENT_ASSET_PREFIX); + return pathname.startsWith(CLIENT_ASSET_PREFIX) || pathname.startsWith(VENDOR_ASSET_PREFIX); } export async function handleClientAssetRequest(url: URL, res: ServerResponse): Promise { - const assetName = url.pathname.slice(CLIENT_ASSET_PREFIX.length); + const isVendorAsset = url.pathname.startsWith(VENDOR_ASSET_PREFIX); + const assetPrefix = isVendorAsset ? VENDOR_ASSET_PREFIX : CLIENT_ASSET_PREFIX; + const assetName = url.pathname.slice(assetPrefix.length); - if (!/^[a-z0-9-]+\.js$/i.test(assetName)) { + if (!isAllowedAssetPath(assetName, isVendorAsset)) { res.statusCode = 404; res.end("Not found"); return; } try { - const assetUrl = new URL(`../client/${assetName}`, import.meta.url); - const source = await readFile(assetUrl, "utf8"); + const assetUrl = new URL(`../${isVendorAsset ? "vendor" : "client"}/${assetName}`, import.meta.url); + const source = await readFile(assetUrl); res.statusCode = 200; - res.setHeader("content-type", "text/javascript; charset=utf-8"); + res.setHeader("content-type", getContentType(assetName)); res.setHeader("cache-control", "no-cache"); res.end(source); } catch { @@ -28,3 +31,27 @@ export async function handleClientAssetRequest(url: URL, res: ServerResponse): P res.end("Not found"); } } + +function isAllowedAssetPath(assetName: string, isVendorAsset: boolean): boolean { + if (assetName.split("/").includes("..")) { + return false; + } + + if (isVendorAsset) { + return /^[a-z0-9/_.-]+\.(css|js|mjs|png)$/i.test(assetName); + } + + return /^[a-z0-9-]+\.js$/i.test(assetName); +} + +function getContentType(assetName: string): string { + if (assetName.endsWith(".css")) { + return "text/css; charset=utf-8"; + } + + if (assetName.endsWith(".png")) { + return "image/png"; + } + + return "text/javascript; charset=utf-8"; +} diff --git a/src/server/page-template.ts b/src/server/page-template.ts index 7b570b8..d8734a5 100644 --- a/src/server/page-template.ts +++ b/src/server/page-template.ts @@ -7,12 +7,7 @@ export function htmlPage(): string { mapix - + @@ -153,7 +148,7 @@ ${APP_STYLES} - + `; diff --git a/src/shared/copy-vendor-assets.ts b/src/shared/copy-vendor-assets.ts new file mode 100644 index 0000000..2dbf046 --- /dev/null +++ b/src/shared/copy-vendor-assets.ts @@ -0,0 +1,34 @@ +import { cp, mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; + +const projectRoot = process.cwd(); +const vendorRoot = join(projectRoot, "dist", "vendor"); + +async function copyFile(source: string, target: string): Promise { + await mkdir(join(target, ".."), { recursive: true }); + await cp(source, target); +} + +async function copyVendorAssets(): Promise { + await rm(vendorRoot, { recursive: true, force: true }); + + await copyFile( + join(projectRoot, "node_modules", "leaflet", "dist", "leaflet.css"), + join(vendorRoot, "leaflet", "leaflet.css") + ); + await copyFile( + join(projectRoot, "node_modules", "leaflet", "dist", "leaflet.js"), + join(vendorRoot, "leaflet", "leaflet.js") + ); + await cp( + join(projectRoot, "node_modules", "leaflet", "dist", "images"), + join(vendorRoot, "leaflet", "images"), + { recursive: true } + ); + await copyFile( + join(projectRoot, "node_modules", "exifr", "dist", "full.esm.mjs"), + join(vendorRoot, "exifr", "full.esm.mjs") + ); +} + +await copyVendorAssets();