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/analog-app-e2e/tests/ssr-policy.spec.ts b/apps/analog-app-e2e/tests/ssr-policy.spec.ts new file mode 100644 index 0000000000..2c1b5aa86b --- /dev/null +++ b/apps/analog-app-e2e/tests/ssr-policy.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; + +test('caller hints cannot disable SSR on an enabled route', async ({ + request, +}) => { + 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'); + 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/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..426ed46c15 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,14 +600,26 @@ 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; + 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); }); `; @@ -748,7 +760,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 +781,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..8f2821c130 --- /dev/null +++ b/packages/platform/src/lib/nitro/ssr-renderer.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { generateSsrRendererVirtual } from './analog-nitro-plugin'; + +const template = '