diff --git a/resources/images/Favicon.svg b/proprietary/images/Favicon.svg similarity index 100% rename from resources/images/Favicon.svg rename to proprietary/images/Favicon.svg diff --git a/resources/images/OF.png b/proprietary/images/OF.png similarity index 100% rename from resources/images/OF.png rename to proprietary/images/OF.png diff --git a/resources/images/OF.webp b/proprietary/images/OF.webp similarity index 100% rename from resources/images/OF.webp rename to proprietary/images/OF.webp diff --git a/resources/images/OpenFront.png b/proprietary/images/OpenFront.png similarity index 100% rename from resources/images/OpenFront.png rename to proprietary/images/OpenFront.png diff --git a/resources/images/OpenFront.webp b/proprietary/images/OpenFront.webp similarity index 100% rename from resources/images/OpenFront.webp rename to proprietary/images/OpenFront.webp diff --git a/resources/images/OpenFrontLogo.png b/proprietary/images/OpenFrontLogo.png similarity index 100% rename from resources/images/OpenFrontLogo.png rename to proprietary/images/OpenFrontLogo.png diff --git a/resources/images/OpenFrontLogo.svg b/proprietary/images/OpenFrontLogo.svg similarity index 100% rename from resources/images/OpenFrontLogo.svg rename to proprietary/images/OpenFrontLogo.svg diff --git a/resources/images/OpenFrontLogoDark.svg b/proprietary/images/OpenFrontLogoDark.svg similarity index 100% rename from resources/images/OpenFrontLogoDark.svg rename to proprietary/images/OpenFrontLogoDark.svg diff --git a/src/client/sound/SoundManager.ts b/src/client/sound/SoundManager.ts index 9edf026d1..a30f4c5e3 100644 --- a/src/client/sound/SoundManager.ts +++ b/src/client/sound/SoundManager.ts @@ -1,7 +1,5 @@ import { Howl } from "howler"; -import of4 from "../../../proprietary/sounds/music/of4.mp3"; -import openfront from "../../../proprietary/sounds/music/openfront.mp3"; -import war from "../../../proprietary/sounds/music/war.mp3"; +import { assetUrl } from "../../core/AssetUrls"; import { EventBus } from "../../core/EventBus"; import { UserSettings } from "../../core/game/UserSettings"; import { @@ -33,19 +31,19 @@ export class SoundManager { this.safely("initialize background music", () => { this.backgroundMusic = [ new Howl({ - src: [of4], + src: [assetUrl("sounds/music/of4.mp3")], loop: false, onend: this.playNext.bind(this), volume: 0, }), new Howl({ - src: [openfront], + src: [assetUrl("sounds/music/openfront.mp3")], loop: false, onend: this.playNext.bind(this), volume: 0, }), new Howl({ - src: [war], + src: [assetUrl("sounds/music/war.mp3")], loop: false, onend: this.playNext.bind(this), volume: 0, diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index 841ea08d0..a27582dba 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -233,20 +233,44 @@ export function getResourcesDir(rootDir: string = process.cwd()): string { return path.join(rootDir, "resources"); } +export function getProprietaryDir(rootDir: string = process.cwd()): string { + return path.join(rootDir, "proprietary"); +} + +// Scans directories with synchronous fs.existsSync — assumes a small number of sourceDirs. +function resolveSourceDir(relativePath: string, sourceDirs: string[]): string { + for (const dir of sourceDirs) { + const candidate = path.join(dir, relativePath); + if (fs.existsSync(candidate)) { + return dir; + } + } + throw new Error( + `Asset ${relativePath} not found in any source directory: ${sourceDirs.join(", ")}`, + ); +} + +function resolveSourceFile(relativePath: string, sourceDirs: string[]): string { + return path.join(resolveSourceDir(relativePath, sourceDirs), relativePath); +} + export function shouldKeepRootPublicFile(relativePath: string): boolean { return ROOT_PUBLIC_FILES.has(normalizeAssetPath(relativePath)); } -export function listHashedPublicAssetPaths(resourcesDir: string): string[] { +export function listHashedPublicAssetPaths(sourceDirs: string[]): string[] { const files = new Set(); - for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) { - for (const file of globSync(pattern, { - cwd: resourcesDir, - nodir: true, - dot: false, - posix: true, - })) { - files.add(normalizeAssetPath(file)); + for (const dir of sourceDirs) { + if (!fs.existsSync(dir)) continue; + for (const pattern of HASHED_PUBLIC_ASSET_GLOBS) { + for (const file of globSync(pattern, { + cwd: dir, + nodir: true, + dot: false, + posix: true, + })) { + files.add(normalizeAssetPath(file)); + } } } return [...files].sort(); @@ -264,13 +288,14 @@ export function listRootPublicFiles(resourcesDir: string): string[] { .sort(); } -export function buildPublicAssetManifest(resourcesDir: string): AssetManifest { - const cached = manifestCache.get(resourcesDir); +export function buildPublicAssetManifest(sourceDirs: string[]): AssetManifest { + const cacheKey = sourceDirs.join("\0"); + const cached = manifestCache.get(cacheKey); if (cached) { return cached; } - const hashedPublicAssetPaths = listHashedPublicAssetPaths(resourcesDir); + const hashedPublicAssetPaths = listHashedPublicAssetPaths(sourceDirs); const rawAssetPaths = hashedPublicAssetPaths.filter( (relativePath) => !isDerivedPublicAsset(relativePath), ); @@ -280,14 +305,14 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest { const manifest: AssetManifest = {}; for (const relativePath of rawAssetPaths) { - const absolutePath = path.join(resourcesDir, relativePath); + const absolutePath = resolveSourceFile(relativePath, sourceDirs); const hash = createContentHash(absolutePath); manifest[relativePath] = createHashedAssetUrl(relativePath, hash); } for (const relativePath of derivedAssetPaths) { const renderedAsset = renderDerivedPublicAsset( - resourcesDir, + resolveSourceDir(relativePath, sourceDirs), relativePath, manifest, ); @@ -301,7 +326,7 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest { ); } - manifestCache.set(resourcesDir, manifest); + manifestCache.set(cacheKey, manifest); return manifest; } @@ -310,17 +335,18 @@ export function clearPublicAssetManifestCache(): void { } export function createHashedPublicAssetFiles( - resourcesDir: string, + sourceDirs: string[], outDir: string, assetManifest: AssetManifest, ): void { for (const [relativePath, hashedUrl] of Object.entries(assetManifest)) { - const sourcePath = path.join(resourcesDir, relativePath); + const sourceDir = resolveSourceDir(relativePath, sourceDirs); + const sourcePath = path.join(sourceDir, relativePath); const outputPath = path.join(outDir, normalizeAssetPath(hashedUrl)); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); const renderedAsset = renderDerivedPublicAsset( - resourcesDir, + sourceDir, relativePath, assetManifest, ); diff --git a/tests/client/sound/SoundManager.test.ts b/tests/client/sound/SoundManager.test.ts index 246ccfc37..a8827ebf1 100644 --- a/tests/client/sound/SoundManager.test.ts +++ b/tests/client/sound/SoundManager.test.ts @@ -39,17 +39,6 @@ vi.mock("howler", () => { return { Howl: MockHowl }; }); -// Mock music imports -vi.mock("../../../../proprietary/sounds/music/of4.mp3", () => ({ - default: "of4.mp3", -})); -vi.mock("../../../../proprietary/sounds/music/openfront.mp3", () => ({ - default: "openfront.mp3", -})); -vi.mock("../../../../proprietary/sounds/music/war.mp3", () => ({ - default: "war.mp3", -})); - // Mock the Sounds module so tests don't depend on actual asset paths vi.mock("../../../src/client/sound/Sounds", async (importOriginal) => { const actual = diff --git a/tests/server/PublicAssetManifest.test.ts b/tests/server/PublicAssetManifest.test.ts index 9649a26f9..db7ec0620 100644 --- a/tests/server/PublicAssetManifest.test.ts +++ b/tests/server/PublicAssetManifest.test.ts @@ -107,11 +107,11 @@ describe("PublicAssetManifest", () => { "utf8", ); - const firstManifest = buildPublicAssetManifest(resourcesDir); + const firstManifest = buildPublicAssetManifest([resourcesDir]); const firstManifestHref = firstManifest["manifest.json"]; const firstIconHref = firstManifest["icons/app-icon.png"]; - createHashedPublicAssetFiles(resourcesDir, outDir, firstManifest); + createHashedPublicAssetFiles([resourcesDir], outDir, firstManifest); const firstOutput = await fs.readFile( path.join(outDir, firstManifestHref.slice(1)), "utf8", @@ -124,7 +124,7 @@ describe("PublicAssetManifest", () => { ); clearPublicAssetManifestCache(); - const secondManifest = buildPublicAssetManifest(resourcesDir); + const secondManifest = buildPublicAssetManifest([resourcesDir]); const secondManifestHref = secondManifest["manifest.json"]; const secondIconHref = secondManifest["icons/app-icon.png"]; @@ -147,8 +147,8 @@ describe("PublicAssetManifest", () => { "utf8", ); - const assetManifest = buildPublicAssetManifest(resourcesDir); - createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); + const assetManifest = buildPublicAssetManifest([resourcesDir]); + createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest); const emittedManifest = await emitHashedAsset( outDir, @@ -164,7 +164,7 @@ describe("PublicAssetManifest", () => { await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]); - expect(() => buildPublicAssetManifest(resourcesDir)).toThrow( + expect(() => buildPublicAssetManifest([resourcesDir])).toThrow( /manifest\.json references icons\/missing\.png/i, ); }); @@ -177,8 +177,8 @@ describe("PublicAssetManifest", () => { { src: "data:image/png;base64,AAA" }, ]); - const assetManifest = buildPublicAssetManifest(resourcesDir); - createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); + const assetManifest = buildPublicAssetManifest([resourcesDir]); + createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest); const emittedManifest = await emitHashedAsset( outDir, @@ -198,8 +198,8 @@ describe("PublicAssetManifest", () => { "test.png", ); - const assetManifest = buildPublicAssetManifest(resourcesDir); - createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); + const assetManifest = buildPublicAssetManifest([resourcesDir]); + createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest); const xmlHref = assetManifest["fonts/test.xml"]; const pngHref = assetManifest["fonts/test.png"]; @@ -220,12 +220,12 @@ describe("PublicAssetManifest", () => { "test.png", ); - const firstManifest = buildPublicAssetManifest(resourcesDir); + const firstManifest = buildPublicAssetManifest([resourcesDir]); await fs.writeFile(path.join(resourcesDir, "fonts", "test.png"), "png-v2"); clearPublicAssetManifestCache(); - const secondManifest = buildPublicAssetManifest(resourcesDir); + const secondManifest = buildPublicAssetManifest([resourcesDir]); expect(firstManifest["fonts/test.png"]).not.toBe( secondManifest["fonts/test.png"], @@ -250,7 +250,7 @@ describe("PublicAssetManifest", () => { ].join("\n"), ); - expect(() => buildPublicAssetManifest(resourcesDir)).toThrow( + expect(() => buildPublicAssetManifest([resourcesDir])).toThrow( /missing from the asset manifest/i, ); }); @@ -265,8 +265,8 @@ describe("PublicAssetManifest", () => { "nested-png", ); - const assetManifest = buildPublicAssetManifest(resourcesDir); - createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); + const assetManifest = buildPublicAssetManifest([resourcesDir]); + createHashedPublicAssetFiles([resourcesDir], outDir, assetManifest); const xmlHref = assetManifest["fonts/nested/atlas.xml"]; const pngHref = assetManifest["fonts/nested/pages/p0.png"]; diff --git a/vite.config.ts b/vite.config.ts index bd39e35e1..4355badbe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,15 +1,16 @@ import tailwindcss from "@tailwindcss/vite"; +import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; -import { defineConfig, loadEnv } from "vite"; +import { defineConfig, loadEnv, type Plugin } from "vite"; import { createHtmlPlugin } from "vite-plugin-html"; -import { viteStaticCopy } from "vite-plugin-static-copy"; import tsconfigPaths from "vite-tsconfig-paths"; import { type AssetManifest, buildAssetUrl } from "./src/core/AssetUrls"; import { buildPublicAssetManifest, copyRootPublicFiles, createHashedPublicAssetFiles, + getProprietaryDir, getResourcesDir, writePublicAssetManifestModule, } from "./src/server/PublicAssetManifest"; @@ -18,12 +19,43 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +function serveProprietaryDir(dir: string): Plugin { + const resolvedDir = path.resolve(dir) + path.sep; + return { + name: "serve-proprietary-dir", + configureServer(server) { + // Return a function so the middleware is registered after Vite's internal + // static-file handler (publicDir). This makes proprietary/ a fallback + // rather than taking precedence over resources/. + return () => { + server.middlewares.use((req, res, next) => { + if (!req.url) return next(); + const urlPath = new URL(req.url, "http://localhost").pathname; + const filePath = path.resolve( + dir, + decodeURIComponent(urlPath).replace(/^\//, ""), + ); + if (!filePath.startsWith(resolvedDir)) return next(); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + res.setHeader("Cache-Control", "no-cache"); + fs.createReadStream(filePath).pipe(res); + } else { + next(); + } + }); + }; + }, + }; +} + export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), ""); const isProduction = mode === "production"; const resourcesDir = getResourcesDir(__dirname); + const proprietaryDir = getProprietaryDir(__dirname); + const sourceDirs = [resourcesDir, proprietaryDir]; const assetManifest: AssetManifest = isProduction - ? buildPublicAssetManifest(resourcesDir) + ? buildPublicAssetManifest(sourceDirs) : {}; const htmlAssetData = { assetManifest: JSON.stringify(assetManifest), @@ -45,7 +77,7 @@ export default defineConfig(({ mode }) => { closeBundle() { const outDir = path.join(__dirname, "static"); copyRootPublicFiles(resourcesDir, outDir); - createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest); + createHashedPublicAssetFiles(sourceDirs, outDir, assetManifest); writePublicAssetManifestModule(outDir, assetManifest); }, }); @@ -91,6 +123,7 @@ export default defineConfig(({ mode }) => { plugins: [ tsconfigPaths(), + ...(!isProduction ? [serveProprietaryDir(proprietaryDir)] : []), ...(isProduction ? [] : [ @@ -106,14 +139,6 @@ export default defineConfig(({ mode }) => { }, }), ]), - viteStaticCopy({ - targets: [ - { - src: "proprietary/*", - dest: ".", - }, - ], - }), ...(isProduction ? [syncHashedPublicAssets()] : []), tailwindcss(), ],