feat(@angular-devkit/build-angular): add ssr option in application builder

This commit adds an `ssr` option to the application builder, this can be either a `boolean` or an `object` with an `entryPoint` property.

In the future, server bundles will only be emitted when the ssr option is truthy, as unlike SSR, SSG and AppShell do not require the server bundles to be written to disk.
This commit is contained in:
Alan Agius 2023-07-10 16:14:06 +00:00 committed by Charles
parent a0a2c7aef6
commit e6b377436a
8 changed files with 148 additions and 46 deletions

View File

@ -52,6 +52,7 @@ export async function executeBuild(
cacheOptions,
prerenderOptions,
appShellOptions,
ssrOptions,
} = options;
const browsers = getSupportedBrowsers(projectRoot, context.logger);
@ -167,8 +168,7 @@ export async function executeBuild(
executionResult.addOutputFile(indexHtmlOptions.output, content);
if (serverEntryPoint) {
// TODO only add the below file when SSR is enabled.
if (ssrOptions) {
executionResult.addOutputFile('index.server.html', contentWithoutCriticalCssInlined);
}
}

View File

@ -186,6 +186,17 @@ export async function normalizeOptions(
};
}
let ssrOptions;
if (options.ssr === true) {
ssrOptions = {};
} else if (typeof options.ssr === 'object') {
const { entry } = options.ssr;
ssrOptions = {
entry: entry && path.join(workspaceRoot, entry),
};
}
let appShellOptions;
if (options.appShell) {
appShellOptions = {
@ -241,6 +252,7 @@ export async function normalizeOptions(
serverEntryPoint,
prerenderOptions,
appShellOptions,
ssrOptions,
verbose,
watch,
workspaceRoot,

View File

@ -452,6 +452,26 @@
}
]
},
"ssr": {
"description": "Server side render (SSR) pages of your application during runtime.",
"default": false,
"oneOf": [
{
"type": "boolean",
"description": "Enable the server bundles to be written to disk."
},
{
"type": "object",
"properties": {
"entry": {
"type": "string",
"description": "The server entry-point that when executed will spawn the web server."
}
},
"additionalProperties": false
}
]
},
"appShell": {
"type": "boolean",
"description": "Generates an application shell during build time.",

View File

@ -30,7 +30,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
harness.expectFile('dist/server.mjs').toExist();
harness.expectFile('dist/main.server.mjs').toExist();
harness.expectFile('dist/main.js').toExist();
});
@ -45,7 +45,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
harness.expectFile('dist/server.mjs').toExist();
harness.expectFile('dist/main.server.mjs').toExist();
});
it('fails and shows an error when file does not exist', async () => {
@ -62,7 +62,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
);
harness.expectFile('dist/main.js').toNotExist();
harness.expectFile('dist/server.mjs').toNotExist();
harness.expectFile('dist/main.server.mjs').toNotExist();
});
it('throws an error when given an empty string', async () => {
@ -88,8 +88,8 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
// Always uses the name `server.mjs` for the `server` option.
harness.expectFile('dist/server.mjs').toExist();
// Always uses the name `main.server.mjs` for the `server` option.
harness.expectFile('dist/main.server.mjs').toExist();
});
});
});

View File

@ -0,0 +1,58 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { buildApplication } from '../../index';
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
beforeEach(async () => {
await harness.modifyFile('src/tsconfig.app.json', (content) => {
const tsConfig = JSON.parse(content);
tsConfig.files ??= [];
tsConfig.files.push('main.server.ts', 'server.ts');
return JSON.stringify(tsConfig);
});
await harness.writeFile('src/server.ts', `console.log('Hello!');`);
});
describe('Option: "ssr"', () => {
it('uses a provided TypeScript file', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
ssr: {
entry: 'src/server.ts',
},
});
const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
harness.expectFile('dist/main.server.mjs').toExist();
harness.expectFile('dist/server.mjs').toExist();
});
it('resolves an absolute path as relative inside the workspace root', async () => {
await harness.writeFile('file.mjs', `console.log('Hello!');`);
harness.useTarget('build', {
...BASE_OPTIONS,
server: 'src/main.server.ts',
ssr: {
entry: '/file.mjs',
},
});
const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
harness.expectFile('dist/server.mjs').toExist();
});
});
});

View File

@ -97,7 +97,7 @@ export function createServerCodeBundleOptions(
target: string[],
sourceFileCache: SourceFileCache,
): BuildOptions {
const { jit, serverEntryPoint, workspaceRoot } = options;
const { jit, serverEntryPoint, workspaceRoot, ssrOptions } = options;
assert(
serverEntryPoint,
@ -110,7 +110,15 @@ export function createServerCodeBundleOptions(
sourceFileCache,
);
const namespace = 'angular:server-entry';
const namespace = 'angular:main-server';
const entryPoints: Record<string, string> = {
'main.server': namespace,
};
const ssrEntryPoint = ssrOptions?.entry;
if (ssrEntryPoint) {
entryPoints['server'] = ssrEntryPoint;
}
const buildOptions: BuildOptions = {
...getEsBuildCommonOptions(options),
@ -131,9 +139,7 @@ export function createServerCodeBundleOptions(
`globalThis['require'] ??= createRequire(import.meta.url);`,
].join('\n'),
},
entryPoints: {
'server': namespace,
},
entryPoints,
supported: getFeatureSupport(target),
plugins: [
createSourcemapIngorelistPlugin(),
@ -143,30 +149,6 @@ export function createServerCodeBundleOptions(
// Component stylesheet options
styleOptions,
),
createVirtualModulePlugin({
namespace,
loadContent: () => {
const mainServerEntryPoint = path
.relative(workspaceRoot, serverEntryPoint)
.replace(/\\/g, '/');
const importAndExportDec: string[] = [
`import '@angular/platform-server/init';`,
`import moduleOrBootstrapFn from './${mainServerEntryPoint}';`,
`export default moduleOrBootstrapFn;`,
`export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';`,
];
if (jit) {
importAndExportDec.unshift(`import '@angular/compiler';`);
}
return {
contents: importAndExportDec.join('\n'),
loader: 'js',
resolveDir: workspaceRoot,
};
},
}),
],
};
@ -177,6 +159,33 @@ export function createServerCodeBundleOptions(
buildOptions.plugins.push(createRxjsEsmResolutionPlugin());
}
buildOptions.plugins.push(
createVirtualModulePlugin({
namespace,
loadContent: () => {
const mainServerEntryPoint = path
.relative(workspaceRoot, serverEntryPoint)
.replace(/\\/g, '/');
const importAndExportDec: string[] = [
`import '@angular/platform-server/init';`,
`import moduleOrBootstrapFn from './${mainServerEntryPoint}';`,
`export default moduleOrBootstrapFn;`,
`export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';`,
];
if (jit) {
importAndExportDec.unshift(`import '@angular/compiler';`);
}
return {
contents: importAndExportDec.join('\n'),
loader: 'js',
resolveDir: workspaceRoot,
};
},
}),
);
return buildOptions;
}

View File

@ -63,7 +63,7 @@ async function render({ route, serverContext }: RenderOptions): Promise<RenderRe
ɵSERVER_CONTEXT,
renderModule,
renderApplication,
} = await loadEsmModule<BundleExports>('./server.mjs');
} = await loadEsmModule<BundleExports>('./main.server.mjs');
assert(ɵSERVER_CONTEXT, `ɵSERVER_CONTEXT was not exported.`);

View File

@ -375,14 +375,17 @@ async function findPackageTars(): Promise<{ [pkg: string]: PkgInfo }> {
}),
);
return pkgs.reduce((all, pkg, i) => {
const json = pkgJsons[i].toString('utf8');
const { name, version } = JSON.parse(json);
if (!name) {
throw new Error(`Package ${pkg} - package.json name/version not found`);
}
return pkgs.reduce(
(all, pkg, i) => {
const json = pkgJsons[i].toString('utf8');
const { name, version } = JSON.parse(json);
if (!name) {
throw new Error(`Package ${pkg} - package.json name/version not found`);
}
all[name] = { path: realpathSync(pkg), name, version };
return all;
}, {} as { [pkg: string]: PkgInfo });
all[name] = { path: realpathSync(pkg), name, version };
return all;
},
{} as { [pkg: string]: PkgInfo },
);
}