From 6658a3e687109a6622d261519e83eb4cec7f691e Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 01:42:04 +0000 Subject: [PATCH 1/3] fix(platform): apply SSR route policies before rendering Read matched policies before header middleware and retain explicit child SSR opt-ins. Refs analogjs/analog#2522. Includes the shared CI bootstrap prerequisites. Co-Authored-By: OpenAI Codex --- .node-version | 2 +- .nvmrc | 1 + .nx/workflows/agents.yaml | 12 +--- CONTRIBUTING.md | 3 + .../features/server/server-side-rendering.md | 26 +++++-- .../src/lib/nitro/analog-nitro-plugin.spec.ts | 8 +++ .../src/lib/nitro/analog-nitro-plugin.ts | 21 +++--- .../src/lib/nitro/ssr-renderer.spec.ts | 71 +++++++++++++++++++ 8 files changed, 118 insertions(+), 26 deletions(-) create mode 120000 .nvmrc create mode 100644 packages/platform/src/lib/nitro/ssr-renderer.spec.ts diff --git a/.node-version b/.node-version index 045200741c..5bf4400f22 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.14.1 \ No newline at end of file +24.15.0 diff --git a/.nvmrc b/.nvmrc new file mode 120000 index 0000000000..070266a268 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +.node-version \ No newline at end of file diff --git a/.nx/workflows/agents.yaml b/.nx/workflows/agents.yaml index 4007a327a2..14ccb307f5 100644 --- a/.nx/workflows/agents.yaml +++ b/.nx/workflows/agents.yaml @@ -23,10 +23,8 @@ launch-templates: uses: 'nrwl/nx-cloud-workflows/v5/workflow-steps/checkout/main.yaml' - name: Install Node - # Reads `.node-version` (24.11.0). The base image ships Node 24.14, so we - # install the pinned version to preserve the old `node-version-file` - # behavior. Verify this reusable step picks up `.node-version`; if not, - # pass the version explicitly via its inputs. + # This step reads .nvmrc, which points to the shared .node-version pin. + # Install Node 24.15.0 instead of falling back to the Node 24.14 image. uses: 'nrwl/nx-cloud-workflows/v5/workflow-steps/install-node/main.yaml' - name: Enable Corepack and pnpm @@ -53,10 +51,6 @@ launch-templates: pnpm install --frozen-lockfile --prefer-offline - name: Install browsers - # Agents run both `test` (Vitest + Chromium shell) and `e2e` (Playwright - # + Cypress) tasks, so the full browser superset is installed once. This - # is heavier than either old job alone but is required because any agent - # may receive either task family. + # Vitest browser tests and Playwright E2E targets share these binaries. script: | pnpm playwright install --with-deps - pnpm cypress install diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6fc23a601..fce99bc3f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,9 @@ Source code for the Analog framework exists under the `packages/` folder. To con Analog uses [pnpm](https://pnpm.io/) to manage its dependencies. +Use the Node.js version pinned in `.node-version`. The `.nvmrc` symlink shares +that pin with nvm and the Nx Agents installer. + Before opening a pull request, run the following command from the root folder to make sure your development dependencies are up-to-date: ```shell diff --git a/apps/docs-analog/src/content/features/server/server-side-rendering.md b/apps/docs-analog/src/content/features/server/server-side-rendering.md index a8864f824f..1971ddc9b5 100644 --- a/apps/docs-analog/src/content/features/server/server-side-rendering.md +++ b/apps/docs-analog/src/content/features/server/server-side-rendering.md @@ -28,8 +28,16 @@ For more information about externals with SSR, check out the [Vite documentation SSR is enabled by default. For a hybrid approach, you can specify some routes to only be rendered client-side, and not be server side rendered. This is done through the `routeRules` configuration object by specifying an `ssr` option. +In Analog v3, configure these rules on the separate Nitro plugin. A more specific +`ssr: true` rule enables server rendering under a parent rule that disables it. + ```ts // https://vitejs.dev/config/ +import { defineConfig } from 'vite'; +import analog from '@analogjs/platform'; +import angular from '@analogjs/vite-plugin-angular'; +import { nitro } from 'nitro/vite'; + export default defineConfig(({ mode }) => ({ // ...other config plugins: [ @@ -37,14 +45,18 @@ export default defineConfig(({ mode }) => ({ prerender: { routes: ['/', '/404.html'], }, - nitro: { - routeRules: { - // All admin URLs are only rendered on the client - '/admin/**': { ssr: false }, + }), + angular(), + nitro({ + routeRules: { + // All admin URLs are only rendered on the client + '/admin/**': { ssr: false }, - // Render a 404 page as a fallback page - '/404.html': { ssr: false }, - }, + // A specific child can opt back into server rendering + '/admin/help': { ssr: true }, + + // Render a 404 page as a fallback page + '/404.html': { ssr: false }, }, }), ], diff --git a/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts b/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts index b01d874839..af13bbdfa0 100644 --- a/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts +++ b/packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts @@ -242,6 +242,10 @@ describe('analogNitroPlugin', () => { routeRules: { '/buffered': { streaming: false }, '/no-ssr': { ssr: false }, + '/no-ssr/help': { + ssr: true, + headers: { 'x-analog-no-ssr': 'true', 'x-example': 'retained' }, + }, '/default': {}, }, }, @@ -255,6 +259,10 @@ describe('analogNitroPlugin', () => { expect(nitroMock.options.routeRules['/no-ssr'].headers).toEqual({ 'x-analog-no-ssr': 'true', }); + expect(nitroMock.options.routeRules['/no-ssr/help'].headers).toEqual({ + 'x-analog-no-ssr': 'false', + 'x-example': 'retained', + }); expect(nitroMock.options.routeRules['/default'].headers).toBeUndefined(); }); }); diff --git a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts index 87bfeadaf2..4e428e18f9 100644 --- a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts +++ b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts @@ -591,7 +591,7 @@ export function analogNitroPlugin(options: Options = {}): Plugin { * the env-runner and in prod via the `__nitro_vite_envs__` global set up by * nitro/vite's `prodSetup`). */ -function generateSsrRendererVirtual(template: string): string { +export function generateSsrRendererVirtual(template: string): string { return ` import { defineHandler } from 'nitro/h3'; import ssr from '#analog/ssr'; @@ -600,11 +600,10 @@ const TEMPLATE = ${JSON.stringify(template)}; export default defineHandler(async (event) => { event.res.headers.set('content-type', 'text/html; charset=utf-8'); - // 'x-analog-no-ssr' is stamped on response headers by - // injectAnalogRouteRuleHeaders for routeRules with \`ssr: false\`. Nitro - // applies routeRule headers to the response before the renderer fires, - // so we can short-circuit by reading them here. - if (event.res.headers.get('x-analog-no-ssr') === 'true') { + // Matched header rules are available before response middleware applies them. + const noSsr = event.context.routeRules?.headers?.['x-analog-no-ssr'] + ?? event.res.headers.get('x-analog-no-ssr'); + if (noSsr === 'true') { return TEMPLATE; } const service = ssr.default ?? ssr; @@ -748,7 +747,8 @@ function sanitizeNitroBundlerConfig(rollupConfig: { output?: unknown }): void { /** * Walks Nitro's resolved routeRules and stamps `x-analog-no-ssr: true` onto - * any rule with `ssr: false`, and `x-analog-no-streaming: true` onto any rule + * any rule with `ssr: false`, resetting it for explicit `ssr: true`, and + * `x-analog-no-streaming: true` onto any rule * with `streaming: false`. Kept as response-header hints for downstream * consumers (CDN, edge logic); the actual SSR short-circuit happens inside * the SSR renderer virtual above, and the router falls back to a buffered @@ -768,8 +768,11 @@ export function injectAnalogRouteRuleHeaders(nitro: Nitro): void { if (!routeRules) return; for (const rule of Object.values(routeRules)) { - if (rule?.ssr === false) { - rule.headers = { ...rule.headers, 'x-analog-no-ssr': 'true' }; + if (typeof rule?.ssr === 'boolean') { + rule.headers = { + ...rule.headers, + 'x-analog-no-ssr': String(!rule.ssr), + }; } if (rule?.streaming === false) { rule.headers = { ...rule.headers, 'x-analog-no-streaming': 'true' }; diff --git a/packages/platform/src/lib/nitro/ssr-renderer.spec.ts b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts new file mode 100644 index 0000000000..5698e7231b --- /dev/null +++ b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { generateSsrRendererVirtual } from './analog-nitro-plugin'; + +const template = ''; + +function createRenderer(fetch: (request: Request) => Promise) { + const source = generateSsrRendererVirtual(template) + .replace("import { defineHandler } from 'nitro/h3';", '') + .replace("import ssr from '#analog/ssr';", '') + .replace('export default', 'return'); + const evaluate = new Function('defineHandler', 'ssr', source); + return evaluate((handler: unknown) => handler, { fetch }); +} + +function createEvent(ssr?: boolean) { + return { + req: new Request('http://localhost/client'), + res: { headers: new Headers() }, + context: { + routeRules: { + headers: ssr === undefined ? {} : { 'x-analog-no-ssr': String(!ssr) }, + }, + }, + }; +} + +describe('SSR renderer route rules', () => { + it('returns the client template before response headers are applied', async () => { + const fetch = vi.fn(async () => new Response('server-rendered content')); + const render = createRenderer(fetch); + const event = createEvent(false); + + expect(await render(event)).toBe(template); + expect(fetch).not.toHaveBeenCalled(); + expect(event.res.headers.get('content-type')).toBe( + 'text/html; charset=utf-8', + ); + }); + + it('honors explicit SSR over an inherited no-SSR header', async () => { + const response = new Response('server-rendered content'); + const fetch = vi.fn(async () => response); + const render = createRenderer(fetch); + const event = createEvent(true); + event.res.headers.set('x-analog-no-ssr', 'true'); + + expect(await render(event)).toBe(response); + expect(fetch).toHaveBeenCalledWith(event.req); + }); + + it('retains the header fallback when no SSR rule is resolved', async () => { + const fetch = vi.fn(async () => new Response('server-rendered content')); + const render = createRenderer(fetch); + const event = createEvent(); + event.res.headers.set('x-analog-no-ssr', 'true'); + + expect(await render(event)).toBe(template); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('renders normally when no rule disables SSR', async () => { + const response = new Response('server-rendered content'); + const fetch = vi.fn(async () => response); + const render = createRenderer(fetch); + const event = createEvent(); + + expect(await render(event)).toBe(response); + expect(fetch).toHaveBeenCalledWith(event.req); + }); +}); From 53db7f78261c97304970efb46cddcf3b491d11d3 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 16:01:04 +0000 Subject: [PATCH 2/3] fix(platform): filter caller SSR hints before renderer dispatch Restore caller-header filtering from eeda7af8d5c5123e09a0f5da9896ccdbafa86791. Preserve Request adapter fields, runtime context and cancellation; verify configured SSR rules over built HTTP. The route-policy contribution owns this shared prerequisite for independent renderer copies in the split. Refs: analogjs/analog#2523, analogjs/analog#2516 --- apps/analog-app-e2e/tests/ssr-policy.spec.ts | 23 +++++++ apps/analog-app/vite.config.ts | 1 + .../src/lib/nitro/analog-nitro-plugin.ts | 13 ++++ .../src/lib/nitro/ssr-renderer.spec.ts | 60 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 apps/analog-app-e2e/tests/ssr-policy.spec.ts diff --git a/apps/analog-app-e2e/tests/ssr-policy.spec.ts b/apps/analog-app-e2e/tests/ssr-policy.spec.ts new file mode 100644 index 0000000000..cea868d6bc --- /dev/null +++ b/apps/analog-app-e2e/tests/ssr-policy.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +test('caller hints cannot disable SSR on an enabled route', async ({ + request, +}) => { + for (const headers of [{}, { 'x-analog-no-ssr': 'true' }]) { + const response = await request.get('/products/1?ssr-policy=1', { headers }); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).toContain('Product Details'); + expect(html).toContain('ng-server-context'); + } +}); + +test('caller hints cannot enable SSR on a configured client route', async ({ + request, +}) => { + const response = await request.get('/cart', { + headers: { 'x-analog-no-ssr': 'false' }, + }); + expect(response.status()).toBe(200); + expect(await response.text()).not.toContain('ng-server-context'); +}); diff --git a/apps/analog-app/vite.config.ts b/apps/analog-app/vite.config.ts index 1d24c4d51c..afa10a0491 100644 --- a/apps/analog-app/vite.config.ts +++ b/apps/analog-app/vite.config.ts @@ -94,6 +94,7 @@ export default defineConfig(async ({ mode, command }) => { }), nitro({ routeRules: { + '/products/**': { ssr: true }, '/client': { ssr: false }, '/cart/**': { ssr: false }, '/404.html': { ssr: false }, diff --git a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts index 4e428e18f9..426ed46c15 100644 --- a/packages/platform/src/lib/nitro/analog-nitro-plugin.ts +++ b/packages/platform/src/lib/nitro/analog-nitro-plugin.ts @@ -607,6 +607,19 @@ export default defineHandler(async (event) => { return TEMPLATE; } const service = ssr.default ?? ssr; + if (event.req.headers.has('x-analog-no-ssr')) { + const headers = new Headers(event.req.headers); + headers.delete('x-analog-no-ssr'); + // Copy public fields because server Request adapters lack native constructor state. + const request = new Request(event.req.url, { + method: event.req.method, + headers, + signal: event.req.signal, + ...(event.req.method === 'GET' || event.req.method === 'HEAD' ? {} : { body: event.req.body, duplex: 'half' }), + }); + if (event.req.runtime) Object.defineProperty(request, 'runtime', { value: event.req.runtime }); + return service.fetch(request); + } return service.fetch(event.req); }); `; diff --git a/packages/platform/src/lib/nitro/ssr-renderer.spec.ts b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts index 5698e7231b..8f2821c130 100644 --- a/packages/platform/src/lib/nitro/ssr-renderer.spec.ts +++ b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts @@ -26,10 +26,70 @@ function createEvent(ssr?: boolean) { } describe('SSR renderer route rules', () => { + it.each([undefined, true])( + 'strips caller SSR hints with rule %s', + async (ssr) => { + const fetch = vi.fn( + async (_request: Request) => new Response('rendered'), + ); + const event = createEvent(ssr); + event.req.headers.set('X-Analog-No-SSR', 'true'); + + await createRenderer(fetch)(event); + + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][0].headers.has('x-analog-no-ssr')).toBe(false); + expect(event.req.headers.get('x-analog-no-ssr')).toBe('true'); + }, + ); + + it.each(['GET', 'HEAD', 'POST'])( + 'preserves a %s request adapter when filtering', + async (method) => { + const fetch = vi.fn( + async (_request: Request) => new Response('rendered'), + ); + const abort = new AbortController(); + const runtime = { node: { req: {}, res: {} } }; + const event = { + ...createEvent(true), + req: { + url: 'http://localhost/page?query=1', + method, + headers: new Headers({ + 'x-analog-no-ssr': 'true', + 'x-custom': 'retained', + }), + body: method === 'POST' ? new Response('request payload').body : null, + signal: abort.signal, + runtime, + }, + }; + + await createRenderer(fetch)(event); + + const forwarded = fetch.mock.calls[0][0]; + expect(forwarded).toBeInstanceOf(Request); + expect(forwarded.url).toBe(event.req.url); + expect(forwarded.method).toBe(method); + expect(forwarded.headers.get('x-custom')).toBe('retained'); + expect(forwarded.headers.has('x-analog-no-ssr')).toBe(false); + expect(Reflect.get(forwarded, 'runtime')).toBe(runtime); + expect(await forwarded.text()).toBe( + method === 'POST' ? 'request payload' : '', + ); + const reason = new Error('disconnected'); + abort.abort(reason); + expect(forwarded.signal.aborted).toBe(true); + expect(forwarded.signal.reason).toBe(reason); + }, + ); + it('returns the client template before response headers are applied', async () => { const fetch = vi.fn(async () => new Response('server-rendered content')); const render = createRenderer(fetch); const event = createEvent(false); + event.req.headers.set('x-analog-no-ssr', 'false'); expect(await render(event)).toBe(template); expect(fetch).not.toHaveBeenCalled(); From 535fb080ee9b29931a6f6232a08f532213f164f0 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Mon, 7 Sep 2026 16:05:05 +0000 Subject: [PATCH 3/3] test(platform): type HTTP SSR policy header cases --- apps/analog-app-e2e/tests/ssr-policy.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/analog-app-e2e/tests/ssr-policy.spec.ts b/apps/analog-app-e2e/tests/ssr-policy.spec.ts index cea868d6bc..2c1b5aa86b 100644 --- a/apps/analog-app-e2e/tests/ssr-policy.spec.ts +++ b/apps/analog-app-e2e/tests/ssr-policy.spec.ts @@ -3,8 +3,10 @@ import { expect, test } from '@playwright/test'; test('caller hints cannot disable SSR on an enabled route', async ({ request, }) => { - for (const headers of [{}, { 'x-analog-no-ssr': 'true' }]) { - const response = await request.get('/products/1?ssr-policy=1', { headers }); + for (const supplied of [false, true]) { + const response = await request.get('/products/1?ssr-policy=1', { + headers: supplied ? { 'x-analog-no-ssr': 'true' } : {}, + }); expect(response.status()).toBe(200); const html = await response.text(); expect(html).toContain('Product Details');