Skip to content

Conversation

@alessiopelliccione
Copy link
Contributor

Move the ExternalSsrMiddleware registration to execute before Vite's internal middlewares. This fixes an issue where custom server routes (like /health or /api) were unreachable in development mode if the application used a specific baseHref, as Vite would filter them out before the SSR middleware could process them.

Closes #31896

PR Checklist

Please check to confirm your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • Other... Please describe:

What is the current behavior?

The ExternalSsrMiddleware is currently registered via the returned callback in configureServer, placing it after Vite's internal middlewares in the stack.
Consequently, if a baseHref is configured (e.g., /app/), Vite intercepts requests matching the root or other paths before they reach the custom SSR server.
This prevents custom handlers in server.ts (such as /ping or /health) from processing requests that fall outside the application's base path.

This is causing issues like #31896

What is the new behavior?

The ExternalSsrMiddleware is registered immediately within the configureServer hook, ensuring it executes before Vite's internal middlewares. This allows the custom SSR server to intercept and handle requests first, enabling support for routes defined outside the configured baseHref while allowing unhandled requests to fall through to Vite.

Does this PR introduce a breaking change?

  • Yes
  • No

…outside baseHref

Move the `ExternalSsrMiddleware` registration to execute before Vite's internal middlewares. This fixes an issue where custom server routes (like `/health` or `/api`) were unreachable in development mode if the application used a specific `baseHref`, as Vite would filter them out before the SSR middleware could process them.

Closes angular#31896
@alessiopelliccione alessiopelliccione force-pushed the fix-dev-server-base-href-middleware-fallthrough branch from 2f4cf38 to fe496e1 Compare November 24, 2025 20:40
@alessiopelliccione alessiopelliccione changed the title fix(build): ensure external SSR middleware handles requests outside baseHref fix(@angular/build): ensure external SSR middleware handles requests outside baseHref Nov 24, 2025
@alan-agius4
Copy link
Collaborator

Hi @alessiopelliccione, thanks for your contribution. We need a slightly different approach for this as the current implementation breaks some e2e tests.

@alessiopelliccione
Copy link
Contributor Author

alessiopelliccione commented Nov 25, 2025

Hi @alessiopelliccione, thanks for your contribution. We need a slightly different approach for this as the current implementation breaks some e2e tests.

Hi @alan-agius4,

The SSR middleware was incorrectly intercepting Vite's internal requests after the recent reordering. This was causing the failures of E2E tests.

I added locally a check to bypass the middleware for paths like /@... and /__vite..., which restores correct dev server behavior and fixes the failing E2E pipeline.

I pushed this update alessiopelliccione@309a096

Tell me if it should be ok now, and I will squash the two commits.

@alan-agius4
Copy link
Collaborator

Hi @alessiopelliccione, I doubt that it will be enough as with your change all requests will be passed via the middleware, include those for assets in memory, this will cause assets that exist to return a 404 when using hono/fastify.

Example:

$ curl localhost:4200/main.js

You can try adding a new e2e

tests/legacy-cli/e2e/tests/vite/ssr-serve-base-href.ts

import assert from 'node:assert';
import { writeMultipleFiles } from '../../utils/fs';
import { ng, silentNg } from '../../utils/process';
import { installWorkspacePackages, uninstallPackage } from '../../utils/packages';
import { ngServe, updateJsonFile, useSha } from '../../utils/project';
import { getGlobalVariable } from '../../utils/env';

export default async function () {
  assert(
    getGlobalVariable('argv')['esbuild'],
    'This test should not be called in the Webpack suite.',
  );

  // Forcibly remove in case another test doesn't clean itself up.
  await uninstallPackage('@angular/ssr');
  await ng('add', '@angular/ssr', '--skip-confirmation', '--skip-install');
  await useSha();
  await installWorkspacePackages();
  await updateJsonFile('angular.json', (workspaceJson) => {
    workspaceJson.projects['test-project'].architect.build.options.baseHref = '/base/';
  });

  await writeMultipleFiles({
    'src/app/app.routes.ts': `
      import { Routes } from '@angular/router';
      import { Home } from './home/home';

      export const routes: Routes = [
        { path: 'home', component: Home }
      ];
    `,
    'src/server.ts': `
      import { AngularNodeAppEngine, writeResponseToNodeResponse, isMainModule, createNodeRequestHandler } from '@angular/ssr/node';
      import express from 'express';
      import { join } from 'node:path';

      export function app(): express.Express {
        const server = express();
        const browserDistFolder = join(import.meta.dirname, '../browser');
        const angularNodeAppEngine = new AngularNodeAppEngine();

        server.use('/api/{*splat}', (req, res) => {
          res.json({ hello: 'foo' })
        });

        server.use(express.static(browserDistFolder, {
          maxAge: '1y',
          index: 'index.html'
        }));

        server.use(async(req, res, next) => {
          const response = await angularNodeAppEngine.handle(req);
          if (response) {
            writeResponseToNodeResponse(response, res);
          } else {
            next();
          }
        });

        return server;
      }

      const server = app();
      export const reqHandler = createNodeRequestHandler(server);
  `,
  });

  await silentNg('generate', 'component', 'home');

  const port = await ngServe();

  await validateResponse('/base/main.js', /bootstrapApplication/);
  await validateResponse('/base/home', /home works/);
  await validateResponse('/api/test', /foo/);

  await validate404Response('/base/api/test');
  await validate404Response('/home');
  await validate404Response('/main.js');

  function validate404Response(pathname: string): Promise<void> {
    return validateResponse(pathname, /Cannot GET/, 404);
  }

  async function validateResponse(pathname: string, match: RegExp, status = 200): Promise<void> {
    const response = await fetch(new URL(pathname, `http://localhost:${port}`));
    assert.equal(response.status, status, `Response status for ${pathname} does not match`);

    const text = await response.text();
    assert.match(text, match, `Response body for ${pathname} does not match`);
  }
}

And you can run it via

pnpm bazel run //tests/legacy-cli:e2e_node22 --config=e2e --test_arg="--glob=tests/vite/ssr-serve-base-href.ts" --test_arg="--esbuild"

Or ran and debug one of the failing tests via:

pnpm bazel run //tests/legacy-cli:e2e_node22 --config=e2e --test_arg="--glob=tests/vite/ssr-entry-hono.ts" --test_arg="--esbuild"

@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Dec 26, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Angular SSR: Express routes are only prefixed in development with baseHref set

2 participants