- commit
- 4ff244208d2df0f846ba8f3922a81cbd3b3180d9
- parent
- d27ab0feae1cb1882e6c20feff8b5aad66dbe33c
- author
- Dami <dami.etoile@gmail.com>
| M | apps/ | 8 | +++-- |
| M | docs/ | 16 | +++++++++++ |
| M | docs/ | 1 | + |
| M | package/ | 3 | ++ |
| M | package/ | 3 | ++ |
| M | package/ | 7 | ++++- |
| M | package/ | 2 | +- |
| M | package/ | 15 | +++++++--- |
| M | package/ | 10 | ++++++- |
| M | package/ | 6 | +++- |
| M | package/ | 1 | + |
| M | package/ | 25 | ++++++++++++++++- |
| M | package/ | 36 | +++++++++++++++++++++++- |
| M | package/ | 15 | ++++++++++ |
| M | package/ | 2 | + |
| M | package/ | 6 | ++-- |
| A | package/ | 34 | +++++++++++++++++++++++ |
| A | package/ | 44 | ++++++++++++++++++++++++++++++ |
| M | package/ | 3 | +- |
| M | package/ | 33 | +++++++++++++++++++++++ |
| M | package/ | 19 | ++++++++++++- |
@@ -5,6 +5,8 @@ export interface Props {
55 }66 77 const { title } = Astro.props;8+const deploymentBase = import.meta.env.BASE_URL;9+const publicAsset = (name: string) => `${deploymentBase}${name}`;810 ---911 1012 <!doctype html>@@ -13,9 +15,9 @@ const { title } = Astro.props;
1315 <meta charset="UTF-8" />1416 <meta name="viewport" content="width=device-width" />1517 <meta name="theme-color" content="#18231f" />16- <link rel="icon" href="./favicon.svg" type="image/svg+xml" />17- <link rel="apple-touch-icon" href="./apple-touch-icon.png" />18- <link rel="manifest" href="./manifest.webmanifest" />18+ <link rel="icon" href={publicAsset("favicon.svg")} type="image/svg+xml" />19+ <link rel="apple-touch-icon" href={publicAsset("apple-touch-icon.png")} />20+ <link rel="manifest" href={publicAsset("manifest.webmanifest")} />1921 <title>{title}</title>2022 </head>2123 <body>@@ -44,6 +44,18 @@ Before deploying, review:
4444 - page titles, descriptions, and starter copy;4545 - `.env` — either no public Jazz pair for local-only mode or a complete pair for optional sync.4646 47+The default deployment base is `/`. When a static host mounts the application below the origin root,48+set the path once in `.env` before running `dev` or `build`:49+50+```dotenv51+LOFI_BASE_PATH=/field-notes/52+```53+54+The value must be an absolute path, not an origin. Lofi feeds it into Astro's `base` setting and55+uses the resulting base for public asset links, the manifest, the service worker, its scope, build56+identity, and local preview. Upload the contents of `dist/` to that same mount point. A root build57+and a subpath build are different deployment artifacts; rebuild after changing `LOFI_BASE_PATH`.58+4759 Run `deno task doctor` and `deno task test` before the production build.4860 4961 ## Deno Deploy@@ -72,6 +84,10 @@ Upload the contents of `dist/` to any host that can:
7284 - keep the service worker at the intended scope;7385 - fall back to the appropriate prerendered HTML for application routes.7486 87+Every prerendered route is included in the shell precache. While offline, a direct navigation such88+as `/field-notes/settings/` resolves its cached `settings/index.html`; if that route was not89+emitted, the worker falls back to the cached application root.90+7591 Do not run a server-side Jazz credential in the static host or expose `JAZZ_ADMIN_SECRET` or7692 `BACKEND_SECRET` as public environment variables.7793 @@ -26,6 +26,7 @@ offering passkey backup to real users.
2626 2727 | Name | Classification | Behavior |2828 | ------------------- | -------------- | ----------------------------------------- |29+| `LOFI_BASE_PATH` | Public build | Mount path; defaults to `/` |2930 | `JAZZ_APP_ID` | Client-visible | Selects the managed Jazz application |3031 | `JAZZ_SERVER_URL` | Client-visible | Selects its sync endpoint |3132 | `JAZZ_ADMIN_SECRET` | Server-only | Used by schema and administrative tooling |@@ -82,6 +82,7 @@ const cloudServerUrl = process.env.JAZZ_SERVER_URL?.trim();
8282 const managedJazzServer = process.env.LOFI_SKIP_JAZZ_MANAGED === "1"8383 ? false8484 : { allowLocalFirstAuth: true };85+const deploymentBase = process.env.LOFI_BASE_PATH || "/";8586 8687 if (Boolean(cloudAppId) !== Boolean(cloudServerUrl)) {8788 throw new Error(@@ -95,7 +96,9 @@ if (cloudAppId && cloudServerUrl) {
9596 }9697 9798 export default defineConfig({99+ base: deploymentBase,98100 output: "static",101+ trailingSlash: "always",99102 integrations: [preact({ compat: true, devtools: true })],100103 vite: {101104 plugins: [jazzPlugin({ schemaDir, inspector: false, server: managedJazzServer })],@@ -11,6 +11,9 @@ Deno.test("prepareLofiAstroConfig materializes package-owned vendor integration"
1111 "@astrojs/preact",1212 "jazz-tools/dev/vite",1313 "JAZZ_APP_ID",14+ "LOFI_BASE_PATH",15+ "base: deploymentBase",16+ 'trailingSlash: "always"',1417 ".lofi/package/runtime/mod.ts",1518 "^jsr:@nzip\\/lofi@[^/]+",1619 "^npm:preact@[^/]+",@@ -44,7 +44,12 @@ exitOnFailure(
4444 await Deno.writeTextFile(4545 "dist/lofi-build.json",4646 `${47- JSON.stringify({ lofiVersion: LOFI_VERSION, sourceHash, builtAt: new Date().toISOString() })47+ JSON.stringify({48+ lofiVersion: LOFI_VERSION,49+ sourceHash,50+ basePath: environment.LOFI_BASE_PATH,51+ builtAt: new Date().toISOString(),52+ })4853 }\n`,4954 );5055 const serviceWorkerPath = "dist/sw.js";@@ -28,7 +28,7 @@ console.log(
2828 : "local-only"2929 }`,3030 );31-console.log("PWA: development service worker disabled");31+console.log(`PWA: development service worker disabled; base ${environment.LOFI_BASE_PATH}`);3232 console.log("Device: stable HTTPS URL not configured; device preview graduates in M3");3333 3434 const forwarded = Deno.args[0] === "--" ? Deno.args.slice(1) : Deno.args;@@ -8,6 +8,7 @@
88 */99 1010 import { extname, join, normalize } from "node:path";11+import { normalizeDeploymentBase, pathWithinDeploymentBase } from "../tooling/base-path.ts";1112 1213 const portFlag = Deno.args.findIndex((argument) => argument === "--port");1314 const port = portFlag >= 0 ? Number(Deno.args[portFlag + 1]) : 4321;@@ -16,7 +17,7 @@ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
1617 Deno.exit(2);1718 }1819 19-let identity: { lofiVersion?: string; sourceHash?: string };20+let identity: { lofiVersion?: string; sourceHash?: string; basePath?: string };2021 try {2122 identity = JSON.parse(await Deno.readTextFile("dist/lofi-build.json"));2223 } catch {@@ -27,6 +28,7 @@ if (!identity.sourceHash) {
2728 console.error("error: production build identity is invalid; run `deno task build` again");2829 Deno.exit(1);2930 }31+const basePath = normalizeDeploymentBase(identity.basePath);3032 3133 const contentTypes: Record<string, string> = {3234 ".css": "text/css; charset=utf-8",@@ -40,12 +42,17 @@ const contentTypes: Record<string, string> = {
4042 };4143 4244 console.log(43- `lofi preview: http://127.0.0.1:${port}/ (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,45+ `lofi preview: http://127.0.0.1:${port}${basePath} (build ${identity.sourceHash}, @nzip/lofi ${identity.lofiVersion})`,4446 );4547 Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {4648 const url = new URL(request.url);47- let pathname = decodeURIComponent(url.pathname);48- if (pathname.endsWith("/")) pathname += "index.html";49+ if (basePath !== "/" && url.pathname === basePath.slice(0, -1)) {50+ return Response.redirect(new URL(basePath, url.origin), 308);51+ }52+ const scopedPath = pathWithinDeploymentBase(url.pathname, basePath);53+ if (scopedPath === null) return new Response("Not found", { status: 404 });54+ let pathname = decodeURIComponent(scopedPath);55+ if (pathname === "" || pathname.endsWith("/")) pathname += "index.html";4956 const relative = normalize(pathname).replace(/^[/\\]+/, "");5057 if (relative.startsWith("..")) return new Response("Not found", { status: 404 });5158 const path = join("dist", relative);@@ -32,7 +32,11 @@ test-results/
3232 `;3333 3434 const generatedEnvironment =35- `# Optional public Jazz cloud configuration. Leave both blank for local-only mode.35+ `# Public deployment base. Keep / for an origin-root deployment, or use /my-app/ when36+# the static host mounts this build below the origin root.37+LOFI_BASE_PATH=/38+39+# Optional public Jazz cloud configuration. Leave both blank for local-only mode.3640 # Run \`deno task jazz:provision\` to generate a managed Jazz app and fill these in.3741 JAZZ_APP_ID=3842 JAZZ_SERVER_URL=@@ -95,6 +99,10 @@ static site — they push the built \`dist/\` as the deploy root, which serves i
9599 - \`deno task deploy\` — thereafter: build and push \`dist/\`.96100 97101 Point them at any other static host by editing those two tasks.102+103+Deployments use the origin root by default. If the host mounts the app below it, copy104+\`.env.example\`, set \`LOFI_BASE_PATH=/my-app/\`, and rebuild. Upload \`dist/\` at that same path;105+the manifest, public assets, service worker, worker scope, and preview URL all follow this value.98106 `;99107 }100108 @@ -49,7 +49,9 @@ Deno.test("starter manifest references install-grade raster icons with exact dim
4949 new URL("../apps/reference/src/layouts/Shell.astro", import.meta.url),5050 );5151 assert(52- shell.includes('rel="apple-touch-icon" href="./apple-touch-icon.png"'),53- "iOS icon is not linked",52+ shell.includes("const deploymentBase = import.meta.env.BASE_URL") &&53+ shell.includes('publicAsset("apple-touch-icon.png")') &&54+ shell.includes('publicAsset("manifest.webmanifest")'),55+ "install assets are not linked through the deployment base",5456 );5557 });@@ -70,6 +70,9 @@ export type PwaControllerDependencies = {
7070 readonly serviceWorker?: () => ServiceWorkerContainer | undefined;7171 readonly installEnvironment?: () => InstallEnvironment;7272 readonly production?: () => boolean;73+ /** Absolute URL of the configured deployment base, independent of the current route. */74+ readonly deploymentBaseUrl?: () => string;75+ /** @deprecated Supply deploymentBaseUrl when injecting browser location in tests. */7376 readonly documentBaseURI?: () => string;7477 readonly reload?: () => void;7578 readonly exposeState?: (state: PwaState) => void;@@ -84,6 +87,18 @@ export type PwaController = {
8487 initialize(): void;8588 };8689 90+/** Resolves the worker URL and scope from one configured application base. */91+export function resolvePwaResources(deploymentBaseUrl: string): {92+ workerUrl: URL;93+ scope: string;94+} {95+ const base = new URL(deploymentBaseUrl);96+ if (!base.pathname.endsWith("/")) base.pathname += "/";97+ base.search = "";98+ base.hash = "";99+ return { workerUrl: new URL("sw.js", base), scope: base.pathname };100+}101+87102 export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {88103 if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";89104 const iosLike = /^(iPhone|iPad|iPod)$/.test(environment.platform) ||@@ -204,10 +219,16 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
204219 if (dependencies.reload) dependencies.reload();205220 else location.reload();206221 });207- const workerUrl = new URL("./sw.js", dependencies.documentBaseURI?.() ?? document.baseURI);222+ const deploymentBaseUrl = dependencies.deploymentBaseUrl?.() ?? (() => {223+ const locationOrigin = dependencies.documentBaseURI224+ ? new URL(dependencies.documentBaseURI()).origin225+ : document.location.origin;226+ return new URL(import.meta.env.BASE_URL, locationOrigin).href;227+ })();228+ const { workerUrl, scope } = resolvePwaResources(deploymentBaseUrl);208229 void (async () => {209230 const registration = await container.register(workerUrl, {210- scope: new URL("./", dependencies.documentBaseURI?.() ?? document.baseURI).pathname,231+ scope,211232 });212233 watchForUpdate(registration, container);213234 if (registration.waiting && container.controller) return;@@ -5,6 +5,7 @@ import {
55 type PwaController,66 type PwaInstallState,77 type PwaState,8+ resolvePwaResources,89 waitForActivation,910 } from "./pwa.ts";1011 @@ -41,8 +42,12 @@ class FakeContainer extends EventTarget {
4142 controller: ServiceWorker | null = null;4243 registration = new FakeRegistration();4344 registerError: Error | undefined;45+ registeredUrl: string | undefined;46+ registeredScope: string | undefined;4447 45- register(): Promise<ServiceWorkerRegistration> {48+ register(url: string | URL, options?: RegistrationOptions): Promise<ServiceWorkerRegistration> {49+ this.registeredUrl = String(url);50+ this.registeredScope = options?.scope;4651 if (this.registerError) return Promise.reject(this.registerError);4752 return Promise.resolve(this.registration as unknown as ServiceWorkerRegistration);4853 }@@ -58,6 +63,7 @@ function testController(options: {
5863 target?: EventTarget;5964 install?: PwaInstallState;6065 reload?: () => void;66+ deploymentBaseUrl?: string;6167 } = {}): PwaController {6268 const environment = options.install === "manual-ios"6369 ? { platform: "iPhone", maxTouchPoints: 5 }@@ -71,11 +77,37 @@ function testController(options: {
7177 ...environment,7278 }),7379 production: () => options.production ?? false,74- documentBaseURI: () => "http://127.0.0.1:4321/",80+ deploymentBaseUrl: () => options.deploymentBaseUrl ?? "http://127.0.0.1:4321/",7581 reload: options.reload,7682 });7783 }7884 85+test("PWA resources resolve from the deployment base instead of the current route", () => {86+ const resources = resolvePwaResources("https://example.com/field-notes");87+ if (resources.workerUrl.href !== "https://example.com/field-notes/sw.js") {88+ throw new Error(`unexpected worker URL ${resources.workerUrl.href}`);89+ }90+ if (resources.scope !== "/field-notes/") throw new Error(`unexpected scope ${resources.scope}`);91+});92+93+test("nested direct visits register the worker at the configured non-root base", async () => {94+ const container = new FakeContainer();95+ container.registration.active = asServiceWorker(new FakeServiceWorker("activated"));96+ const controller = testController({97+ container,98+ production: true,99+ deploymentBaseUrl: "https://example.com/field-notes/",100+ });101+ controller.initialize();102+ await waitForState(controller, (state) => state.worker === "ready");103+ if (container.registeredUrl !== "https://example.com/field-notes/sw.js") {104+ throw new Error(`registered the wrong worker URL: ${container.registeredUrl}`);105+ }106+ if (container.registeredScope !== "/field-notes/") {107+ throw new Error(`registered the wrong scope: ${container.registeredScope}`);108+ }109+});110+79111 function waitForState(controller: PwaController, predicate: (state: PwaState) => boolean) {80112 return new Promise<PwaState>((resolve) => {81113 const unsubscribe = controller.subscribe((state) => {@@ -41,6 +41,17 @@ self.addEventListener("activate", (event) => {
4141 })());4242 });4343 44+function cachedPrerenderedNavigation(request) {45+ const requestUrl = new URL(request.url);46+ const scope = new URL(self.registration.scope);47+ if (requestUrl.origin !== scope.origin || !requestUrl.pathname.startsWith(scope.pathname)) {48+ return undefined;49+ }50+ const route = requestUrl.pathname.slice(scope.pathname.length).replace(/^\/+|\/+$/g, "");51+ if (!route) return caches.match(scope);52+ return caches.match(new URL(`${route}/index.html`, scope));53+}54+4455 self.addEventListener("fetch", (event) => {4556 if (event.request.method !== "GET") return;4657 const requestUrl = new URL(event.request.url);@@ -50,6 +61,10 @@ self.addEventListener("fetch", (event) => {
5061 ignoreSearch: event.request.mode === "navigate",5162 });5263 if (cached) return cached;64+ if (event.request.mode === "navigate") {65+ const prerendered = await cachedPrerenderedNavigation(event.request);66+ if (prerendered) return prerendered;67+ }5368 try {5469 const response = await fetch(event.request);5570 if (response.ok) {@@ -19,6 +19,8 @@ Deno.test("service worker uses cached navigation shell as the offline fallback",
1919 for (2020 const contract of [2121 'event.request.mode === "navigate"',22+ "cachedPrerenderedNavigation(event.request)",23+ "new URL(`${route}/index.html`, scope)",2224 'caches.match(new URL("./", self.registration.scope))',2325 'reportFailure(\n "runtime-cache"',2426 ]@@ -1,5 +1,5 @@
11 {2- ".env.example": "9b21194b6be854a0c3c14ce03e664a81d7102924c3bc3db00e51464df44a1c84",2+ ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",44 "deno.json": "ee47812226531b9043efcaf8e511bb66a801257a48d995dbcaf4dbac2a52d87b",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",@@ -8,13 +8,13 @@
88 "public/icon-512.png": "b943095ef43892d24974dabb7fe68221f365af749e6fd455a0d0296f08ecd854",99 "public/icon-maskable-512.png": "c61dfca524605cfc073f1725af358599ce0aa38d4637fe5d3c5c59230ecbd3e2",1010 "public/manifest.webmanifest": "abbbf47b9f016b621ac14c9d71867bad79be944a5224482d1add3d137fd823d5",11- "README.md": "4fde6743e26c947f74fa028c8ecc991ab7f0cb3ee99b8b8b1da01411665911f6",11+ "README.md": "4d4f37ec8f4cfc7dc127d7c3b0c74fd22658dd6ba9f59229316367095ebcdfd7",1212 "src/app.ts": "4c4c6c3f6ad4123f6cffc7a480965c4ea38442676c2af662d1bbfe082d40194f",1313 "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da",1414 "src/islands/AccountGate.tsx": "ef541886720e984f989c2054266be8d2272b6c43fea722bde8ce615352cc0210",1515 "src/islands/TaskList.tsx": "b38d32ecc08c60e1bef3c70e3e96914df1a2037419253324c4ac48443531f7e7",1616 "src/islands/use-tasks.ts": "2ad1649b64f677bb042861d988f54736e5442bae8f4e335fc0e50f51b2a260f1",17- "src/layouts/Shell.astro": "7bad8419d4ad8f89294c8c145fd2d710cde43f3bc6ebc40fd95793786bac5f1e",17+ "src/layouts/Shell.astro": "12144355def66c7bf6ed5b0d611ba3a5454cf7499b667936aa6c04494807653a",1818 "src/pages/index.astro": "de9ad86f9b25f7aff1e293931a785dbe393e4d0a35464ffbb649a0ebb4518281",1919 "src/permissions.ts": "dcb00b7f0576d04ad527b8892b8096573b107c3130ed6d835243251c406a38b7",2020 "src/schema.ts": "f2c555b088ded8eea2653f5d955fc9aae69dfd588e891adf509e99052f1e9949",@@ -0,0 +1,34 @@
1+/** Normalizes the public URL path where a generated application is mounted. */2+export function normalizeDeploymentBase(value: string | undefined): string {3+ const input = value?.trim() || "/";4+ if (!input.startsWith("/") || input.startsWith("//")) {5+ throw new Error("LOFI_BASE_PATH must be an absolute URL path beginning with one '/'");6+ }7+ if (input.includes("?") || input.includes("#") || input.includes("\\")) {8+ throw new Error("LOFI_BASE_PATH must not contain a query, fragment, or backslash");9+ }10+ const segments = input.split("/").filter(Boolean);11+ const encodedSegments: string[] = [];12+ for (const segment of segments) {13+ let decoded: string;14+ try {15+ decoded = decodeURIComponent(segment);16+ } catch {17+ throw new Error("LOFI_BASE_PATH contains invalid URL encoding");18+ }19+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\")) {20+ throw new Error("LOFI_BASE_PATH must not contain traversal or encoded path separators");21+ }22+ encodedSegments.push(encodeURIComponent(decoded));23+ }24+ return encodedSegments.length === 0 ? "/" : `/${encodedSegments.join("/")}/`;25+}26+27+/** Returns the request path relative to the configured deployment base. */28+export function pathWithinDeploymentBase(pathname: string, base: string): string | null {29+ const normalizedBase = normalizeDeploymentBase(base);30+ if (normalizedBase === "/") return pathname.replace(/^\/+/, "");31+ if (pathname === normalizedBase.slice(0, -1)) return "";32+ if (!pathname.startsWith(normalizedBase)) return null;33+ return pathname.slice(normalizedBase.length);34+}@@ -0,0 +1,44 @@
1+import { normalizeDeploymentBase, pathWithinDeploymentBase } from "./base-path.ts";2+3+function assertEquals(actual: unknown, expected: unknown): void {4+ if (actual !== expected) {5+ throw new Error(`expected ${String(expected)}, received ${String(actual)}`);6+ }7+}8+9+Deno.test("deployment bases are absolute and have one trailing slash", () => {10+ assertEquals(normalizeDeploymentBase(undefined), "/");11+ assertEquals(normalizeDeploymentBase("/"), "/");12+ assertEquals(normalizeDeploymentBase(" /field-notes "), "/field-notes/");13+ assertEquals(normalizeDeploymentBase("/teams/notes/"), "/teams/notes/");14+});15+16+Deno.test("deployment bases reject origins, traversal, and URL suffixes", () => {17+ for (18+ const input of [19+ "notes",20+ "//example.com/notes",21+ "/../notes",22+ "/%2e%2e/notes",23+ "/notes?x=1",24+ "/notes#top",25+ "/notes\\admin",26+ ]27+ ) {28+ let rejected = false;29+ try {30+ normalizeDeploymentBase(input);31+ } catch {32+ rejected = true;33+ }34+ if (!rejected) throw new Error(`expected ${JSON.stringify(input)} to be rejected`);35+ }36+});37+38+Deno.test("request paths resolve only inside the deployment base", () => {39+ assertEquals(pathWithinDeploymentBase("/field-notes/", "/field-notes/"), "");40+ assertEquals(pathWithinDeploymentBase("/field-notes/settings/", "/field-notes/"), "settings/");41+ assertEquals(pathWithinDeploymentBase("/field-notes", "/field-notes/"), "");42+ assertEquals(pathWithinDeploymentBase("/other/", "/field-notes/"), null);43+ assertEquals(pathWithinDeploymentBase("/settings/", "/"), "settings/");44+});@@ -102,7 +102,8 @@ export async function doctorReport(
102102 {103103 name: "PWA",104104 status: "pending",105- detail: "development service worker disabled; production activation checked in browser",105+ detail:106+ `deployment base ${validation.basePath}; development service worker disabled; production activation checked in browser`,106107 },107108 {108109 name: "Device URL",@@ -80,3 +80,36 @@ Deno.test("doctor blocks unsupported Deno and incomplete generated layouts", asy
8080 await Deno.remove(root, { recursive: true });8181 }8282 });83+84+Deno.test("doctor normalizes a deployment base and blocks an unsafe one", async () => {85+ const cwd = await makeTestRoot();86+ try {87+ const project = await createProject({ cwd, name: "starter" });88+ const valid = await doctorReport({89+ root: project.destination,90+ environment: { LOFI_BASE_PATH: "/field-notes" },91+ denoVersion: "2.9.0",92+ });93+ assert(!valid.blocked, "valid non-root deployment base should pass doctor");94+ assert(valid.validation.basePath === "/field-notes/", "deployment base was not normalized");95+ assert(96+ valid.diagnostics.some((item) =>97+ item.name === "PWA" && item.detail.includes("/field-notes/")98+ ),99+ "doctor did not report the deployment base",100+ );101+102+ const invalid = await doctorReport({103+ root: project.destination,104+ environment: { LOFI_BASE_PATH: "https://example.com/field-notes" },105+ denoVersion: "2.9.0",106+ });107+ assert(invalid.blocked, "origin-like deployment base should block doctor");108+ assert(109+ invalid.diagnostics.some((item) => item.detail.includes("LOFI_BASE_PATH")),110+ "doctor did not name the invalid deployment base",111+ );112+ } finally {113+ await Deno.remove(cwd, { recursive: true });114+ }115+});@@ -1,10 +1,18 @@
1+import { normalizeDeploymentBase } from "./base-path.ts";2+13 export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;24 export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;3-export const environmentNames = [...clientEnvironmentNames, ...serverEnvironmentNames] as const;5+export const deploymentEnvironmentNames = ["LOFI_BASE_PATH"] as const;6+export const environmentNames = [7+ ...clientEnvironmentNames,8+ ...serverEnvironmentNames,9+ ...deploymentEnvironmentNames,10+] as const;411 512 interface EnvironmentResultBase {613 presentClientNames: string[];714 presentServerNames: string[];15+ basePath: string;816 warnings: string[];917 }1018 @@ -39,6 +47,13 @@ export function validateEnvironment(
3947 const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));4048 const errors: string[] = [];4149 const warnings: string[] = [];50+ let basePath = "/";51+52+ try {53+ basePath = normalizeDeploymentBase(env.LOFI_BASE_PATH);54+ } catch (error) {55+ errors.push(error instanceof Error ? error.message : String(error));56+ }4257 4358 if (4459 presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length@@ -69,6 +84,7 @@ export function validateEnvironment(
6984 const base: EnvironmentResultBase = {7085 presentClientNames: [...presentClientNames],7186 presentServerNames: [...presentServerNames],87+ basePath,7288 warnings,7389 };7490 if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };@@ -161,6 +177,7 @@ export function childEnvironment(
161177 environment.JAZZ_APP_ID = "";162178 environment.JAZZ_SERVER_URL = "";163179 }180+ environment.LOFI_BASE_PATH = validation.basePath;164181 environment.JAZZ_ADMIN_SECRET = "";165182 environment.BACKEND_SECRET = "";166183 return environment;