Skip to content

[nitro 2/6] Bridge the Express app into Nitro; single-origin dev - #4702

Draft
cprecioso wants to merge 4 commits into
cprecioso/nitro-01-clientfrom
cprecioso/nitro-02-express-bridge
Draft

[nitro 2/6] Bridge the Express app into Nitro; single-origin dev#4702
cprecioso wants to merge 4 commits into
cprecioso/nitro-01-clientfrom
cprecioso/nitro-02-express-bridge

Conversation

@cprecioso

Copy link
Copy Markdown
Member

Description

Part 2 of the Nitro migration stack (stacked on #4701). This is THE dev cutover: all HTTP API traffic goes same-origin through Nitro on port 3000, bridged into the (unchanged) generated Express app.

  • Bridge: fromNodeHandler(app) from nitro/h3, reached through a generated Nitro serverEntry (h3 defineHandler). Express's routing stays the single source of truth for API paths — the serverEntry only does a codegen'd prefix check (fast-path), short-circuits ws upgrades and prerender requests, and strips Express-added headers when Express falls through (helmet's API CSP must not land on renderer HTML). Unmatched routes fall through to the renderer natively via Express's out-next().
  • 404 contract: /auth, /operations, /crud get terminal JSON 404s (a typo'd operation must never come back as a 200 SPA shell). The dev wrong-port page dies; GET /_wasp/health replaces the / health response (/ is a page now).
  • Error statuses preserved: h3's next(err) discards err.status/err.statusCode (body-parser 413s, http-errors-style middleware would all become 500s) — a status-preserving branch in the Express error handler keeps them. HttpError handling is byte-identical.
  • setupFn runs against the bridged app too, so user middleware affects real traffic; its server becomes a Proxy that throws a descriptive migration error when touched (the raw http.Server no longer exists on this path). When a setupFn exists, the bridged prefix list widens to a catch-all so setupFn-registered routes keep working. The standalone dev process (jobs, socket.io, setupFn-with-server) keeps running on 3001 unchanged; its HTTP routes go unused — transitional until parts 3/4.
  • Single-origin defaults: REACT_APP_API_URL dev default becomes same-origin ("", baseDir-aware), WASP_SERVER_URL dev default moves to http://localhost:3000; the server's dev .env is loaded (set-if-undefined, matching -r dotenv/config semantics) into the single Vite process. The socket.io client keeps an explicit :3001 dev fallback until part 3 (crossws).
  • Virtual user modules: the SDK Vite plugin now maps server-side modules too (the nitro environment bundles wasp/server), which surfaced a real dedupe bug — two operations declared in the same file generated duplicate object keys. Fixed by deduping on the virtual module id, in the server-side .js plugin too (where it was latently shipping duplicates).
  • nitro is declared in the generated server's deps (its tsc --build compiles src/nitro/*.ts).

Verified on a live app (username auth, parameterized custom api, apiNamespace, throwing api, prerender route, setupFn, websockets, PORT=3001 planted in .env.server): operations + auth signup/login/me through :3000 in dev and built output, JSON 404 tiers, SPA-shell fallthrough with zero leaked Express headers, 422/413 statuses, health endpoint, hydration + prerender intact, standalone server + socket.io still functional on 3001, dev port unhijacked. 657/657 unit tests; e2e goldens regenerated and re-verified in comparison mode (947/947 twice).

Bonus finding: the node-server preset traces Prisma (including the native engine) into the output with zero externals config — that was the last open platform-risk question from the spikes.

Type of change

  • 🔧 Just code/docs improvement
  • 🐞 Bug fix
  • 🚀 New/improved feature
  • 💥 Breaking change

Checklist

  • I tested my change in a Wasp app to verify that it works as intended.

  • 🧪 Tests and apps:

    • I added unit tests for my change.
    • (if you fixed a bug) I added a regression test for the bug I fixed.
    • (if you added/updated a feature) I added/updated e2e tests in examples/kitchen-sink/e2e-tests.
    • (if you added/updated a feature) I updated the starter templates in waspc/data/Cli/templates, as needed.
    • (if you added/updated a feature) I updated the example apps in examples/, as needed.
      • (if you updated examples/tutorials) I updated the tutorial in the docs (and vice versa).
  • 📜 Documentation:

    • (if you added/updated a feature) I added/updated the documentation in web/docs/.
  • 🆕 Changelog: (if change is more than just code/docs improvement)

    • I updated waspc/ChangeLog.md with a user-friendly description of the change.
    • (if you did a breaking change) I added a step to the current migration guide in web/docs/migration-guides/.
    • I bumped the version in waspc/waspc.cabal to reflect the changes I introduced.

All HTTP API traffic (operations, auth, CRUD, custom APIs) now flows
same-origin through Nitro's serverEntry into the generated Express app
via fromNodeHandler; page requests fall through to the renderer.

- new server templates: nitro/{expressBridge,serverEntry,apiManifest,
  setup}.ts; new NitroRoutesG.hs computes bridged path prefixes (widened
  to a catch-all when a setupFn registers unenumerable routes)
- serverEntry short-circuits ws upgrades and prerender requests, and
  strips Express-added headers on fallthrough (helmet CSP must not leak
  onto renderer HTML)
- terminal JSON 404s inside /auth, /operations, /crud (a typo'd
  operation must never fall through to the SPA shell); wrong-port page
  replaced by GET /_wasp/health
- status-preserving branch in the Express error handler (h3's next(err)
  otherwise collapses 4xx-carrying errors like body-parser's 413 to 500)
- setupFn runs against the bridged app too (server: throwing Proxy with
  a migration message; standalone dev process still runs it unchanged)
- single-origin defaults: REACT_APP_API_URL '' (same-origin),
  WASP_SERVER_URL dev default :3000; server .env loaded set-if-undefined
  into the Vite process in dev; ws client keeps an explicit :3001
  fallback until the crossws phase
- virtual user modules plugin now maps server-side modules too; dedupe
  by module id (fixes duplicate object keys when two operations share a
  file, also latent in the server-side .js plugin)
- nitro declared in the generated server's deps (its tsc compiles
  src/nitro/*.ts)

The standalone dev server keeps running on 3001 (jobs, socket.io,
setupFn-with-server) but its HTTP routes go unused.
An app whose setupFn awaits a job submit (kitchen-sink does) hung every
dev request: nothing starts pg-boss in the Nitro worker, so
pgBossStarted never resolves and the setup gate never opens.

ensurePgBossStarted() starts pg-boss on first submission when nothing
else has. No Wasp jobs execute in that instance (registerJob only runs
via the standalone process's allJobs import), and the started promise
is cached on globalThis so dev reloads don't stack instances (verified:
stable pg_stat_activity across edits, exactly-once job execution in the
standalone process). Transitional until job execution moves into the
Nitro worker.

Also: kitchen-sink's OAuth-link e2e assertion now matches the relative
same-origin href.
@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@wasp.sh/spec

npx https://pkg.pr.new/wasp-lang/wasp/@wasp.sh/spec@4702

@wasp.sh/wasp-cli

npx https://pkg.pr.new/wasp-lang/wasp/@wasp.sh/wasp-cli@4702

@wasp.sh/wasp-cli-darwin-arm64-unknown

npx https://pkg.pr.new/wasp-lang/wasp/@wasp.sh/wasp-cli-darwin-arm64-unknown@4702

@wasp.sh/wasp-cli-darwin-x64-unknown

npx https://pkg.pr.new/wasp-lang/wasp/@wasp.sh/wasp-cli-darwin-x64-unknown@4702

@wasp.sh/wasp-cli-linux-arm64-glibc

npx https://pkg.pr.new/wasp-lang/wasp/@wasp.sh/wasp-cli-linux-arm64-glibc@4702

@wasp.sh/wasp-cli-linux-x64-glibc

npx https://pkg.pr.new/wasp-lang/wasp/@wasp.sh/wasp-cli-linux-x64-glibc@4702

@wasp.sh/wasp-cli-linux-x64-musl

npx https://pkg.pr.new/wasp-lang/wasp/@wasp.sh/wasp-cli-linux-x64-musl@4702

commit: 916f998

@github-actions

Copy link
Copy Markdown
Contributor

This pull request is now stale.
That means that it hasn't seen any activity for a while, and needs to be updated or addressed before it can be merged.

Next steps if the PR is still relevant, and you are able to devote time to it:

  • If you received any questions or feedback, please address them.
  • Merge the latest main into your branch to ensure that you are up to date.
  • If you are still working on the changes, please leave a comment telling us so.
  • Otherwise, just leave a comment explaining why the PR is still relevant.

If no action is taken, this PR will be automatically closed in 7 days.

@github-actions github-actions Bot added the Stale label Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant