Skip to content

fix(transforms): resolve server-side esm.sh modules with a server target - #3541

Merged
kojiwakayama merged 3 commits into
mainfrom
fix/ssg-client-module-resolution
Aug 10, 2026
Merged

kojiwakayama merged 3 commits into
mainfrom
fix/ssg-client-module-resolution

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Follows #3538 (now merged). That PR fixed the extension-contract error; this fixes the failure hiding behind it, so veryfront init → veryfront build works end to end.

Related: veryfront-issue-inbox#456.

Problem

With #3538 merged, the default ai-agent template still failed to build:

✗ Failed to build app route /: ReferenceError: document is not defined
✗ [ssg-generation-error] Static site generation failed

ai-agent is the default template (cli/commands/init/catalog.ts:24), so a bare veryfront init produced a project that could not build.

Cause

The SSR/SSG HTTP module cache resolved bare npm specifiers with target=es2022. esm.sh applies the browser export condition for browser targets, so a package that ships a DOM implementation behind that condition resolves to one — and then throws when the module is evaluated server-side.

The template's markdown-renderer.tsx imports react-markdown and remark-gfm. Both pull decode-named-character-reference, whose browser build is:

var r = document.createElement("i");   // module scope

Confirmed against esm.sh directly:

target build
es2022 document.createElement
denonext / node pure lookup table, zero document refs

Fix

Resolve bare specifiers in this cache with target=denonext.

Scope notes, since this file is shared:

  • React URLs are resolved earlier in resolveBareSpecifier and are untouched, so SSR React singleton identity is unchanged.
  • The normalizeEsmShUrl default stays es2022. It also canonicalizes browser-facing URLs via src/release-assets/build-executor.ts; changing it there would ship Deno-targeted builds to browsers. I tried that first and backed it out.
  • The leading /denonext/ strip is now scoped to the path prefix. It previously used a substring replace, which would also rewrite an inner /denonext/ segment of a resolved build path. The existing test covers the prefix form (esm.sh/denonext/lodash@4) and still passes.

Only specifier-resolver.ts calls resolveBareSpecifier, and it writes file:// modules for the SSR runtime, so this path is server-only.

Verification

A/B on the single file, default template, pristine scaffold, cache cleared each run:

WITHOUT fix → ReferenceError: document is not defined
WITH fix    → ✓ Built in 6.44s

Output is real, not a hollow pass: 14.7 KB index.html with the Chat component's SSR markup, import map, hydration runtime, client.js/app.js, and a CSS asset.

  • deno task typecheck — exit 0
  • deno test src/transforms/ — 154 passed, 0 failed
  • deno test src/release-assets/ src/rendering/ src/build/ src/discovery/ — 216 passed, 0 failed
  • deno fmt --check + deno lint on both files — clean

Three existing assertions encoded the old browser target and were updated. Added a regression test naming the two template packages.

Follow-ups, not in this PR

  • The SSG error is flattened to a one-line message with no stack (src/build/production-build/static-generation.ts:413). The cause is preserved but never surfaced, which made this materially harder to isolate.
  • cli/commands/build/build-error.integration.test.ts:16-18 is an assertRejects with no error type or message constraint, so it passes whichever way the build fails. It stayed green through both this bug and the contract regression.

Summary by CodeRabbit

  • New Features

    • Added support for resolving server-targeted ESM packages through esm.sh.
    • Improved module URL generation for pinned packages and selected React Markdown dependencies.
    • Updated bare package imports to use the appropriate server ESM target.
  • Bug Fixes

    • Corrected URL normalization to preserve nested path segments while removing only the leading server-target marker.

The SSR/SSG HTTP module cache resolved bare npm specifiers with
target=es2022. esm.sh applies the `browser` export condition for browser
targets, so packages that ship a DOM implementation behind it resolve to one
and then throw when evaluated server-side.

`veryfront build` on the default ai-agent template failed with
"ReferenceError: document is not defined": react-markdown and remark-gfm both
pull decode-named-character-reference, whose browser build calls
document.createElement at module scope.

Resolve bare specifiers for this cache with target=denonext. React URLs are
resolved earlier in the same function and are unchanged, as is the
normalizeEsmShUrl default, which also canonicalizes browser-facing
release-asset URLs.

Scope the leading /denonext/ strip to the path prefix so it no longer rewrites
an inner segment of a resolved build path.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kojiwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 30f83ffb-438f-4b2a-a28f-adae180c4d5e

📥 Commits

Reviewing files that changed from the base of the PR and between 6270bd7 and 40641ca.

📒 Files selected for processing (1)
  • src/transforms/esm/http-cache-helpers.test.ts
📝 Walkthrough

Walkthrough

The ESM URL helpers now use the node server target. URL normalization removes only a leading /denonext/ segment. Tests cover pinned packages and React Markdown dependencies.

Changes

ESM URL resolution

Layer / File(s) Summary
Server-targeted ESM URL resolution
src/transforms/esm/http-cache-helpers.ts, src/transforms/esm/http-cache-helpers.test.ts
The helpers define and use SERVER_ESM_TARGET for esm.sh URLs. Normalization preserves nested /denonext/ segments. Tests validate pinned packages and server builds for React Markdown dependencies.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: kojiwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: resolving server-side esm.sh modules with a server target.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ssg-client-module-resolution

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e778fc6ca3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/transforms/esm/http-cache-helpers.ts Outdated
This cache exists to keep SSR runtime-agnostic across Deno, Node, and Bun
(see transforms/pipeline/stages/ssr-http-cache.ts). denonext selects esm.sh's
deno condition, which can resolve Deno-only APIs into a module that is then
evaluated under Node or Bun. node is the portable server condition and is
equally free of the browser DOM builds this fix targets.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/transforms/esm/http-cache-helpers.test.ts (1)

19-19: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the required target independently.

The resolver and these expectations both read SERVER_ESM_TARGET. If that constant changes to es2022, the implementation and expected values can change together while the test remains green.

Use the literal target in the regression expectation, or add an independent assertion that SERVER_ESM_TARGET equals "denonext".

Proposed test guard
       for (const specifier of ["react-markdown@9.0.3", "remark-gfm@4.0.1"]) {
         const result = resolveBareSpecifier(specifier, emptyImportMap);
         assertEquals(result.includes("target=es2022"), false, specifier);
-        assertEquals(result.includes(`target=${SERVER_ESM_TARGET}`), true, specifier);
+        assertEquals(result.includes("target=denonext"), true, specifier);
       }

The PR objective requires the exact denonext server target.

Also applies to: 746-764

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/transforms/esm/http-cache-helpers.test.ts` at line 19, Update the
expectations in the ESM transform tests to independently enforce the literal
"denonext" server target rather than deriving it solely from SERVER_ESM_TARGET.
Add an assertion that SERVER_ESM_TARGET equals "denonext" and ensure the related
cases around the resolver expectations retain this exact target.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/transforms/esm/http-cache-helpers.ts`:
- Around line 548-568: Extend the focused tests for normalizeHttpUrl to cover an
esm.sh URL whose path contains an inner /denonext/ segment, such as
/pkg@1/X-abc/denonext/pkg.mjs. Assert that normalization preserves this path
unchanged, while retaining the existing leading-segment removal coverage.

---

Nitpick comments:
In `@src/transforms/esm/http-cache-helpers.test.ts`:
- Line 19: Update the expectations in the ESM transform tests to independently
enforce the literal "denonext" server target rather than deriving it solely from
SERVER_ESM_TARGET. Add an assertion that SERVER_ESM_TARGET equals "denonext" and
ensure the related cases around the resolver expectations retain this exact
target.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b645d44f-28c5-4e1c-929a-3025af8c09fa

📥 Commits

Reviewing files that changed from the base of the PR and between bb90d94 and e778fc6.

📒 Files selected for processing (2)
  • src/transforms/esm/http-cache-helpers.test.ts
  • src/transforms/esm/http-cache-helpers.ts

Comment thread src/transforms/esm/http-cache-helpers.ts
@kwakayama

Copy link
Copy Markdown
Contributor Author

Review: correct fix, verified end to end, incomplete in one place

I re-derived everything below rather than reusing the PR's evidence.

Independent verification

Premise, against esm.sh directly:

es2022   → var r=document.createElement("i"); ...   (module scope)
denonext → import{characterEntities as r}...        (pure lookup table)

A/B, pristine veryfront init --template ai-agent scaffold, cache cleared each run:

CLI result
origin/main (bb90d94) ✗ ReferenceError: document is not defined
this branch (e778fc6) ✓ Built in 8.87s, 14.7 KB index.html

The output is real: 12.7 KB body, hydration data, SSR head, page island, CSS asset. veryfront dev on the branch serves 200 and the app renders and hydrates with zero console errors. deno test src/transforms/ — 154 passed, 0 failed. Branch is a single commit on bb90d94, so no two-dot diff artifacts.

The description undersells the change

Scoping the /denonext/ strip to the path prefix is filed under "scope notes", but it is load-bearing. esm.sh's denonext entry resolves to:

/react-markdown@9.0.3/X-ZXJlYWN0/denonext/react-markdown.mjs

That /denonext/ is an inner segment. The old substring replace mangles every resolved build path. The target change alone does not work without it. Worth promoting in the description so the next reader doesn't "simplify" it back.

Findings

P1 — the same defect survives one function away. src/transforms/esm/specifier-resolver.ts:124 builds https://esm.sh/${bareSpecifier} with no target, so normalizeEsmShUrl defaults it to es2022.

Reproduced on this branch: switch the template's two imports to npm:react-markdown@9.0.3 / npm:remark-gfm@4.0.1, build, and you get ReferenceError: document is not defined again. Not a blocker for the default template, which uses bare pinned specifiers — but the PR's framing is "works end to end", and this is the same bug in the same file family. One argument fixes it.

P2 — two more unscoped /denonext/ replaces remain.

  • src/transforms/esm/http-bundler.ts:125-126
  • src/routing/api/module-loader/esbuild-plugin.ts:364-365

Both still do pathname.replace("/denonext/", "/"). This PR fixed the third copy. Denonext build paths now flow through the system for the first time, so three copies of the invariant disagree. I could not make either fire today — browser bundling still resolves es2022 — so it is latent, not broken. Worth a follow-up issue.

P2 — the new regression test asserts a specifier the build never produces. The test pins react-markdown@9.0.3. The real pipeline requests unversioned https://esm.sh/react-markdown?external=react&target=denonext and resolves 10.1.0, despite package.json pinning 9.0.3.

That version drop is pre-existing (main's cache shows the same shape), so it isn't introduced here. But it means the assertion's package pins are fiction relative to the real request, and the fix's correctness rides on whatever esm.sh serves as latest. A future react-markdown release could reintroduce a browser-conditional DOM dependency with no change on our side. Probably its own issue.

P3 — the cache key now misreports the target. Server cache after a clean build: 92 target=es2022 vs 73 target=denonext. Nearly all the es2022 ones are denonext build paths that normalizeEsmShUrl appended a default target to. Inert, since the path selects the build — but it makes the cache hard to read. Either let the default follow the path or leave a comment.

Checked and clear: SERVER_ESM_TARGET is a Deno target applied regardless of whether SSR runs on Node or Bun. Across all 165 cached modules there are zero Deno. references and zero node: imports, so it doesn't bite for this dependency set. Leaving the React URLs on es2022 is the right call — react-dom-server.browser.production.mjs is still fetched and still works.

Practical caveat

npm run build runs the published CLI. On 0.1.1227 it dies earlier, on the #3538 extension-contract error. The user-facing init → build expectation only holds once both this and #3538 reach a release.

Recommendation

Merge. Fold in the specifier-resolver.ts:124 one-liner first — shipping "init → build works end to end" while the npm: form of the identical import still crashes just queues up the next issue. File the two remaining /denonext/ copies and the unversioned-resolution drop as follow-ups.

@kojiwakayama

Copy link
Copy Markdown
Contributor

Addressed the remaining review feedback in 40641ca. The target-independence assertion uses the current runtime-neutral literal node (the earlier CodeRabbit nitpick referenced the superseded denonext commit), and the focused inner-path regression now preserves nested /denonext/ segments. Focused verification passed 76 steps, plus format and diff checks.

@kwakayama

Copy link
Copy Markdown
Contributor Author

Full template matrix. Every template scaffolded fresh and built, main vs this branch.

Template main (#3538 only) this PR
minimal ✓ 3.88s ✓ 2.26s
ai-agent (default) ✗ document is not defined ✓ 6.39s
docs-agent ✗ document is not defined ✓ 6.15s
agentic-workflow ✓ 3.85s ✓ 2.24s
multi-agent-system ✗ document is not defined ✓ 4.50s
coding-agent ✗ document is not defined ✓ 5.08s
saas-starter ✗ document is not defined ✓ 5.97s

5 of 7 templates are broken on main; all 7 pass here. Wider than the default template alone — I had only verified ai-agent before.

The two that already passed (minimal, agentic-workflow) are the ones that do not pull the markdown renderer, which is consistent with the diagnosed cause.

Outputs verified non-hollow, since veryfront build can succeed with zero pages:

Template pages index.html
minimal 2 3.0 KB
ai-agent 1 14.7 KB
docs-agent 2 40.1 KB
agentic-workflow 1 3.8 KB
multi-agent-system 1 15.7 KB
coding-agent 1 15.6 KB
saas-starter 3 4.7 KB

Each dist is 448–516 KB with real SSR markup, so these are genuine builds rather than empty passes.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 3842fa8 Aug 10, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/ssg-client-module-resolution branch August 10, 2026 16:58
kwakayama added a commit that referenced this pull request Aug 10, 2026
Ships the build fixes for veryfront-issue-inbox#456. `veryfront build` has
been broken for every project since 0.1.1206, and 5 of 7 scaffold templates
could not build at all.

- #3538 prime host contracts in the build extension path
- #3541 resolve server-side esm.sh modules with a server target
- #3542 keep optional Deno imports optional
- #3540 isolate cwd-mutating unit tests

The version is embedded in hydration-runtime.generated.ts, so a bump must be
followed by `deno task generate`; the manifests check fails otherwise.

There is no earlier version to pin to: 0.1.1205 fails the same templates with
the CSSProcessor error that #3417 was fixing.
This was referenced Aug 10, 2026
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