From 7a8f0793cd01467b7f2776037bfad9c837715a66 Mon Sep 17 00:00:00 2001 From: Alfa Team Date: Sun, 28 Jun 2026 17:32:23 -0400 Subject: [PATCH] Add www route root compatibility --- .../PROJECT_INSTRUCTIONS.md | 2 +- .../PROJECT_INSTRUCTIONS_VERSION.md | 3 +- .../ProjectInstructions/PROJECT_STATE.md | 12 +-- .../backlog/BACKLOG_MASTER.md | 4 +- .../repository/www_migration_map.md | 16 +++ ...te-root-compatibility_branch-validation.md | 23 ++++ ...t-compatibility_manual-validation-notes.md | 22 ++++ ...007-www-route-root-compatibility_report.md | 47 +++++++++ ...oot-compatibility_requirement-checklist.md | 23 ++++ ...te-root-compatibility_validation-report.md | 24 +++++ dev/reports/codex_changed_files.txt | Bin 776 -> 1140 bytes dev/reports/codex_review.diff | Bin 20748 -> 50162 bytes dev/scripts/start-dev.mjs | 40 ++----- .../StaticWebRootCompatibility.test.mjs | 82 +++++++++++++++ dev/tests/helpers/playwrightRepoServer.mjs | 35 ++----- .../tools/StaticWebRootCompatibility.spec.mjs | 45 ++++++++ src/dev-runtime/server/local-api-server.mjs | 35 ++----- src/dev-runtime/server/static-web-root.mjs | 99 ++++++++++++++++++ 18 files changed, 414 insertions(+), 98 deletions(-) create mode 100644 dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_branch-validation.md create mode 100644 dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_manual-validation-notes.md create mode 100644 dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_report.md create mode 100644 dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_requirement-checklist.md create mode 100644 dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_validation-report.md create mode 100644 dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs create mode 100644 dev/tests/playwright/tools/StaticWebRootCompatibility.spec.mjs create mode 100644 src/dev-runtime/server/static-web-root.mjs diff --git a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md index 80fa93d38..3a6047dc0 100644 --- a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md +++ b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md @@ -117,7 +117,7 @@ Rules: ## Current Version/Date -- Project Instructions Version: 2026.06.28.006 +- Project Instructions Version: 2026.06.28.007 - Date: 2026-06-28 - Owner: OWNER diff --git a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md index b69da6446..b44470158 100644 --- a/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md +++ b/dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md @@ -1,6 +1,6 @@ # Project Instructions Version -Current Project Instructions Version: 2026.06.28.006 +Current Project Instructions Version: 2026.06.28.007 Last Updated: 2026-06-28 @@ -14,6 +14,7 @@ Last Updated: 2026-06-28 - Added proposed repository layout architecture plan for future `www/`, `api/`, and `dev/` separation. - Added repository layout scaffold governance for `www/`, `api/`, and `dev/local-runtime/`. - Added `www/` migration map for safe browser-served application movement planning. +- Added local route-root compatibility toggle documentation for future `www/` activation. - Codex must read this version file and the latest repository copy of `PROJECT_INSTRUCTIONS.md` before performing work. - Codex must discard previously remembered Project Instructions and treat the repository copy as authoritative. - Codex must validate canonical report and ZIP paths, branching model, and legacy path avoidance before work proceeds. diff --git a/dev/build/ProjectInstructions/PROJECT_STATE.md b/dev/build/ProjectInstructions/PROJECT_STATE.md index 79b89c03d..6b22eb153 100644 --- a/dev/build/ProjectInstructions/PROJECT_STATE.md +++ b/dev/build/ProjectInstructions/PROJECT_STATE.md @@ -1,7 +1,7 @@ # Project State ```yaml -project_state_version: "2026.06.28.006" +project_state_version: "2026.06.28.007" last_updated: "2026-06-28" current_main_commit: "40de767476d70cadfd1292c916844c2f31b6f185" repository_status: @@ -10,9 +10,9 @@ repository_status: runtime_database: "Postgres" creator_metadata: "API to Postgres" creator_assets: "API to R2" -project_instructions_version: "2026.06.28.006" -repository_structure_version: "2026.06.28.006" -canonical_layout_version: "2026.06.28.006" +project_instructions_version: "2026.06.28.007" +repository_structure_version: "2026.06.28.007" +canonical_layout_version: "2026.06.28.007" active_teams: - "Owner" - "Alfa" @@ -20,8 +20,8 @@ active_teams: - "Charlie" - "Delta" - "Golf" -latest_owner_pr: "PR_26180_OWNER_006-www-migration-map" -latest_structure_pr: "PR_26180_OWNER_006-www-migration-map" +latest_owner_pr: "PR_26180_OWNER_007-www-route-root-compatibility" +latest_structure_pr: "PR_26180_OWNER_007-www-route-root-compatibility" valid_top_level_folders: - "account/" - "admin/" diff --git a/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md b/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md index e571a4024..3c52cbced 100644 --- a/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md +++ b/dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md @@ -39,8 +39,8 @@ The backlog is the authoritative source for determining the next logical PRs. - Team: Owner - Product Area: Repository Architecture Simplification - Status: Active -- Percent Complete: 15% -- Active PR: PR_26180_OWNER_006-www-migration-map +- Percent Complete: 20% +- Active PR: PR_26180_OWNER_007-www-route-root-compatibility - Next Milestone: Move browser-served application to `www/` - Source / Reference: `dev/build/ProjectInstructions/repository/repository_layout_architecture_plan.md`; `dev/build/ProjectInstructions/repository/www_migration_map.md` diff --git a/dev/build/ProjectInstructions/repository/www_migration_map.md b/dev/build/ProjectInstructions/repository/www_migration_map.md index bd617513d..82f351cd9 100644 --- a/dev/build/ProjectInstructions/repository/www_migration_map.md +++ b/dev/build/ProjectInstructions/repository/www_migration_map.md @@ -170,6 +170,22 @@ Recommended approach for the actual move PR: 5. Do not introduce browser redirects unless a static route cannot be served directly. 6. Document every temporary shim with removal criteria for the legacy-layout cleanup PR. +## Route Root Compatibility Toggle + +`PR_26180_OWNER_007-www-route-root-compatibility` installs the local compatibility switch without moving browser files. + +The future activation toggle is: + +```text +GAMEFOUNDRY_LOCAL_WEB_ROOT=www +``` + +Default behavior remains current repository-root static serving when `GAMEFOUNDRY_LOCAL_WEB_ROOT` is unset, empty, `.`, `root`, or `repo-root`. + +When `GAMEFOUNDRY_LOCAL_WEB_ROOT=www`, local static serving prefers `www/` for public browser URLs while retaining repository-root fallback for compatibility during migration. + +This toggle is for local runtime and test helpers only. It does not change production deployment, public URLs, API routes, or package commands. + ## Required Sequence For Safe www Move 1. Freeze the public route contract. diff --git a/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_branch-validation.md b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_branch-validation.md new file mode 100644 index 000000000..df1542475 --- /dev/null +++ b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_branch-validation.md @@ -0,0 +1,23 @@ +# PR_26180_OWNER_007 Branch Validation + +## Result + +PASS + +## Checks + +| Check | Result | +|---|---| +| Current branch is `PR_26180_OWNER_007-www-route-root-compatibility` | PASS | +| Branch is stacked on `PR_26180_OWNER_006-www-migration-map` | PASS | +| Startup validation completed | PASS | +| Project Instructions version loaded | PASS: `2026.06.28.007` | +| `PROJECT_BRANCHING_POLICY.md` loaded | PASS | +| `BACKLOG_MASTER.md` loaded | PASS | +| No browser-served files moved | PASS | +| Reports path uses `dev/reports/` | PASS | +| ZIP path uses `dev/workspace/zips/` | PASS | + +## Notes + +This is a stacked Owner PR based on `PR_26180_OWNER_006-www-migration-map`. diff --git a/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_manual-validation-notes.md b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_manual-validation-notes.md new file mode 100644 index 000000000..da5b9742b --- /dev/null +++ b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_manual-validation-notes.md @@ -0,0 +1,22 @@ +# PR_26180_OWNER_007 Manual Validation Notes + +## Manual Review + +- Confirmed no browser-served files were moved. +- Confirmed public URLs remain unchanged. +- Confirmed the future web-root activation toggle is documented as `GAMEFOUNDRY_LOCAL_WEB_ROOT=www`. +- Confirmed the default remains repository-root static serving until the actual `www/` move PR. +- Confirmed route-root compatibility work is limited to local static serving and test helper support. + +## Manual Test Guidance + +After applying this PR, Owner can test: + +1. Start the current local development flow with no web-root override. +2. Open `/index.html`, `/toolbox/index.html`, and an `/assets/...` URL. +3. Confirm the routes continue to load from the current repository-root layout. +4. In a future migration branch, set `GAMEFOUNDRY_LOCAL_WEB_ROOT=www` after browser files are moved and confirm the same public URLs load from `www/`. + +## Runtime Scope + +No UI, API, database, or product behavior changes are expected from this PR. diff --git a/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_report.md b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_report.md new file mode 100644 index 000000000..1107dbd3d --- /dev/null +++ b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_report.md @@ -0,0 +1,47 @@ +# PR_26180_OWNER_007-www-route-root-compatibility Report + +## Executive Summary + +PASS. This PR prepares local static route-root compatibility for the future `www/` migration without moving browser-served files or changing public URLs. + +The implementation adds a shared local static route resolver that can prefer a configurable web root while preserving the current repository-root serving default. The documented future toggle is: + +```text +GAMEFOUNDRY_LOCAL_WEB_ROOT=www +``` + +Default behavior remains current root serving when the variable is unset, empty, `.`, `root`, or `repo-root`. + +## Source Plan + +Used `dev/build/ProjectInstructions/repository/www_migration_map.md` as the source plan. The map now documents the route-root compatibility toggle and the default/future behavior split. + +## Changed Files + +- `dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS.md` +- `dev/build/ProjectInstructions/PROJECT_INSTRUCTIONS_VERSION.md` +- `dev/build/ProjectInstructions/PROJECT_STATE.md` +- `dev/build/ProjectInstructions/backlog/BACKLOG_MASTER.md` +- `dev/build/ProjectInstructions/repository/www_migration_map.md` +- `dev/scripts/start-dev.mjs` +- `dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs` +- `dev/tests/helpers/playwrightRepoServer.mjs` +- `dev/tests/playwright/tools/StaticWebRootCompatibility.spec.mjs` +- `src/dev-runtime/server/local-api-server.mjs` +- `src/dev-runtime/server/static-web-root.mjs` + +## Implementation Notes + +- Added `src/dev-runtime/server/static-web-root.mjs` for shared local static route resolution. +- Updated the local dev web server, Local API static fallback, and Playwright repo server helper to use the shared resolver. +- Added configurable `webRoot` support for test helpers and local server internals. +- Preserved public route URLs including `/index.html`, `/toolbox/index.html`, `/assets/...`, `/account/...`, `/admin/...`, and `/games/...`. +- Preserved compatibility alias behavior such as `/tools/...` resolving to `/toolbox/...`. +- Kept repository-root serving as the default until the actual `www/` move PR. +- No browser-served files were moved. +- No public URL changes were introduced. +- No API/server architecture changes were introduced. + +## Owner Recommendation + +Ready for review as the route-root compatibility preparation step before moving browser-served files into `www/`. diff --git a/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_requirement-checklist.md b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_requirement-checklist.md new file mode 100644 index 000000000..29f00532a --- /dev/null +++ b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_requirement-checklist.md @@ -0,0 +1,23 @@ +# PR_26180_OWNER_007 Requirement Checklist + +| Requirement | Result | Evidence | +|---|---|---| +| Use `www_migration_map.md` as source plan | PASS | Updated `dev/build/ProjectInstructions/repository/www_migration_map.md` with the compatibility toggle. | +| Preserve `/index.html` | PASS | Covered by Node and Playwright route-root tests. | +| Preserve `/toolbox/index.html` | PASS | Covered by Node and Playwright route-root tests. | +| Preserve `/assets/...` | PASS | Covered by Node and Playwright route-root tests. | +| Preserve `/account/...` | PASS | Covered by Node route normalization test. | +| Preserve `/admin/...` | PASS | Covered by Node route normalization test. | +| Preserve `/games/...` | PASS | Covered by Node route normalization test. | +| Add configurable web root support | PASS | Added shared `resolveLocalWebRoot` / `resolveStaticRouteTarget` helper. | +| Default remains current root serving | PASS | Default local web root is repository root. | +| Document future toggle | PASS | Documented `GAMEFOUNDRY_LOCAL_WEB_ROOT=www`. | +| Update targeted tests if needed | PASS | Added Node and Playwright route-root compatibility tests. | +| No browser files moved | PASS | No root HTML/pages/assets/toolbox files moved. | +| No public URL changes | PASS | URLs remain unchanged; only filesystem lookup is configurable. | +| No API/server architecture change | PASS | Local static resolution was shared; API architecture was not changed. | +| No product behavior change | PASS | Default local behavior remains root serving. | + +## Scope Result + +PASS. Changes are limited to route-root compatibility, tests/helpers, and governance/status documentation. diff --git a/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_validation-report.md b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_validation-report.md new file mode 100644 index 000000000..852613ab9 --- /dev/null +++ b/dev/reports/PR_26180_OWNER_007-www-route-root-compatibility_validation-report.md @@ -0,0 +1,24 @@ +# PR_26180_OWNER_007 Validation Report + +## Required Validation + +| Command | Result | +|---|---| +| `node --check src/dev-runtime/server/static-web-root.mjs` | PASS | +| `node --check dev/scripts/start-dev.mjs` | PASS | +| `node --check src/dev-runtime/server/local-api-server.mjs` | PASS | +| `node --check dev/tests/helpers/playwrightRepoServer.mjs` | PASS | +| `node --check dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs` | PASS | +| `node --check dev/tests/playwright/tools/StaticWebRootCompatibility.spec.mjs` | PASS | +| `node ./dev/scripts/run-node-test-files.mjs dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs` | PASS | +| `npx playwright test dev/tests/playwright/tools/StaticWebRootCompatibility.spec.mjs --reporter=line` | PASS: 2/2 | +| `git diff --check` | PASS | +| `npm run validate:canonical-structure` | PASS | + +## Notes + +An exploratory broader Playwright route/navigation smoke was also attempted with `dev/tests/playwright/tools/ToolNavigationPrevNext.spec.mjs`. Two route-opening tests passed; one registry coverage assertion failed on a non-scope "Registry entry missing for Sprites" condition. The required focused route-root compatibility lane passed and no registry changes are included in this PR. + +## Result + +PASS diff --git a/dev/reports/codex_changed_files.txt b/dev/reports/codex_changed_files.txt index 573b9513cdb5e5671c746f88608387b82b0c08ac..5dc4818136fddf568c53c39dcc6c85703bf5f14c 100644 GIT binary patch literal 1140 zcmcJN>uSP45QWe0LLVjO8CsEI@5EkyN)kn5V-t3x(wA@h%|;<$skA5|o9xWlIdkUh z*XKl2traWNN`*>I_jfQ%c=j%Y&z9452!zFsqwFc_zNn?=r8u6FtIYfqJ zEz;AHy5~A$Jw4l4FLac^+wz}=-E>~?4vwTQQ_6JTaG=tR*My(yr^OGINI=a`ha&6o-vLU^x^1g=eWgEPVBRLI(Oh}uy!x(wxmQFMO(-1%z}9L z4m9u^(oVGZBI3-7yJd_^vN4LSlI?T763I|s@ife)0#k3Z8Hkd%K|_zVt$c`e4z8re z6khA1W|vZ76V%K!r&2=JrZftubyyG8{i#k&E$h4tO#jp6L$7&oU^h)qulX+%^qszA k*elQ}--xwlViEnA{(WVCtK53tIu$WN^UC~e-Re&48`ezHod5s; literal 776 zcmbV~O;dv~5QgvlFDdAu;}sdk!4K#_)Z=A2_-30eh{PdhZz)Mu(q`1r(-s?eFZT7*r1WESVSs;eC!um9v;m}K>uJTRO1f6IM znL~t*^e1|L$k;hnxZT{`g^<5zNdaNFL7qWB4Dt+AhA7&ikx0P~rbU*HC`aTk3sTzh zVUg=N{zCN23Bxm7(NO< Jt(mCn)i=?P8x#Nl diff --git a/dev/reports/codex_review.diff b/dev/reports/codex_review.diff index 11855db253dc11f0f80c80f2f24fc2ee54ccfa26..05d5c4f628bdc44ffd9edeac910161b41cafdf3b 100644 GIT binary patch literal 50162 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 literal 20748 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 JL^hE#|35lZ9MJ#( diff --git a/dev/scripts/start-dev.mjs b/dev/scripts/start-dev.mjs index a58a53697..33e8420a8 100644 --- a/dev/scripts/start-dev.mjs +++ b/dev/scripts/start-dev.mjs @@ -7,6 +7,7 @@ import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { localAdminNotesHeaderPartialPath } from "../../src/dev-runtime/admin/admin-notes-menu.mjs"; import { startLocalApiServer } from "../../src/dev-runtime/server/local-api-server.mjs"; +import { resolveStaticRouteTarget } from "../../src/dev-runtime/server/static-web-root.mjs"; import { parseRoleArgument, parseTeamArgument, @@ -98,26 +99,6 @@ function contentTypeForPath(filePath) { return "application/octet-stream"; } -function isInsideRepoRoot(absolutePath) { - const relativePath = path.relative(repoRoot, absolutePath); - return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); -} - -function resolveBrowserRoutePath(decodedPath) { - const normalizedPath = path.normalize(decodedPath).replace(/^(\.\.[/\\])+/, ""); - const webPath = normalizedPath.replace(/\\/g, "/"); - if (webPath === "/") { - return "/index.html"; - } - if (webPath === "/tools" || webPath.startsWith("/tools/")) { - return `/toolbox${webPath.slice("/tools".length)}`; - } - if (webPath === "/admin/admin-notes.html") { - return "/src/dev-runtime/admin/notes.html"; - } - return normalizedPath; -} - function parseModeArgument(args = []) { const values = Array.from(args); for (let index = 0; index < values.length; index += 1) { @@ -280,6 +261,7 @@ export async function startStaticWebServer({ apiBaseUrl, host = DEFAULT_HOST, port, + webRoot, } = {}) { if (!apiBaseUrl) { throw new Error("startStaticWebServer requires apiBaseUrl."); @@ -292,19 +274,13 @@ export async function startStaticWebServer({ return; } const decodedPath = decodeURIComponent(requestUrl.pathname); - const normalizedPath = resolveBrowserRoutePath(decodedPath); - const absolutePath = path.resolve(repoRoot, `.${normalizedPath}`); - if (!isInsideRepoRoot(absolutePath)) { - response.statusCode = 403; - response.end("Forbidden"); + const routeTarget = await resolveStaticRouteTarget({ decodedPath, repoRoot, webRoot }); + if (!routeTarget.targetPath) { + response.statusCode = 404; + response.end("Not Found"); return; } - let targetPath = absolutePath; - const stat = await fs.stat(targetPath).catch(() => null); - if (stat && stat.isDirectory()) { - targetPath = path.join(targetPath, "index.html"); - } - targetPath = localAdminNotesHeaderPartialPath(repoRoot, targetPath); + const targetPath = localAdminNotesHeaderPartialPath(repoRoot, routeTarget.targetPath); const responseContents = await fs.readFile(targetPath); response.statusCode = 200; response.setHeader("Content-Type", contentTypeForPath(targetPath)); @@ -321,7 +297,7 @@ export async function startStaticWebServer({ }); return { - baseUrl: localUrl(host, port), + baseUrl: localUrl(host, server.address()?.port || port), close: async () => { await new Promise((resolve, reject) => { server.close((error) => { diff --git a/dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs b/dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs new file mode 100644 index 000000000..ac4d3e70f --- /dev/null +++ b/dev/tests/dev-runtime/StaticWebRootCompatibility.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { startStaticWebServer } from "../../scripts/start-dev.mjs"; +import { + DEFAULT_LOCAL_WEB_ROOT, + LOCAL_WEB_ROOT_ENV, + WWW_LOCAL_WEB_ROOT, + resolveBrowserRoutePath, + resolveLocalWebRoot, + resolveStaticRouteTarget, +} from "../../../src/dev-runtime/server/static-web-root.mjs"; + +const repoRoot = path.resolve(fileURLToPath(new URL("../../..", import.meta.url))); + +test("local web root defaults to repository root and can be pointed at www", () => { + const defaultRoot = resolveLocalWebRoot({ env: {}, repoRoot }); + assert.equal(defaultRoot.relativePath, DEFAULT_LOCAL_WEB_ROOT); + assert.equal(defaultRoot.absolutePath, repoRoot); + assert.equal(defaultRoot.source, "default"); + + const wwwRoot = resolveLocalWebRoot({ + env: { + [LOCAL_WEB_ROOT_ENV]: WWW_LOCAL_WEB_ROOT, + }, + repoRoot, + }); + assert.equal(wwwRoot.relativePath, WWW_LOCAL_WEB_ROOT); + assert.equal(wwwRoot.absolutePath, path.join(repoRoot, "www")); + assert.equal(wwwRoot.source, "configured"); +}); + +test("browser route compatibility preserves public URLs while normalizing filesystem lookup", () => { + assert.equal(resolveBrowserRoutePath("/"), "/index.html"); + assert.equal(resolveBrowserRoutePath("/index.html"), "/index.html"); + assert.equal(resolveBrowserRoutePath("/toolbox/index.html"), "/toolbox/index.html"); + assert.equal(resolveBrowserRoutePath("/tools/game-design/index.html"), "/toolbox/game-design/index.html"); + assert.equal(resolveBrowserRoutePath("/assets/theme-v2/css/theme.css"), "/assets/theme-v2/css/theme.css"); + assert.equal(resolveBrowserRoutePath("/account/sign-in.html"), "/account/sign-in.html"); + assert.equal(resolveBrowserRoutePath("/admin/system-health.html"), "/admin/system-health.html"); + assert.equal(resolveBrowserRoutePath("/games/index.html"), "/games/index.html"); +}); + +test("static route target can prefer a configured www root before repo-root fallback", async () => { + const routeTarget = await resolveStaticRouteTarget({ + decodedPath: "/index.html", + repoRoot, + webRoot: "www", + }); + assert.equal(routeTarget.routePath, "/index.html"); + assert.equal(routeTarget.targetPath, path.join(repoRoot, "index.html")); + assert.equal(routeTarget.webRoot.relativePath, "www"); +}); + +test("local static web server can serve from a configurable web root", async () => { + const fixtureRoot = path.join(repoRoot, "dev", "workspace", "tmp", "static-web-root-compatibility"); + await fs.rm(fixtureRoot, { force: true, recursive: true }); + await fs.mkdir(path.join(fixtureRoot, "assets"), { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "index.html"), "Fixture Root", "utf8"); + await fs.writeFile(path.join(fixtureRoot, "assets", "probe.txt"), "fixture asset", "utf8"); + + const server = await startStaticWebServer({ + apiBaseUrl: "http://127.0.0.1:1", + port: 0, + webRoot: fixtureRoot, + }); + + try { + const indexResponse = await fetch(`${server.baseUrl}/index.html`); + assert.equal(indexResponse.status, 200); + assert.match(await indexResponse.text(), /Fixture Root/); + + const assetResponse = await fetch(`${server.baseUrl}/assets/probe.txt`); + assert.equal(assetResponse.status, 200); + assert.equal(await assetResponse.text(), "fixture asset"); + } finally { + await server.close(); + await fs.rm(fixtureRoot, { force: true, recursive: true }); + } +}); diff --git a/dev/tests/helpers/playwrightRepoServer.mjs b/dev/tests/helpers/playwrightRepoServer.mjs index 6a8f0e059..1b2e9f070 100644 --- a/dev/tests/helpers/playwrightRepoServer.mjs +++ b/dev/tests/helpers/playwrightRepoServer.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { handleAdminNotesDirectoryRequest } from "../../../src/dev-runtime/admin/admin-notes-directory.mjs"; import { localAdminNotesHeaderPartialPath } from "../../../src/dev-runtime/admin/admin-notes-menu.mjs"; import { createLocalApiRouter } from "../../../src/dev-runtime/server/local-api-router.mjs"; +import { resolveStaticRouteTarget } from "../../../src/dev-runtime/server/static-web-root.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -73,26 +74,10 @@ function contentTypeForPath(filePath) { return "application/octet-stream"; } -function isInsideRepoRoot(absolutePath) { - const relativePath = path.relative(repoRoot, absolutePath); - return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); -} - -function resolveBrowserRoutePath(decodedPath) { - const normalizedPath = path.normalize(decodedPath).replace(/^(\.\.[/\\])+/, ""); - const webPath = normalizedPath.replace(/\\/g, "/"); - if (webPath === "/tools" || webPath.startsWith("/tools/")) { - return `/toolbox${webPath.slice("/tools".length)}`; - } - if (webPath === "/admin/admin-notes.html") { - return "/src/dev-runtime/admin/notes.html"; - } - return normalizedPath; -} - export async function startRepoServer({ gameJourneyCompletionMetricsPostgresClient = null, messagesPostgresClient = null, + webRoot, } = {}) { await loadRuntimeEnv(); const handleLocalApiRequest = createLocalApiRouter({ @@ -110,19 +95,13 @@ export async function startRepoServer({ return; } const decodedPath = decodeURIComponent(requestUrl.pathname); - const normalizedPath = resolveBrowserRoutePath(decodedPath); - const absolutePath = path.resolve(repoRoot, `.${normalizedPath}`); - if (!isInsideRepoRoot(absolutePath)) { - response.statusCode = 403; - response.end("Forbidden"); + const routeTarget = await resolveStaticRouteTarget({ decodedPath, repoRoot, webRoot }); + if (!routeTarget.targetPath) { + response.statusCode = 404; + response.end("Not Found"); return; } - let targetPath = absolutePath; - const stat = await fs.stat(targetPath).catch(() => null); - if (stat && stat.isDirectory()) { - targetPath = path.join(targetPath, "index.html"); - } - targetPath = localAdminNotesHeaderPartialPath(repoRoot, targetPath); + const targetPath = localAdminNotesHeaderPartialPath(repoRoot, routeTarget.targetPath); const responseContents = await fs.readFile(targetPath); response.statusCode = 200; response.setHeader("Content-Type", contentTypeForPath(targetPath)); diff --git a/dev/tests/playwright/tools/StaticWebRootCompatibility.spec.mjs b/dev/tests/playwright/tools/StaticWebRootCompatibility.spec.mjs new file mode 100644 index 000000000..072e277c9 --- /dev/null +++ b/dev/tests/playwright/tools/StaticWebRootCompatibility.spec.mjs @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { startRepoServer } from "../../helpers/playwrightRepoServer.mjs"; + +const repoRoot = path.resolve(fileURLToPath(new URL("../../../..", import.meta.url))); + +test("repo server preserves current public route URLs with default root serving", async ({ page }) => { + const server = await startRepoServer(); + try { + const homeResponse = await page.goto(`${server.baseUrl}/index.html`, { waitUntil: "domcontentloaded" }); + expect(homeResponse?.status()).toBe(200); + + const toolboxResponse = await page.goto(`${server.baseUrl}/toolbox/index.html`, { waitUntil: "domcontentloaded" }); + expect(toolboxResponse?.status()).toBe(200); + + const assetResponse = await page.request.get(`${server.baseUrl}/assets/theme-v2/css/theme.css`); + expect(assetResponse.status()).toBe(200); + } finally { + await server.close(); + } +}); + +test("repo server can prefer a configured web root while preserving public route URLs", async ({ page }) => { + const fixtureRoot = path.join(repoRoot, "dev", "workspace", "tmp", "playwright-static-web-root"); + await fs.rm(fixtureRoot, { force: true, recursive: true }); + await fs.mkdir(path.join(fixtureRoot, "assets"), { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "index.html"), "Fixture Web Root", "utf8"); + await fs.writeFile(path.join(fixtureRoot, "assets", "probe.txt"), "fixture asset", "utf8"); + + const server = await startRepoServer({ webRoot: fixtureRoot }); + try { + const homeResponse = await page.goto(`${server.baseUrl}/index.html`, { waitUntil: "domcontentloaded" }); + expect(homeResponse?.status()).toBe(200); + await expect(page).toHaveTitle("Fixture Web Root"); + + const assetResponse = await page.request.get(`${server.baseUrl}/assets/probe.txt`); + expect(assetResponse.status()).toBe(200); + expect(await assetResponse.text()).toBe("fixture asset"); + } finally { + await server.close(); + await fs.rm(fixtureRoot, { force: true, recursive: true }); + } +}); diff --git a/src/dev-runtime/server/local-api-server.mjs b/src/dev-runtime/server/local-api-server.mjs index e1ad1d97a..53867d1cd 100644 --- a/src/dev-runtime/server/local-api-server.mjs +++ b/src/dev-runtime/server/local-api-server.mjs @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { handleAdminNotesDirectoryRequest } from "../admin/admin-notes-directory.mjs"; import { localAdminNotesHeaderPartialPath } from "../admin/admin-notes-menu.mjs"; import { createLocalApiRouter } from "./local-api-router.mjs"; +import { resolveStaticRouteTarget } from "./static-web-root.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -34,26 +35,10 @@ function contentTypeForPath(filePath) { return "application/octet-stream"; } -function isInsideRepoRoot(absolutePath) { - const relativePath = path.relative(repoRoot, absolutePath); - return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); -} - -function resolveBrowserRoutePath(decodedPath) { - const normalizedPath = path.normalize(decodedPath).replace(/^(\.\.[/\\])+/, ""); - const webPath = normalizedPath.replace(/\\/g, "/"); - if (webPath === "/tools" || webPath.startsWith("/tools/")) { - return `/toolbox${webPath.slice("/tools".length)}`; - } - if (webPath === "/admin/admin-notes.html") { - return "/src/dev-runtime/admin/notes.html"; - } - return normalizedPath; -} - export async function startLocalApiServer({ host = "127.0.0.1", port = 5501, + webRoot, } = {}) { const handleApiRuntimeRequest = createLocalApiRouter({ repoRoot }); const server = http.createServer(async (request, response) => { @@ -66,19 +51,13 @@ export async function startLocalApiServer({ return; } const decodedPath = decodeURIComponent(requestUrl.pathname); - const normalizedPath = resolveBrowserRoutePath(decodedPath); - const absolutePath = path.resolve(repoRoot, `.${normalizedPath}`); - if (!isInsideRepoRoot(absolutePath)) { - response.statusCode = 403; - response.end("Forbidden"); + const routeTarget = await resolveStaticRouteTarget({ decodedPath, repoRoot, webRoot }); + if (!routeTarget.targetPath) { + response.statusCode = 404; + response.end("Not Found"); return; } - let targetPath = absolutePath; - const stat = await fs.stat(targetPath).catch(() => null); - if (stat && stat.isDirectory()) { - targetPath = path.join(targetPath, "index.html"); - } - targetPath = localAdminNotesHeaderPartialPath(repoRoot, targetPath); + const targetPath = localAdminNotesHeaderPartialPath(repoRoot, routeTarget.targetPath); const responseContents = await fs.readFile(targetPath); response.statusCode = 200; response.setHeader("Content-Type", contentTypeForPath(targetPath)); diff --git a/src/dev-runtime/server/static-web-root.mjs b/src/dev-runtime/server/static-web-root.mjs new file mode 100644 index 000000000..0c964d53f --- /dev/null +++ b/src/dev-runtime/server/static-web-root.mjs @@ -0,0 +1,99 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +export const LOCAL_WEB_ROOT_ENV = "GAMEFOUNDRY_LOCAL_WEB_ROOT"; +export const DEFAULT_LOCAL_WEB_ROOT = "."; +export const WWW_LOCAL_WEB_ROOT = "www"; + +export function isInsideRoot(rootPath, absolutePath) { + const relativePath = path.relative(rootPath, absolutePath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function normalizeWebRootValue(value) { + const normalizedValue = String(value || "").trim(); + if (!normalizedValue || normalizedValue === "." || normalizedValue === "root" || normalizedValue === "repo-root") { + return DEFAULT_LOCAL_WEB_ROOT; + } + return normalizedValue.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "") || DEFAULT_LOCAL_WEB_ROOT; +} + +export function resolveLocalWebRoot({ + env = process.env, + repoRoot, + webRoot, +} = {}) { + if (!repoRoot) { + throw new Error("resolveLocalWebRoot requires repoRoot."); + } + const resolvedRepoRoot = path.resolve(repoRoot); + const configuredValue = normalizeWebRootValue(webRoot ?? env?.[LOCAL_WEB_ROOT_ENV]); + const absolutePath = path.resolve(resolvedRepoRoot, configuredValue); + if (!isInsideRoot(resolvedRepoRoot, absolutePath)) { + throw new Error(`${LOCAL_WEB_ROOT_ENV} must resolve inside the repository root.`); + } + return Object.freeze({ + absolutePath, + envName: LOCAL_WEB_ROOT_ENV, + relativePath: configuredValue, + source: webRoot === undefined && env?.[LOCAL_WEB_ROOT_ENV] === undefined ? "default" : "configured", + }); +} + +export function resolveBrowserRoutePath(decodedPath) { + const normalizedPath = path.normalize(decodedPath || "/").replace(/^(\.\.[/\\])+/, ""); + const webPath = normalizedPath.replace(/\\/g, "/"); + if (webPath === "/") { + return "/index.html"; + } + if (webPath === "/tools" || webPath.startsWith("/tools/")) { + return `/toolbox${webPath.slice("/tools".length)}`; + } + if (webPath === "/admin/admin-notes.html") { + return "/src/dev-runtime/admin/notes.html"; + } + return webPath; +} + +function staticRouteCandidateRoots({ repoRoot, webRootConfig }) { + const roots = [webRootConfig.absolutePath]; + if (webRootConfig.absolutePath !== repoRoot) { + roots.push(repoRoot); + } + return roots; +} + +export async function resolveStaticRouteTarget({ + decodedPath, + fsStat = fs.stat, + repoRoot, + webRoot, +} = {}) { + const resolvedRepoRoot = path.resolve(repoRoot); + const webRootConfig = resolveLocalWebRoot({ repoRoot: resolvedRepoRoot, webRoot }); + const routePath = resolveBrowserRoutePath(decodedPath); + for (const rootPath of staticRouteCandidateRoots({ repoRoot: resolvedRepoRoot, webRootConfig })) { + const absolutePath = path.resolve(rootPath, `.${routePath}`); + if (!isInsideRoot(rootPath, absolutePath)) { + continue; + } + let targetPath = absolutePath; + let stat = await fsStat(targetPath).catch(() => null); + if (stat && stat.isDirectory()) { + targetPath = path.join(targetPath, "index.html"); + stat = await fsStat(targetPath).catch(() => null); + } + if (stat && stat.isFile()) { + return Object.freeze({ + routePath, + targetPath, + webRoot: webRootConfig, + }); + } + } + return Object.freeze({ + routePath, + targetPath: "", + webRoot: webRootConfig, + }); +}