Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .node-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24.14.1
24.15.0
1 change: 1 addition & 0 deletions .nvmrc
12 changes: 3 additions & 9 deletions .nx/workflows/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions apps/analog-app-e2e/tests/ssr-policy.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
1 change: 1 addition & 0 deletions apps/analog-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,35 @@ 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: [
analog({
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 },
},
}),
],
Expand Down
8 changes: 8 additions & 0 deletions packages/platform/src/lib/nitro/analog-nitro-plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {},
},
},
Expand All @@ -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();
});
});
34 changes: 25 additions & 9 deletions packages/platform/src/lib/nitro/analog-nitro-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
});
`;
Expand Down Expand Up @@ -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
Expand All @@ -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' };
Expand Down
131 changes: 131 additions & 0 deletions packages/platform/src/lib/nitro/ssr-renderer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';

import { generateSsrRendererVirtual } from './analog-nitro-plugin';

const template = '<html><body><app-root></app-root></body></html>';

function createRenderer(fetch: (request: Request) => Promise<Response>) {
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.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();
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);
});
});
Loading