Skip to content

Rewrite embedded asset URLs server-side instead of via an import map - #19

Merged
EiffL merged 2 commits into
mainfrom
fix/mystra-reload-assets
Sep 17, 2026
Merged

EiffL merged 2 commits into
mainfrom
fix/mystra-reload-assets

Conversation

@lhparker1

Copy link
Copy Markdown
Member

The failure

Reported from a user session: the embedded MySTRA viewer rendered correctly, then broke after editing index.md. The page lost its styling (read as "switched to night mode"), the controls stopped responding, it would not reopen, and the server logged 404s for /myst_assets_folder/_shared/chunk-*.js.

Two independent causes, both rooted in the import map

Import maps only rewrite module specifiers. The compiled bundles carry literal stylesheet hrefs returned from links():

var ht="/myst_assets_folder/_assets/astra-AA4U5IHR.css"

No import map could ever touch those. SSR wrapped them, but client-side navigation emitted them unprefixed and they 404'd — taking the theme's stylesheet with them, which is the lost styling.

Even for genuine module imports, the map arrived too late. Remix emits its modulepreload links before the head content. In the served HTML the first preload is at byte 456 and the map at 4218. On a warm cache the preloads resolve first, against the unprefixed path — the chunk-*.js 404s. (WICG/import-maps#273)

The change

Delete the import map; widen the existing serve-time CSS rewrite to cover .js as well. This fixes both, and additionally covers the dynamic-import chunks that never had a modulepreload at all. 993 of the 1001 built JS files contain /myst_assets_folder/ — esbuild bakes publicPath into every chunk import — so the widening is necessary rather than over-broad.

Build-time publicPath is not an alternative: the prefix is per-session, and publish-theme.yml excludes @remix-run/dev from the published zip, so the deployed site cannot rebuild.

Compiled files on disk are untouched, so standalone myst start and static export are unaffected. With no prefix the middleware chain is byte-for-byte the order it was.

Also in here

  • compression() moves above the rewrite route. It was registered after, so every JS bundle was served uncompressed in prefix mode — 4.5 MB on the root route's critical path, through a proxy that does not compress. This was the one real blocker found in review.
  • Malformed request paths no longer leak absolute paths. Express decodes path params during routing, so /myst_assets_folder/%ZZ.js throws out of the router before any handler. With NODE_ENV unset under npm run start, finalhandler rendered the stack trace and the server's absolute path to the client. Now 400 with no body. Pre-existing and not specific to this route (/%ZZ leaked identically), but the null-byte guard below only closed the half that reaches a handler.
  • Null-byte rejection. %00 made readFile throw ERR_INVALID_ARG_VALUE rather than ENOENT, reaching the default handler.
  • Extension gate lower-cased. On a case-insensitive filesystem path.extname returns .CSS, which fell through and served the file unrewritten. Not reachable from Remix URLs, which are always lower-case.
  • The startup line reports the bound port, so PORT=0 prints a URL that works.

Tests

New packages/astra/tests/viewer-server.test.ts spawns each theme's real server.js and asserts over HTTP: JS and CSS rewriting with exact bodies, the rewritten dependency resolving, absolute non-asset URLs left alone, font.woff2 byte-identical through the widened route's passthrough, gzip present, %00 and ..%2f traversal rejected, and files on disk unchanged.

Each part of the production change is independently pinned — reverting any one fails a specific assertion:

Reverted Fails with
.js widening body mismatch, unprefixed /myst_assets_folder/...
compression() moved back expected null to be 'gzip'
null-byte guard expected 500 to be 404

139 tests pass, typecheck clean, build:article exits 0, three consecutive runs with no flake.

Verified end to end

Against a real project in JupyterLab: 23 asset URLs all 200, zero unprefixed references in the page or the root bundle, no import map in the served HTML, gzip on JS bundles, and two live index.md edits with zero asset 404s in the server log.

Not reproduced: the original failure on the broken build, watched going away. The causal story is inferred from the served bytes and from each assertion failing when its counterpart is reverted, which is strong but not the same thing.

After merge

Merging alone does not reach users — consumers pin release zips (.../releases/download/v0.0.12/astra-book-theme-v0.0.12.zip). Cutting v0.0.13 requires a tag push; publish-theme.yml stamps the version from the tag, so do not hand-bump it.

🤖 Generated with Claude Code

lhparker1 and others added 2 commits September 16, 2026 15:59
The embedded viewer broke after editing a source file: the page lost its
styling and its controls stopped responding, and the server logged 404s
for /myst_assets_folder/_shared/chunk-*.js.

Two independent causes, both rooted in the import map:

Import maps only rewrite module specifiers. The compiled bundles carry
literal stylesheet hrefs returned from links(), e.g.
  var ht="/myst_assets_folder/_assets/astra-AA4U5IHR.css"
which no import map could ever touch. Server-side rendering wrapped
those, but client-side navigation emitted them unprefixed and they 404d,
taking the theme's stylesheet with them.

Even for genuine module imports the map arrived too late. Remix emits
its modulepreload links before the head content: in the served HTML the
first preload is at byte 456 and the map at 4218. On a warm cache the
preloads resolve first, against the unprefixed path.

Serve-time rewriting addresses both, and additionally covers the dynamic
import chunks that never had a modulepreload. Build-time publicPath is
not an alternative: the prefix is per session, and publish-theme.yml
excludes @remix-run/dev from the published zip, so the deployed site
cannot rebuild.

Compiled files on disk are untouched, so standalone `myst start` and
static export keep their current behaviour.

Also move compression() above the rewrite route. It was registered
after, so every JavaScript bundle was served uncompressed - 4.5 MB on
the root route's critical path, through a proxy that does not compress.

The new test spawns each theme's real server and asserts over HTTP:
JS and CSS rewriting, the rewritten dependency resolving, gzip, path
traversal and null-byte rejection, and that files on disk are unchanged.
The null byte previously threw ERR_INVALID_ARG_VALUE rather than ENOENT
and reached Express's default handler, which without NODE_ENV set meant
a 500 carrying a stack trace and the absolute server path.

get-port is declared at the root because the test resolves it through
workspace hoisting from themes/*, where it is a dependency of neither
the test's package nor the root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stop disclosing absolute paths on malformed request paths. Express decodes
path params during routing, before any handler runs, so a bad escape such as
/myst_assets_folder/%ZZ.js throws out of the router itself and no in-handler
check can catch it. NODE_ENV is unset under `npm run start`, so finalhandler
rendered the stack trace to the client:

  URIError: Failed to decode param '%ZZ.js'
    at decode_param (/path/to/node_modules/express/lib/router/layer.js:172:12)

Now 400 Bad Request with no body. The previous commit claimed to close this
disclosure class via the null-byte guard, but only closed the half of it that
reaches a handler. It predates that commit and is not specific to the asset
route - /%ZZ leaked the same way.

Lower-case the extension before gating the rewrite. On a case-insensitive
filesystem path.extname returns .CSS, which fell through to express.static
and served the file unrewritten with a 200. Not reachable from Remix-generated
URLs, which are always lower-case, but the gate should match the filesystem
it runs on.

Report the bound port rather than the requested one, so PORT=0 prints a URL
that works. The test now passes PORT=0 and reads the port back from that line,
letting the OS assign it at bind time. Choosing a port up front and binding it
later leaves a window the parallel article/book cases can collide in, which
surfaced as an opaque `Theme exited: 1`. This also drops get-port from the
test, and with it the root devDependency the previous commit added - the
package files are now untouched by this branch.

Verified: reverting themes/*/server.js and themes/*/app/root.tsx to origin/main
still fails both viewer-server cases, so the suite continues to pin the fix.
139 tests pass, typecheck clean, three consecutive runs with no flake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Lightcone previews: Paper · Playground — inventory, viewers, dialogs and glyphs

064c71a · @astra-spec/ui 0.0.5 from 9c940eb · Lightcone brand 0.0.3 · astra-theme 064c71a · content 3735fcc · MySTRA https://github.com/LightconeResearch/MySTRA/releases/download/v0.0.8/mystra.mjs

@EiffL
EiffL merged commit f7189ba into main Sep 17, 2026
1 check passed
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.

2 participants