feat: bundle browser dependencies locally
This commit is contained in:
@@ -32,6 +32,7 @@ This repository is a browser-first photo mapping prototype.
|
|||||||
- Preserve the Nextcloud public-share flow and local proxy behavior.
|
- Preserve the Nextcloud public-share flow and local proxy behavior.
|
||||||
- Keep the timeline zoomable and tied to the selected date range.
|
- Keep the timeline zoomable and tied to the selected date range.
|
||||||
- Keep the code style consistent with the existing TypeScript/DOM approach.
|
- 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 your output during editing as short as possible.
|
||||||
- Keep interim chat updates short and to the point when the work is straightforward.
|
- 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
|
## Deployment Notes
|
||||||
|
|
||||||
- The app is designed to run in a container.
|
- 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 host: `0.0.0.0`
|
||||||
- Default port: `3000`
|
- Default port: `3000`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ A browser-based photo mapping prototype that reads EXIF/GPS data and shows image
|
|||||||
- Fullscreen photo viewer
|
- Fullscreen photo viewer
|
||||||
- Timeline view with zoomable time ranges
|
- Timeline view with zoomable time ranges
|
||||||
- Progressive loading with cancel support
|
- Progressive loading with cancel support
|
||||||
|
- Local browser dependency bundle for Leaflet and EXIF parsing
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
@@ -23,5 +24,5 @@ npm start
|
|||||||
|
|
||||||
- Photos are not stored on the server.
|
- Photos are not stored on the server.
|
||||||
- The UI and processing run in the browser where possible.
|
- 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.
|
- Future auth can be forwarded by a reverse proxy via HTTP headers.
|
||||||
|
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -7,6 +7,10 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "mapix",
|
"name": "mapix",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"exifr": "^7.1.3",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
@@ -25,6 +29,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json && node dist/shared/copy-vendor-assets.js",
|
||||||
"dev": "node --watch dist/index.js",
|
"dev": "node --watch dist/index.js",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "npm run build && node --test dist/server/nextcloud-proxy.test.js",
|
"test": "npm run build && node --test dist/server/nextcloud-proxy.test.js",
|
||||||
@@ -16,5 +16,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"exifr": "^7.1.3",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @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 { createPhotoMarkerIcon } from "./map-view.js";
|
||||||
import { parseListing, resolveDavBaseUrl } from "./nextcloud-import.js";
|
import { parseListing, resolveDavBaseUrl } from "./nextcloud-import.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -2,25 +2,28 @@ import { readFile } from "node:fs/promises";
|
|||||||
import type { ServerResponse } from "node:http";
|
import type { ServerResponse } from "node:http";
|
||||||
|
|
||||||
const CLIENT_ASSET_PREFIX = "/client/";
|
const CLIENT_ASSET_PREFIX = "/client/";
|
||||||
|
const VENDOR_ASSET_PREFIX = "/vendor/";
|
||||||
|
|
||||||
export function isClientAssetPath(pathname: string): boolean {
|
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<void> {
|
export async function handleClientAssetRequest(url: URL, res: ServerResponse): Promise<void> {
|
||||||
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.statusCode = 404;
|
||||||
res.end("Not found");
|
res.end("Not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const assetUrl = new URL(`../client/${assetName}`, import.meta.url);
|
const assetUrl = new URL(`../${isVendorAsset ? "vendor" : "client"}/${assetName}`, import.meta.url);
|
||||||
const source = await readFile(assetUrl, "utf8");
|
const source = await readFile(assetUrl);
|
||||||
res.statusCode = 200;
|
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.setHeader("cache-control", "no-cache");
|
||||||
res.end(source);
|
res.end(source);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -28,3 +31,27 @@ export async function handleClientAssetRequest(url: URL, res: ServerResponse): P
|
|||||||
res.end("Not found");
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ export function htmlPage(): string {
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>mapix</title>
|
<title>mapix</title>
|
||||||
<link
|
<link rel="stylesheet" href="/vendor/leaflet/leaflet.css" />
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin=""
|
|
||||||
/>
|
|
||||||
<style>
|
<style>
|
||||||
${APP_STYLES}
|
${APP_STYLES}
|
||||||
</style>
|
</style>
|
||||||
@@ -153,7 +148,7 @@ ${APP_STYLES}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
<script src="/vendor/leaflet/leaflet.js"></script>
|
||||||
<script type="module" src="/client/app.js"></script>
|
<script type="module" src="/client/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
34
src/shared/copy-vendor-assets.ts
Normal file
34
src/shared/copy-vendor-assets.ts
Normal file
@@ -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<void> {
|
||||||
|
await mkdir(join(target, ".."), { recursive: true });
|
||||||
|
await cp(source, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyVendorAssets(): Promise<void> {
|
||||||
|
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();
|
||||||
Reference in New Issue
Block a user