diff --git a/apps/world-lab/src/app/maplibre-worker/[file]/route.ts b/apps/world-lab/src/app/maplibre-worker/[file]/route.ts index 8f121ea..e8bbed5 100644 --- a/apps/world-lab/src/app/maplibre-worker/[file]/route.ts +++ b/apps/world-lab/src/app/maplibre-worker/[file]/route.ts @@ -1,27 +1,9 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { notFound } from 'next/navigation'; - -const mapLibreWorkerFiles = new Set([ - 'maplibre-gl-shared.mjs', - 'maplibre-gl-worker.mjs', -]); +import { serveMapLibreWorkerFile } from '../serve-worker-file'; export async function GET( _request: Request, context: { params: Promise<{ file: string }> }, ) { const { file } = await context.params; - if (!mapLibreWorkerFiles.has(file)) notFound(); - - const contents = await readFile( - join(process.cwd(), 'node_modules', 'maplibre-gl', 'dist', file), - ); - - return new Response(contents, { - headers: { - 'Cache-Control': 'public, max-age=31536000, immutable', - 'Content-Type': 'text/javascript; charset=utf-8', - }, - }); + return serveMapLibreWorkerFile(file); } diff --git a/apps/world-lab/src/app/maplibre-worker/route.test.ts b/apps/world-lab/src/app/maplibre-worker/route.test.ts new file mode 100644 index 0000000..8b75648 --- /dev/null +++ b/apps/world-lab/src/app/maplibre-worker/route.test.ts @@ -0,0 +1,51 @@ +import { getVersion } from 'maplibre-gl'; +import { describe, expect, it } from 'vitest'; +import { GET as getLegacyWorker } from './[file]/route'; +import { GET as getVersionedWorker } from './v/[version]/[file]/route'; + +describe('MapLibre worker routes', () => { + it('serves versioned allowlisted files with immutable caching', async () => { + const response = await getVersionedWorker(new Request('http://localhost'), { + params: Promise.resolve({ + version: getVersion(), + file: 'maplibre-gl-worker.mjs', + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(await response.text()).toContain('maplibre-gl-shared.mjs'); + }); + + it('returns not found for stale versions and files outside the allowlist', async () => { + await expect( + getVersionedWorker(new Request('http://localhost'), { + params: Promise.resolve({ + version: 'stale-version', + file: 'maplibre-gl-worker.mjs', + }), + }), + ).rejects.toThrow('NEXT_HTTP_ERROR_FALLBACK;404'); + + await expect( + getVersionedWorker(new Request('http://localhost'), { + params: Promise.resolve({ + version: getVersion(), + file: 'unlisted.mjs', + }), + }), + ).rejects.toThrow('NEXT_HTTP_ERROR_FALLBACK;404'); + }); + + it('serves legacy URLs without allowing browsers to retain stale bundles', async () => { + const response = await getLegacyWorker(new Request('http://localhost'), { + params: Promise.resolve({ file: 'maplibre-gl-shared.mjs' }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.text()).toContain('maplibre-gl-shared.mjs.map'); + }); +}); diff --git a/apps/world-lab/src/app/maplibre-worker/serve-worker-file.ts b/apps/world-lab/src/app/maplibre-worker/serve-worker-file.ts new file mode 100644 index 0000000..0d78596 --- /dev/null +++ b/apps/world-lab/src/app/maplibre-worker/serve-worker-file.ts @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { notFound } from 'next/navigation'; +import { mapLibreWorkerFiles } from './worker-files'; + +async function getInstalledMapLibreVersion() { + const packageContents = await readFile( + join(process.cwd(), 'node_modules', 'maplibre-gl', 'package.json'), + 'utf8', + ); + return (JSON.parse(packageContents) as { version: string }).version; +} + +export async function serveMapLibreWorkerFile( + file: string, + requestedVersion?: string, +) { + if (!mapLibreWorkerFiles.has(file)) notFound(); + + if (requestedVersion !== undefined) { + const installedVersion = await getInstalledMapLibreVersion(); + if (requestedVersion !== installedVersion) notFound(); + } + + const contents = await readFile( + join(process.cwd(), 'node_modules', 'maplibre-gl', 'dist', file), + ); + + return new Response(contents, { + headers: { + 'Cache-Control': + requestedVersion === undefined + ? 'no-store' + : 'public, max-age=31536000, immutable', + 'Content-Type': 'text/javascript; charset=utf-8', + }, + }); +} diff --git a/apps/world-lab/src/app/maplibre-worker/v/[version]/[file]/route.ts b/apps/world-lab/src/app/maplibre-worker/v/[version]/[file]/route.ts new file mode 100644 index 0000000..17e0f8d --- /dev/null +++ b/apps/world-lab/src/app/maplibre-worker/v/[version]/[file]/route.ts @@ -0,0 +1,9 @@ +import { serveMapLibreWorkerFile } from '../../../serve-worker-file'; + +export async function GET( + _request: Request, + context: { params: Promise<{ version: string; file: string }> }, +) { + const { file, version } = await context.params; + return serveMapLibreWorkerFile(file, version); +} diff --git a/apps/world-lab/src/app/maplibre-worker/worker-files.ts b/apps/world-lab/src/app/maplibre-worker/worker-files.ts new file mode 100644 index 0000000..7a5cb0e --- /dev/null +++ b/apps/world-lab/src/app/maplibre-worker/worker-files.ts @@ -0,0 +1,4 @@ +export const mapLibreWorkerFiles = new Set([ + 'maplibre-gl-shared.mjs', + 'maplibre-gl-worker.mjs', +]); diff --git a/apps/world-lab/src/components/world-map.tsx b/apps/world-lab/src/components/world-map.tsx index d611dc7..d59af70 100644 --- a/apps/world-lab/src/components/world-map.tsx +++ b/apps/world-lab/src/components/world-map.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import { cellToBoundary, cellToLatLng } from 'h3-js'; import { AttributionControl, + getVersion, LngLatBounds, type GeoJSONSource, Map, @@ -43,7 +44,7 @@ const sourceId = 'development-hexes'; const fillLayerId = 'development-hex-fills'; const lineLayerId = 'development-hex-lines'; -setWorkerUrl('/maplibre-worker/maplibre-gl-worker.mjs'); +setWorkerUrl(`/maplibre-worker/v/${getVersion()}/maplibre-gl-worker.mjs`); type OverlayStatus = 'initializing' | 'ready' | 'incomplete' | 'failed'; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ef01761..50f9fb9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -158,6 +158,12 @@ World Lab issues explicit ticks while Start or a bounded run is active. Provider `apps/world-lab` is a Next.js App Router developer/admin surface. It fetches runtime-validated simulation snapshots through a local rewrite, controls one tick at a time, and updates MapLibre's existing H3 GeoJSON source without recreating the map. Agent markers are fully visible and use deterministic offsets when sharing cells. +MapLibre's worker URL includes the renderer's package version. The worker and +its relative shared-module import use the same versioned route, which serves +only the installed version and two allowlisted files. Versioned responses use +immutable caching; legacy unversioned responses use `no-store`. This keeps +dependency upgrades from pairing a new renderer with cached older workers. + Its command navbar is the single persistent application-control row. Browser-session run-target selection remains client orchestration and preserves absolute tick semantics; execution and reconciliation still consume authoritative API snapshots. Agent color uses retained effective color, base agent color, and a neutral fallback. The basemap uses the standard HTTPS OpenStreetMap raster tile endpoint with diff --git a/docs/TESTING.md b/docs/TESTING.md index bc3bd07..ccccbd8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -235,11 +235,18 @@ neutral fallback for unknown agents. OpenStreetMap tile URLs, visible attribution, source zoom bounds, and a dark raster treatment that leaves domain overlays untouched. +`maplibre-worker/route.test.ts` covers matching package versions, rejected +versions and filenames, immutable versioned responses, and uncached legacy +responses. + ### Playwright E2E (`tests/e2e/world-lab.spec.ts`) -Two tests: the long swarm-activity log scrolls inside the fixed-height bottom -dock; and a deterministic scripted swarm tick commits and exports safe -telemetry without an OpenRouter request. Browser tests fulfill OpenStreetMap +Three tests: a map renders using matching versioned worker assets even when +the legacy worker URL is intercepted with a stale response; the long +swarm-activity log scrolls inside the fixed-height bottom dock; and a +deterministic scripted swarm tick commits and exports safe telemetry without +an OpenRouter request. The worker regression also checks both versioned asset +responses and rejects a mismatched package version. Browser tests fulfill OpenStreetMap tile requests with a local image fixture instead of contacting the public tile service. diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index cc6edbf..ac00c06 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -24,6 +24,53 @@ async function openMoreActions(page: Parameters[0]['page']) { } } +test('renders the map without using stale unversioned worker assets', async ({ + page, + context, +}) => { + const { dependencies } = JSON.parse( + await readFile('apps/world-lab/package.json', 'utf8'), + ); + const version = dependencies['maplibre-gl']; + const workerRequests: string[] = []; + const errors: string[] = []; + context.on('request', (request) => { + if (request.url().includes('/maplibre-worker/')) { + workerRequests.push(new URL(request.url()).pathname); + } + }); + page.on('pageerror', (error) => errors.push(error.message)); + await context.route('**/maplibre-worker/maplibre-gl-*.mjs', (route) => + route.fulfill({ + contentType: 'text/javascript', + body: 'throw new Error("Stale unversioned MapLibre worker loaded");', + }), + ); + + await page.goto('/'); + await expect(page.getByTestId('world-map')).toHaveAttribute( + 'data-overlay-status', + 'ready', + ); + expect(workerRequests).toContain( + `/maplibre-worker/v/${version}/maplibre-gl-worker.mjs`, + ); + expect(errors).toEqual([]); + + for (const file of ['maplibre-gl-worker.mjs', 'maplibre-gl-shared.mjs']) { + const response = await context.request.get( + `/maplibre-worker/v/${version}/${file}`, + ); + expect(response.ok()).toBe(true); + expect(response.headers()['cache-control']).toContain('immutable'); + expect(await response.text()).toContain(`v${version}/LICENSE.txt`); + } + const mismatched = await context.request.get( + '/maplibre-worker/v/0.0.0/maplibre-gl-worker.mjs', + ); + expect(mismatched.status()).toBe(404); +}); + test('keeps long swarm activity scrollable inside the bottom dock', async ({ page, }) => {