Skip to content

Add SSR support to docs-app using vite-ember-ssr#292

Open
NullVoxPopuli-ai-agent wants to merge 6 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:add-ssr-support
Open

Add SSR support to docs-app using vite-ember-ssr#292
NullVoxPopuli-ai-agent wants to merge 6 commits into
universal-ember:mainfrom
NullVoxPopuli-ai-agent:add-ssr-support

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown

Summary

  • Integrates vite-ember-ssr to enable server-side rendering of the docs-app
  • Adds a Fastify SSR server (server.mjs) with both development (Vite middleware + HMR) and production modes
  • SSR renders the full app shell: header, group navigation, page navigation sidebar, layout — while page content loads on the client via ember-repl runtime compilation

New files

  • docs-app/server.mjs — Fastify SSR server with dev/prod modes
  • docs-app/src/app-ssr.ts — SSR entry point exporting createSsrApp() factory
  • docs-app/vite.config.ssr.mjs — Separate Vite config for SSR build (targets Node 22)

New scripts

  • dev:ssr — Start dev server with SSR + HMR
  • start:ssr — Start production SSR server
  • build:ssr — Build SSR bundle to dist/server/
  • build:all — Build both client and SSR bundles

SSR compatibility workarounds

  • Browser globals polyfill — Happy DOM globals installed at server startup for modules that access window/document at load time (e.g., ember-primitives/color-scheme)
  • ember-repl/repl-sdk stubs — These browser-only modules (Web Workers, Comlink) are replaced with no-op stubs during SSR since runtime markdown compilation isn't needed server-side
  • kolay/setup virtual module patch — Fixes window[SECRET] ownership tracking across per-request Happy DOM window isolation

Dependencies

  • vite-ember-ssr (not yet published to npm — currently referencing git repo)
  • fastify, @fastify/middie, @fastify/static, @fastify/compress
  • happy-dom

Notes

  • vite-ember-ssr is not yet published to npm. The package.json dependency reference will need updating once it's published.
  • Page content shows a loading state during SSR because ember-repl (runtime markdown compiler) is stubbed. The full page content hydrates on the client. Pre-compiled .gjs.md pages may render fully with further work.
  • Tested in dev mode — returns HTTP 200 with full navigation, layout, and styles SSR'd.

Test plan

  • Run pnpm dev:ssr in docs-app/ and verify the page loads with SSR content visible before JS hydration
  • Verify client-side navigation works after hydration
  • Run pnpm build:all and pnpm start:ssr to test production mode
  • Verify existing pnpm start:vite (non-SSR) still works unchanged

🤖 Generated with Claude Code

@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel

vercel Bot commented Mar 28, 2026

Copy link
Copy Markdown

@NullVoxPopuli is attempting to deploy a commit to the universal-ember Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Mar 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kolay-docs-app Ready Ready Preview, Comment May 25, 2026 4:43pm

NullVoxPopuli-ai-agent pushed a commit to NullVoxPopuli-ai-agent/kolay that referenced this pull request May 23, 2026
PR universal-ember#292 wired up vite-ember-ssr from a pre-hydration snapshot that
boot-replaced the SSR markup on the client — no actual rehydration,
just a flash of server HTML. Upstream vite-ember-ssr@0.1.0 has since
shipped `_renderMode: serialize` on the server and a `bootRehydrated`
client helper, so the client now attaches Glimmer to the existing DOM
in place.

Server changes:
- Switch to the new `createEmberApp(ssrBundle, { dev: { ssrLoadModule } })`
  + `app.renderRoute(url, { shoebox })` API. Drops the hand-written
  per-request happy-dom plumbing.
- Re-export `settled` from `@ember/test-helpers` in `app-ssr.ts` so the
  renderer awaits reactiveweb's `getPromiseState` test-waiters and the
  `.gjs.md` page loader resolves before we read the DOM. Without this
  every route SSRs as the `<Page>` loading-state.
- Enable `isolateWorkers` for the prod tinypool. Components in this app
  schedule autoruns that can fire after `instance.destroy()` and corrupt
  the worker's shared HappyDOM Window, taking out subsequent renders.
  Fresh-window-per-request avoids the bleed. uncaughtException is also
  logged-not-fatal as a belt-and-braces guard.
- Drop `vite.config.ssr.mjs` and fold SSR into the single `vite.config.js`
  via `emberSsr()` (handles ssr.noExternal, outDirs, CJS shim, CSS manifest).

Client changes:
- New `src/entry.ts` calls `bootRehydrated(Application, config)`.
  Removes the inline boot script in `index.html` that was manually
  tearing down SSR content before booting.

Kolay-internal fixes that block multi-request SSR:
- `kolay/setup` virtual module: read `window[SECRET]` fresh on every
  `setupKolay()` call instead of caching at module load. Module-level
  capture pinned to the first request's HappyDOM Window; subsequent
  requests added owners to a destroyed window and `getKey()` (which
  reads window fresh) crashed with "cannot read 'owners' of undefined".
- `registry.ts`: drop `.md` from the eager `import.meta.glob`. The
  `.gjs.md` pages are loaded by Selected via the `kolay/compiled-docs`
  pages map; eager-importing them forces every compiled-page chunk to
  evaluate at app boot, and in the prod SSR bundle that creates a
  circular import where each chunk's top-level `templateOnly()` runs
  before app-ssr.js initializes the binding.
- `kolaySsrStubs` Vite plugin: no-op `repl-sdk`, `ember-repl`, and the
  babel-plugin-ember-template-compilation chain during SSR transforms.
  Those modules touch Web Workers / CJS named exports the SSR pipeline
  can't bundle, and runtime markdown compilation isn't needed on the
  server — `.gjs.md` is pre-compiled by `kolay/vite`.

Verified: dev:ssr and start:ssr both render every route 200 with full
nav, layout, and markdown content. The HTML carries Glimmer rehydration
markers (`<!--%+b:N%-->`) and `window.__vite_ember_ssr_rehydrate__=true`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires up server-side rendering for the docs-app using vite-ember-ssr's
real Glimmer rehydration path (`_renderMode: 'serialize'` on the server +
`bootRehydrated` on the client). The client attaches Glimmer to the
existing DOM in place — no flash, no boot-replace.

Plumbing:
- `server.mjs` — Fastify server with dev (Vite middleware + HMR via
  `createEmberApp(entry, { dev: { ssrLoadModule } })`) and prod
  (`createEmberApp(bundle, { isolateWorkers: true })` + tinypool)
  modes. Adds `dev:ssr`, `start:ssr`, `build:ssr`, `build:all` scripts.
- `src/app-ssr.ts` — SSR entry exporting `createSsrApp()` and re-exporting
  `settled` from `@ember/test-helpers` so the renderer awaits the run
  loop, pending timers, and `@ember/test-waiters` (reactiveweb's
  `getPromiseState`, etc.) before capturing the DOM.
- `src/entry.ts` — client entry calling `bootRehydrated(Application,
  config)`. Falls back to `Application.create(config.APP)` for non-SSR
  pages so `pnpm start:vite` still works.
- `vite.config.js` — folds in the previous separate `vite.config.ssr.mjs`
  and adds the `emberSsr()` plugin (handles `ssr.noExternal`, dist/client
  vs dist/server outDirs, CJS shim, CSS manifest).

SSR compatibility fixes:
- `kolay/setup` virtual module: read `window[SECRET]` fresh on every
  `setupKolay()` call instead of capturing at module load. Module-level
  capture pinned to the first request's HappyDOM Window; subsequent
  requests added owners to a destroyed window and `getKey()` (which
  reads window fresh) crashed with "cannot read 'owners' of undefined".
- `docs-app/src/registry.ts`: drop `.md` from the eager `import.meta.glob`.
  Pre-compiled `.gjs.md` pages are loaded by Selected via the
  `kolay/compiled-docs:virtual` pages map; eager-importing them statically
  forces every compiled-page chunk to evaluate at app boot, and in the
  prod SSR bundle that creates a circular import where each chunk's
  top-level `templateOnly()` runs before app-ssr.js initializes the
  binding ("templateOnly is not a function").
- `kolaySsrStubs` Vite plugin (in `vite.config.js`): no-op `repl-sdk`,
  `ember-repl`, and the babel-plugin-ember-template-compilation chain
  during SSR transforms. Those modules touch Web Workers / CJS named
  exports the SSR pipeline can't bundle, and runtime markdown compilation
  isn't needed on the server — `.gjs.md` is pre-compiled by `kolay/vite`.

Verified: `pnpm dev:ssr` and `pnpm start:ssr` both render every route
200 with full nav, layout, and markdown content. The HTML carries
Glimmer rehydration markers (`<!--%+b:N%-->`) and
`window.__vite_ember_ssr_rehydrate__=true` so `bootRehydrated` attaches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NullVoxPopuli and others added 2 commits May 25, 2026 00:13
Previously the gjs.md plugin emitted a `kolay/virtual:live:N.gjs`
virtual module per demo and an `import DemoN from <virtualId>` line
in the parent `.gjs.md`. Vite then resolved and content-tag-processed
each virtual module separately, producing one chunk per page with N+1
modules in its graph.

Replaced that with the limber `repl-sdk/render-to-string` helpers:
`splitModule` lifts each demo's top-level imports out of its body and
rewrites `export default <expr>` to `return <expr>`; `wrapAsConst`
puts the body in a uniquely-named IIFE; `replacePlaceholder` rewrites
the `<div id="…" class="…">` placeholders to invocations of that const
wrapped in a div that keeps the original class. The output is a single
`.gjs` module that content-tag processes in one pass.

Two patches let kolay test this before limber publishes:

  - `patches/repl-sdk.patch`: brings the `renderToString` API +
    `buildGmdModule` / `splitModule` / etc. (and exports them via
    `repl-sdk/render-to-string`).
  - `patches/ember-repl.patch`: adds `CompilerService.compileToSource`
    next to the existing content-tag/standalone fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independent bugs together caused the SSG'd content to be
unmounted and re-rendered on rehydration:

  1. `prefetchPage`'s dynamic import was annotated with `@vite-ignore`,
     so Vite left the `kolay/compiled-docs:virtual` specifier intact
     and the browser threw "Failed to resolve module specifier" at
     runtime. moduleCache stayed empty, every `Selected.loader` access
     went through `getPromiseState`'s pending state, and `<Page>`
     rendered its `<:pending>` block on top of the SSG'd success
     content. Drop the `@vite-ignore` — the specifier IS resolvable at
     build time because kolay's plugin registers it.

  2. `Selected.#path` and `prefetchPage` weren't normalizing trailing
     slashes. An SSG'd `/usage/setup/index.html` served as
     `/usage/setup/` produced `findByPath('/usage/setup/')` which
     missed the manifest's `/usage/setup` entry, so Selected returned
     a "page not found" error and stomped the prerendered DOM.
     Factor out `normalizePagePath` and use it from both sites.

  3. `<APIDocs>` / `<Load>` always started in `request.isLoading = true`
     on rehydration because the typedoc JSON fetch had to round-trip
     even when the same content was already in the DOM. Add a project-
     level cache (deserialized `ProjectReflection` keyed by package
     name) and a synchronous fast path in `Load#request` that reads
     from it. Expose `prewarmTypedocCache(pkg, loader)` and
     `prewarmTypedocCaches()` so the client entry can pre-fetch +
     deserialize every configured `apiDocs({ packages })` entry before
     `Application.create`, mirroring how `prefetchPage` warms the page
     module cache.

Verified via a CPU-throttled puppeteer trace sampling every 25ms
across every documented route: each page now reports exactly one
unique `proseLen` value over the full hydration window (was
4424 → 3381 → 4424 on `/plugins/kolay/` before this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Pushed 36261d3 switching the .gjs.md plugin to limber's renderToString helpers, addressing the rehydration FOUC root-cause discussed.

What changed:

  • src/build/plugins/gjs-md.js no longer emits kolay/virtual:live:N.gjs virtual modules per demo. Instead it uses splitModule + wrapAsConst + replacePlaceholder from repl-sdk/render-to-string to inline each demo as a top-level IIFE-const, then runs content-tag once over the whole .gjs module.
  • Two patches let this work before limber publishes:

On the limber side, the same string-emission path is shared between the runtime compile() and the new compileToSource() — runtime stashes the live scope on globalThis[Symbol.for('repl-sdk:gmd-scope:N')] so the emitted module reads it back. No more parallel implementations.

Verified locally:

  • pnpm --filter kolay vitest run — 52/52 pass
  • pnpm --filter kolay build — green
  • cd docs-app && pnpm build — 23 pages SSG'd, including ones with APIDocs demos (their typedoc content now lands in static HTML)

Pulls in limber#2165's latest commit: gmd's runtime path now
registers its live scope via `api.provide('repl-sdk:gmd-scope:N', scope)`
instead of writing to `globalThis` directly. The emitted module
threads scope through a regular `import * as __scope__ from '<specifier>'`
that resolves via repl-sdk's existing `manual:` resolver.

Verified the docs-app build still SSGs all 23 pages and rehydration
holds prose mounted with no FOUC across every documented route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NullVoxPopuli and others added 2 commits May 26, 2026 01:18
Review feedback (universal-ember#292):
  - Bring back the three inline-snapshot assertions that I'd
    replaced with `toContain` checks during the refactor away from
    `kolay/virtual:live:N.gjs` virtual modules.
  - Restore the "handles a big dog with top-level component
    invocation" case I'd dropped.

The snapshots now reflect the new output shape — content-tag's
deduped `template as template_<hash>` import, the inlined
`const Demo<N>_<id> = (() => { return template_<hash>(\`...\`, …); })()`
IIFE, and the rewritten placeholder div wrapping a `<Demo<N>_<id> />`
invocation — and serve as a guard against accidental changes to that
shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls limber#2165's latest commit: `api.provide(specifier, value) → unregister`
is gone, replaced by a per-compile `CompileAPI` whose `provideScope(value)
→ { specifier }` is auto-released by the Compiler when the compile's
destroy fires. gmd no longer threads an unregister through extras.

Verified docs-app rehydration still has no FOUC across every documented
route (0 `<:pending>` flashes; SSG content stays mounted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
}
);

expect(virtualModulesByMarkdownFile).toMatchInlineSnapshot(`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bring back the inline snapshot

}
);

expect(virtualModulesByMarkdownFile).toMatchInlineSnapshot(`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bring back the inline snapshot

"test.gjs.md" => Map {},
}
`);
expect(result.code).toMatchInlineSnapshot(`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

bring back inline snapshot

expect(result.code).toContain('<APIDocs @module="declarations/browser"');
});

test('handles a big dog with top-level component invocation', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why'd this test get removed?

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Author

Pushed b28af17 — inline snapshots restored on the existing two tests and the big-dog handlePotentialIndexVisit case is back, all three updated to match the new output shape (content-tag's deduped template as template_<hash> import, the inlined const Demo<N>_<id> = (() => …)() IIFE, and the rewritten placeholder div around <Demo<N>_<id> />).

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.

3 participants