Skip to content

Commit

Permalink
feat(@angular/build): utilize ssr.entry in Vite dev-server when ava…
Browse files Browse the repository at this point in the history
…ilable

When `ssr.entry` (server.ts) is defined, Vite will now use it in the dev-server. This allows API and routes defined in `server.ts` to be accessible during development. This feature requires the new `@angular/ssr` APIs, which are currently in developer preview.
  • Loading branch information
alan-agius4 committed Sep 23, 2024
1 parent ad014c7 commit bbc2901
Show file tree
Hide file tree
Showing 11 changed files with 718 additions and 139 deletions.
4 changes: 2 additions & 2 deletions packages/angular/build/src/builders/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,15 @@ export async function* buildApplicationInternal(

yield* runEsBuildBuildAction(
async (rebuildState) => {
const { serverEntryPoint, jsonLogs } = normalizedOptions;
const { serverEntryPoint, jsonLogs, disableFullServerManifestGeneration } = normalizedOptions;

const startTime = process.hrtime.bigint();
const result = await executeBuild(normalizedOptions, context, rebuildState);

if (jsonLogs) {
result.addLog(await createJsonBuildManifest(result, normalizedOptions));
} else {
if (serverEntryPoint) {
if (serverEntryPoint && !disableFullServerManifestGeneration) {
const prerenderedRoutesLength = Object.keys(result.prerenderedRoutes).length;
let prerenderMsg = `Prerendered ${prerenderedRoutesLength} static route`;
prerenderMsg += prerenderedRoutesLength !== 1 ? 's.' : '.';
Expand Down
82 changes: 50 additions & 32 deletions packages/angular/build/src/builders/dev-server/vite-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import type { Connect, DepOptimizationConfig, InlineConfig, ViteDevServer } from
import { createAngularMemoryPlugin } from '../../tools/vite/angular-memory-plugin';
import { createAngularLocaleDataPlugin } from '../../tools/vite/i18n-locale-plugin';
import { createRemoveIdPrefixPlugin } from '../../tools/vite/id-prefix-plugin';
import {
ServerSsrMode,
createAngularSetupMiddlewaresPlugin,
} from '../../tools/vite/setup-middlewares-plugin';
import { createAngularSsrServerPlugin } from '../../tools/vite/ssr-server-plugin';
import { loadProxyConfiguration, normalizeSourceMaps } from '../../utils';
import { loadEsmModule } from '../../utils/load-esm';
import { Result, ResultFile, ResultKind } from '../application/results';
Expand Down Expand Up @@ -313,14 +318,25 @@ export async function* serveWithVite(
? browserOptions.polyfills
: [browserOptions.polyfills];

let ssrMode: ServerSsrMode = ServerSsrMode.NoSsr;
if (
browserOptions.outputMode &&
typeof browserOptions.ssr === 'object' &&
browserOptions.ssr.entry
) {
ssrMode = ServerSsrMode.ExternalSsrMiddleware;
} else if (browserOptions.server) {
ssrMode = ServerSsrMode.InternalSsrMiddleware;
}

// Setup server and start listening
const serverConfiguration = await setupServer(
serverOptions,
generatedFiles,
assetFiles,
browserOptions.preserveSymlinks,
externalMetadata,
!!browserOptions.ssr,
ssrMode,
prebundleTransformer,
target,
isZonelessApp(polyfills),
Expand All @@ -334,12 +350,6 @@ export async function* serveWithVite(
server = await createServer(serverConfiguration);
await server.listen();

if (browserOptions.ssr && serverOptions.prebundle !== false) {
// Warm up the SSR request and begin optimizing dependencies.
// Without this, Vite will only start optimizing SSR modules when the first request is made.
void server.warmupRequest('./main.server.mjs', { ssr: true });
}

const urls = server.resolvedUrls;
if (urls && (urls.local.length || urls.network.length)) {
serverUrl = new URL(urls.local[0] ?? urls.network[0]);
Expand Down Expand Up @@ -385,34 +395,37 @@ async function handleUpdate(
usedComponentStyles: Map<string, string[]>,
): Promise<void> {
const updatedFiles: string[] = [];
let isServerFileUpdated = false;
let destroyAngularServerAppCalled = false;

// Invalidate any updated files
for (const [file, record] of generatedFiles) {
if (record.updated) {
updatedFiles.push(file);
isServerFileUpdated ||= record.type === BuildOutputFileType.ServerApplication;
for (const [file, { updated, type }] of generatedFiles) {
if (!updated) {
continue;
}

const updatedModules = server.moduleGraph.getModulesByFile(
normalizePath(join(server.config.root, file)),
);
updatedModules?.forEach((m) => server?.moduleGraph.invalidateModule(m));
if (type === BuildOutputFileType.ServerApplication && !destroyAngularServerAppCalled) {
// Clear the server app cache
// This must be done before module invalidation.
const { ɵdestroyAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as {
ɵdestroyAngularServerApp: typeof destroyAngularServerApp;
};

ɵdestroyAngularServerApp();
destroyAngularServerAppCalled = true;
}

updatedFiles.push(file);

const updatedModules = server.moduleGraph.getModulesByFile(
normalizePath(join(server.config.root, file)),
);
updatedModules?.forEach((m) => server.moduleGraph.invalidateModule(m));
}

if (!updatedFiles.length) {
return;
}

// clean server apps cache
if (isServerFileUpdated) {
const { ɵdestroyAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as {
ɵdestroyAngularServerApp: typeof destroyAngularServerApp;
};

ɵdestroyAngularServerApp();
}

if (serverOptions.liveReload || serverOptions.hmr) {
if (updatedFiles.every((f) => f.endsWith('.css'))) {
const timestamp = Date.now();
Expand Down Expand Up @@ -534,7 +547,7 @@ export async function setupServer(
assets: Map<string, string>,
preserveSymlinks: boolean | undefined,
externalMetadata: DevServerExternalResultMetadata,
ssr: boolean,
ssrMode: ServerSsrMode,
prebundleTransformer: JavaScriptTransformer,
target: string[],
zoneless: boolean,
Expand Down Expand Up @@ -587,6 +600,9 @@ export async function setupServer(
preserveSymlinks,
},
server: {
warmup: {
ssrFiles: ['./main.server.mjs', './server.mjs'],
},
port: serverOptions.port,
strictPort: true,
host: serverOptions.host,
Expand Down Expand Up @@ -637,19 +653,21 @@ export async function setupServer(
},
plugins: [
createAngularLocaleDataPlugin(),
createAngularMemoryPlugin({
workspaceRoot: serverOptions.workspaceRoot,
virtualProjectRoot,
createAngularSetupMiddlewaresPlugin({
outputFiles,
assets,
ssr,
external: externalMetadata.explicitBrowser,
indexHtmlTransformer,
extensionMiddleware,
normalizePath,
usedComponentStyles,
ssrMode,
}),
createRemoveIdPrefixPlugin(externalMetadata.explicitBrowser),
await createAngularSsrServerPlugin(serverOptions.workspaceRoot),
await createAngularMemoryPlugin({
virtualProjectRoot,
outputFiles,
external: externalMetadata.explicitBrowser,
}),
],
// Browser only optimizeDeps. (This does not run for SSR dependencies).
optimizeDeps: getDepOptimizationConfig({
Expand Down
106 changes: 22 additions & 84 deletions packages/angular/build/src/tools/vite/angular-memory-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,26 @@
* found in the LICENSE file at https://angular.dev/license
*/

import remapping, { SourceMapInput } from '@ampproject/remapping';
import assert from 'node:assert';
import { readFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import type { Connect, Plugin } from 'vite';
import {
angularHtmlFallbackMiddleware,
createAngularAssetsMiddleware,
createAngularHeadersMiddleware,
createAngularIndexHtmlMiddleware,
createAngularSSRMiddleware,
} from './middlewares';
import { basename, dirname, join, relative } from 'node:path';
import type { Plugin } from 'vite';
import { loadEsmModule } from '../../utils/load-esm';
import { AngularMemoryOutputFiles } from './utils';

export interface AngularMemoryPluginOptions {
workspaceRoot: string;
virtualProjectRoot: string;
outputFiles: AngularMemoryOutputFiles;
assets: Map<string, string>;
ssr: boolean;
external?: string[];
extensionMiddleware?: Connect.NextHandleFunction[];
indexHtmlTransformer?: (content: string) => Promise<string>;
normalizePath: (path: string) => string;
usedComponentStyles: Map<string, string[]>;
}

export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions): Plugin {
const {
workspaceRoot,
virtualProjectRoot,
outputFiles,
assets,
external,
ssr,
extensionMiddleware,
indexHtmlTransformer,
normalizePath,
usedComponentStyles,
} = options;
export async function createAngularMemoryPlugin(
options: AngularMemoryPluginOptions,
): Promise<Plugin> {
const { virtualProjectRoot, outputFiles, external } = options;
const { normalizePath } = await loadEsmModule<typeof import('vite')>('vite');
// See: https://github.com/vitejs/vite/blob/a34a73a3ad8feeacc98632c0f4c643b6820bbfda/packages/vite/src/node/server/pluginContainer.ts#L331-L334
const defaultImporter = join(virtualProjectRoot, 'index.html');

return {
name: 'vite:angular-memory',
Expand All @@ -59,12 +39,18 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions):
return source;
}

if (importer && source[0] === '.' && normalizePath(importer).startsWith(virtualProjectRoot)) {
// Remove query if present
const [importerFile] = importer.split('?', 1);

source =
'/' + normalizePath(join(dirname(relative(virtualProjectRoot, importerFile)), source));
if (importer) {
let normalizedSource: string | undefined;
if (source[0] === '.' && normalizePath(importer).startsWith(virtualProjectRoot)) {
// Remove query if present
const [importerFile] = importer.split('?', 1);
normalizedSource = join(dirname(relative(virtualProjectRoot, importerFile)), source);
} else if (source[0] === '/' && importer === defaultImporter) {
normalizedSource = basename(source);
}
if (normalizedSource) {
source = '/' + normalizePath(normalizedSource);
}
}

const [file] = source.split('?', 1);
Expand Down Expand Up @@ -92,54 +78,6 @@ export function createAngularMemoryPlugin(options: AngularMemoryPluginOptions):
map: mapContents && Buffer.from(mapContents).toString('utf-8'),
};
},
// eslint-disable-next-line max-lines-per-function
configureServer(server) {
const originalssrTransform = server.ssrTransform;
server.ssrTransform = async (code, map, url, originalCode) => {
const result = await originalssrTransform(code, null, url, originalCode);
if (!result || !result.map || !map) {
return result;
}

const remappedMap = remapping(
[result.map as SourceMapInput, map as SourceMapInput],
() => null,
);

// Set the sourcemap root to the workspace root. This is needed since we set a virtual path as root.
remappedMap.sourceRoot = normalizePath(workspaceRoot) + '/';

return {
...result,
map: remappedMap as (typeof result)['map'],
};
};

server.middlewares.use(createAngularHeadersMiddleware(server));

// Assets and resources get handled first
server.middlewares.use(
createAngularAssetsMiddleware(server, assets, outputFiles, usedComponentStyles),
);

if (extensionMiddleware?.length) {
extensionMiddleware.forEach((middleware) => server.middlewares.use(middleware));
}

// Returning a function, installs middleware after the main transform middleware but
// before the built-in HTML middleware
return () => {
if (ssr) {
server.middlewares.use(createAngularSSRMiddleware(server, indexHtmlTransformer));
}

server.middlewares.use(angularHtmlFallbackMiddleware);

server.middlewares.use(
createAngularIndexHtmlMiddleware(server, outputFiles, indexHtmlTransformer),
);
};
},
};
}

Expand Down
5 changes: 4 additions & 1 deletion packages/angular/build/src/tools/vite/middlewares/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@
export { createAngularAssetsMiddleware } from './assets-middleware';
export { angularHtmlFallbackMiddleware } from './html-fallback-middleware';
export { createAngularIndexHtmlMiddleware } from './index-html-middleware';
export { createAngularSSRMiddleware } from './ssr-middleware';
export {
createAngularSsrExternalMiddleware,
createAngularSsrInternalMiddleware,
} from './ssr-middleware';
export { createAngularHeadersMiddleware } from './headers-middleware';
Loading

0 comments on commit bbc2901

Please sign in to comment.