Skip to content

fix(template): build apps with relative asset URLs - #18

Closed
neuromaxer wants to merge 1 commit into
mainfrom
fix/template-relative-base
Closed

neuromaxer wants to merge 1 commit into
mainfrom
fix/template-relative-base

Conversation

@neuromaxer

Copy link
Copy Markdown
Contributor

The problem

Vite defaults to root-absolute asset paths, so a built app emits:

<script src="/assets/index-abc.js">

…and its JS chunks reference each other the same way. That works only when the app is served from the root of its own origin.

A control plane that previews a running app by proxying it under a sub-path — /apps/<id>/preview/ — gets the HTML back correctly, and then the browser asks for /assets/index-abc.js at the proxy's root:

1. GET /api/apps/<id>/preview/        ✅ HTML returned
2. HTML says src="/assets/index-abc.js"
3. GET /assets/index-abc.js           ❌ dashboard root — 404

Blank frame, no obvious cause.

Rewriting the HTML in the proxy does not fix this. The emitted JS chunks resolve sibling assets absolutely too, and a code-split app computes some of those URLs at runtime, so no text substitution catches them all. It would appear to work with the current single-chunk template and fail unpredictably as apps grow.

The fix

base: "./" makes every emitted reference relative, so one build works at its own origin, on a subdomain, or under an arbitrary prefix — and the proxy stays a plain byte pipe with no rewriting in it.

+ base: "./",
  server: { host: "0.0.0.0" },

Verified with a real build

dist/index.html →  src="./assets/index-Daj7akpb.js"       relative ✅

served from /api/apps/abc/preview/:
  GET /api/apps/abc/preview/                       → src="./assets/index-Daj7akpb.js"
  GET /api/apps/abc/preview/assets/index-Daj7akpb.js → http=200  ✅

Existing projects pick this up on their next DEV rebuild, which the deploy-app skill performs on every refinement.

Consumer context, and the ideal future state

openorange wants a live preview pane next to its builder chat. Since apps bind to host loopback and there is no public app hosting yet, the browser cannot reach them directly, so the preview goes through an authorized proxy under a dashboard path — which is exactly the case above.

Worth being explicit that a path-based preview is the interim design, not the target. appx solves this with per-app subdomains (<name>-dev.<domain>), which is better on two counts:

  1. Origin isolation for free. A subdomain is a different origin, so agent-written JavaScript is confined by the browser's own rules. A path on the dashboard origin is not — the app would share the dashboard's origin, so a preview iframe there needs sandbox without allow-same-origin, and that one attribute is the whole boundary. It works, but it is a single point of failure that someone could plausibly "fix" while debugging an app.
  2. Absolute paths would have been fine. At https://app-dev.example.com/, /assets/… resolves to the app's own root. The problem this PR fixes is created by path-based serving.

So the end state is subdomains (wildcard DNS + TLS + ingress routing), and this change is not wasted work when that lands: base: "./" is strictly more portable than absolute paths and keeps working at a subdomain root. It removes a coupling between how an app is built and where it happens to be served.

One caveat worth recording for whoever implements subdomains: appx scopes its session cookie to .<baseDomain>, which means app subdomains receive the dashboard session cookie. It is HttpOnly and the proxy strips it before forwarding upstream, but the browser still attaches it. That part should not be copied — app subdomains want their own scoped token.

Vite defaults to root-absolute asset paths, so a built app emitted
`<script src="/assets/index-abc.js">` and its chunks referenced each other the
same way. That only works when the app is served from the root of its own origin.

A control plane that previews a running app by proxying it under a sub-path
(`/apps/<id>/preview/`) gets the HTML back fine, and then the browser asks for
`/assets/index-abc.js` at the PROXY's root — 404, blank frame, no obvious cause.
Rewriting the HTML in the proxy does not fix it: the emitted JS chunks resolve
sibling assets absolutely too, and a code-split app computes some of those URLs at
runtime, so no text substitution catches them all.

`base: "./"` makes every emitted reference relative, so one build works at its own
origin, on a subdomain, or under an arbitrary prefix, and the proxy stays a plain
byte pipe with no rewriting in it.

Verified with a real build of the template: dist/index.html emits
./assets/index-…js, and serving dist/ from /api/apps/<id>/preview/ resolves that
asset with a 200.

Existing projects pick this up on their next DEV rebuild, which the deploy-app
skill performs on every refinement.
@neuromaxer

Copy link
Copy Markdown
Contributor Author

Closing — the consumer need behind this went away, and testing it further turned up a reason base: "./" is actively worse than the default for these apps.

Why it was opened: openorange wanted to preview a running app in an iframe by proxying it under a dashboard path (/apps/<id>/preview/). Vite's default root-absolute asset paths break that: the HTML loads, then the browser requests /assets/index-abc.js from the proxy's root and 404s.

Why that need is gone: a path-based preview only ever works for frameworks whose assets resolve relative to the document. Next.js, SvelteKit and Nuxt fix their asset prefix at build time (/_next/static/…), and the prefix would have to contain an app id the app cannot know. Since nothing constrains the agent to this template — APPX_TEMPLATE_DIR is a one-time copy at project creation — a framework-dependent preview is the wrong foundation. openorange is going to per-app subdomains instead, where the app is served at a root and absolute paths are correct for every framework.

Why this change would be a regression, not just unnecessary: base: "./" makes asset URLs relative to the current document's path, which breaks the moment an app has client-side routing. Reproduced against the template's own nginx try_files $uri $uri/ /index.html:

app at /                    → ./assets/app.js → /assets/app.js          → 200 ✅
app at /settings/profile    → ./assets/app.js → /settings/assets/app.js
                                               → SPA fallback returns index.html
                                               → 200, but Content-Type text/html ✗

The browser gets HTML where it expects a module, so a deep link or a refresh on any nested route white-screens. base: "/" (the default) is correct precisely because it is path-independent, which is what an SPA with a history-API router needs.

So the default is right for apps served at a root, which is where they are served. Anything wanting a sub-path deployment should set base to that known prefix at build time — the supported Vite pattern — rather than the template guessing with "./".

Leaving the template unchanged. The reproduction is recorded here in case a sub-path deployment target comes up again.

@neuromaxer neuromaxer closed this Aug 7, 2026
@neuromaxer
neuromaxer deleted the fix/template-relative-base branch August 7, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant