Visitar URL original
fix(web): upgrade to Nuxt 4.6 and unbreak SSR payload serialization Aligns the web app with Nuxt 4.6.1 and closes two defects that made SSR return 500 on every route that touches a Pinia store. by techbuzzz · Pull Request #43 · techbuzzz/agent-shaker · GitHub
Skip to content

fix(web): upgrade to Nuxt 4.6 and unbreak SSR payload serialization Aligns the web app with Nuxt 4.6.1 and closes two defects that made SSR return 500 on every route that touches a Pinia store. - #43

Merged
techbuzzz merged 7 commits into
mainfrom
feat/vnext
Oct 11, 2026

Conversation

@techbuzzz

Copy link
Copy Markdown
Owner

Why

Two separate bugs made server-rendered pages fail, plus a stored-XSS hole in
agent-authored markdown.

1. XSS in the SSR render path (security)

MarkdownView.vue sanitised with DOMPurify in the browser but fell back to a
regex on the server that only stripped <script> and on*="…". That missed
<img src=x onerror=…>, single-quoted and unquoted handlers, javascript:
URLs and <svg onload> — and the SSR payload is parsed before hydration, so
the client pass never got a chance to run. Context and standup bodies are
written by MCP agents, i.e. untrusted input.

Now sanitised isomorphically via isomorphic-dompurify (jsdom under the
node condition, the browser build for the client bundle — no jsdom shipped to
the browser), with an explicit tag/attribute allow-list. Logic lives in
app/utils/markdown.ts so it is testable without booting Nuxt; 10 regression
tests cover exactly the payloads the regex let through.

2. useRealtime() returned a fresh state per call

Every call built a new State, new refs and a new listener registry, so the
header's connection indicator could never observe the socket that
projects/[id].vue opened, and useServerUrl.set() called disconnect() on a
throwaway instance and closed nothing. Now a module-level singleton.

3. SSR500 — obj.hasOwnProperty is not a function

pinia@2.3.1's shouldHydrate() called obj.hasOwnProperty(...) on anything
whose toString is [object Object], which includes null-prototype objects.
@pinia/nuxt registers it as a payload reducer, and Nuxt applies reducers to
every value in the payload — so the 404 page failed too, not just pages holding
a store. pinia@4 rewrites this as Object.hasOwn(...). Fixed upstream;
picked up by the upgrade.

4. SSR 500 — getActivePinia() was called but there was no active Pinia

@pinia/nuxt pushes its runtime into build.transpile, so the plugin that
calls createPinia() gets bundled into the server entry, while
import { defineStore } from 'pinia' in app code was traced as an external and
resolved from .output/server/node_modules/pinia. Two copies of the module,
two distinct piniaSymbols: the plugin provided one, useStore() injected the
other, got null, and threw. Vite's dev-time dedupe hides this completely —
dev renders fine, production returns 500.

Fixed with nitro.externals.inline: ['pinia'].

Nuxt 4.6 adoption

  • nuxt 4.5.2 → 4.6.1 (Vite 8.3.4), Node floor raised to 22.21 in
    package.json, Dockerfile and ci.yml to match the @nuxt/cli v4
    requirement.
  • server/routes/* now import from nuxt/server rather than relying on
    Nitro's h3 v1 auto-imports. On 4.6+ those auto-imported names are h3 v1's,
    and h3's defineEventHandler hands the callback a different event than the
    nuxt/server helpers read; the same source keeps working on Nuxt 5 / Nitro
    v3 / h3 v2.
  • createError({ statusCode, statusMessage }) → { status, statusText };
    error.vue reads error.status. NuxtError in 4.6.1 marks the old names @deprecated.
  • Full dependency alignment per review decision: vue-router 5.4.0,
    @nuxt/ui 4.11.3, pinia 4.0.3, @pinia/nuxt 1.0.2.
  • tsconfig.json keeps vueCompilerOptions.plugins: [] — Nuxt 4.6 registers
    vue-router/volar/sfc-route-blocks, and no vue-router since 4.5 ships the
    volar/ directory (verified on both 4.6.4 and 5.4.0). Comment updated.

Also in this change

  • Proxy streams its response. new Response(upstream.body, …) replaces
    arrayBuffer() + send(). Memory no longer scales with response size, all
    upstream headers survive (only hop-by-hop ones are dropped), and the
    send() workaround against Nitro JSON-stringifying the buffer is gone.
  • Proxy forwards content-type. It was dropped before, so every write
    relied on the Go handlers ignoring its absence.
  • getRouterParam(…, { decode: false }) keeps the raw percent-encoding for a
    value that gets spliced into an upstream URL.
  • Nitro v3 migration notes left in nuxt.config.ts and ws.ts: the websocket
    flag becomes features.websocket and defineWebSocketHandler moves to the
    nitro package. Hook signatures and the crossws Peer surface are unchanged.

Verified

  • npm run typecheck — exit 0
  • npm run test — 42/42 (was 32)
  • npm run build — success- Runtime smoke on the built server: /, /settings, /projects, /agents,
    /tasks, /standups, /global, /documentation, /healthz → 200;
    /nope → 404 (previously 500); /api/projects → 502 as expected with no
    Go backend, confirming the new streaming path handles an unreachable upstream.
  • Dashboard rendered in a browser against the production build.

Known, not addressed here

Hydration completed but contains mismatches on the dashboard is pre-existing
— reproduced on main/feat/vnext before this change in the same browser.
Left alone deliberately; it is a separate piece of work.

The Go backend is untouched: this PR is scoped to the web app.

@techbuzzz techbuzzz self-assigned this Oct 11, 2026
@techbuzzz
techbuzzz merged commit e3d21de into main Oct 11, 2026
5 checks passed
@techbuzzz
techbuzzz deleted the feat/vnext branch October 11, 2026 20:45
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