Repository navigation
Rewrite embedded asset URLs server-side instead of via an import map - #19
Merged
Merged
Conversation
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>
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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():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
modulepreloadlinks 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 — thechunk-*.js404s. (WICG/import-maps#273)The change
Delete the import map; widen the existing serve-time CSS rewrite to cover
.jsas well. This fixes both, and additionally covers the dynamic-import chunks that never had amodulepreloadat all. 993 of the 1001 built JS files contain/myst_assets_folder/— esbuild bakespublicPathinto every chunk import — so the widening is necessary rather than over-broad.Build-time
publicPathis not an alternative: the prefix is per-session, andpublish-theme.ymlexcludes@remix-run/devfrom the published zip, so the deployed site cannot rebuild.Compiled files on disk are untouched, so standalone
myst startand 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./myst_assets_folder/%ZZ.jsthrows out of the router before any handler. WithNODE_ENVunset undernpm 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 (/%ZZleaked identically), but the null-byte guard below only closed the half that reaches a handler.%00madereadFilethrowERR_INVALID_ARG_VALUErather thanENOENT, reaching the default handler.path.extnamereturns.CSS, which fell through and served the file unrewritten. Not reachable from Remix URLs, which are always lower-case.PORT=0prints a URL that works.Tests
New
packages/astra/tests/viewer-server.test.tsspawns each theme's realserver.jsand asserts over HTTP: JS and CSS rewriting with exact bodies, the rewritten dependency resolving, absolute non-asset URLs left alone,font.woff2byte-identical through the widened route's passthrough, gzip present,%00and..%2ftraversal rejected, and files on disk unchanged.Each part of the production change is independently pinned — reverting any one fails a specific assertion:
.jswidening/myst_assets_folder/...compression()moved backexpected null to be 'gzip'expected 500 to be 404139 tests pass,
typecheckclean,build:articleexits 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.mdedits 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). Cuttingv0.0.13requires a tag push;publish-theme.ymlstamps the version from the tag, so do not hand-bump it.🤖 Generated with Claude Code