From 8c550302cc046e649f1245007e0e26550a61f931 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 27 Mar 2023 12:25:12 -0400 Subject: [PATCH] feat(@angular-devkit/build-angular): initial development server for esbuild-based builder When using the experimental esbuild-based browser application builder, the preexisting `dev-server` builder can now be used to execute the `ng serve` command with an esbuild bundled application. The `dev-server` builder provides an alternate development server that will execute the `browser-esbuild` builder to build the application and then serve the output files within a development server with live reload capabilities. This is an initial integration of the development server. It is not yet fully optimized and all features may not yet be supported. SSL, in particular, does not yet work. If already using the esbuild-based builder, no additional changes to the Angular configuration are required. The `dev-server` builder will automatically detect the application builder and use the relevent development server implementation. As the esbuild-based browser application builders is currently experimental, using the development server in this mode is also considered experimental. --- package.json | 1 + .../angular_devkit/build_angular/BUILD.bazel | 1 + .../angular_devkit/build_angular/package.json | 1 + .../src/builders/browser-esbuild/index.ts | 7 +- .../src/builders/dev-server/builder.ts | 25 +- .../builders/dev-server/load-proxy-config.ts | 98 +++++++ .../src/builders/dev-server/vite-server.ts | 267 ++++++++++++++++++ yarn.lock | 29 +- 8 files changed, 412 insertions(+), 17 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/dev-server/load-proxy-config.ts create mode 100644 packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts diff --git a/package.json b/package.json index a5c74f2d86a5..bcbecf127ea6 100644 --- a/package.json +++ b/package.json @@ -212,6 +212,7 @@ "typescript": "~5.0.2", "verdaccio": "5.22.1", "verdaccio-auth-memory": "^10.0.0", + "vite": "4.2.1", "webpack": "5.76.2", "webpack-dev-middleware": "6.0.2", "webpack-dev-server": "4.13.1", diff --git a/packages/angular_devkit/build_angular/BUILD.bazel b/packages/angular_devkit/build_angular/BUILD.bazel index 7440acf456c3..43d70d4bc2eb 100644 --- a/packages/angular_devkit/build_angular/BUILD.bazel +++ b/packages/angular_devkit/build_angular/BUILD.bazel @@ -175,6 +175,7 @@ ts_library( "@npm//tree-kill", "@npm//tslib", "@npm//typescript", + "@npm//vite", "@npm//webpack", "@npm//webpack-dev-middleware", "@npm//webpack-dev-server", diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 3b2c3e8af1f6..07d9a589d9df 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -61,6 +61,7 @@ "text-table": "0.2.0", "tree-kill": "1.2.2", "tslib": "2.5.0", + "vite": "4.2.1", "webpack": "5.76.2", "webpack-dev-middleware": "6.0.2", "webpack-dev-server": "4.13.1", diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index fdd5ee7d013b..a699f08f2063 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -560,7 +560,12 @@ export async function* buildEsbuildBrowser( infrastructureSettings?: { write?: boolean; }, -): AsyncIterable { +): AsyncIterable< + BuilderOutput & { + outputFiles?: OutputFile[]; + assetFiles?: { source: string; destination: string }[]; + } +> { // Inform user of experimental status of builder and options logExperimentalWarnings(userOptions, context); diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts index b42c74e3955c..00139f0235d8 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/builder.ts @@ -6,15 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { BuilderContext } from '@angular-devkit/architect'; import { EMPTY, Observable, defer, switchMap } from 'rxjs'; -import { ExecutionTransformer } from '../../transforms'; +import type { ExecutionTransformer } from '../../transforms'; import { checkPort } from '../../utils/check-port'; -import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; +import type { IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { normalizeOptions } from './options'; -import { Schema as DevServerBuilderOptions } from './schema'; -import { DevServerBuilderOutput, serveWebpackBrowser } from './webpack-server'; +import type { Schema as DevServerBuilderOptions } from './schema'; +import type { DevServerBuilderOutput } from './webpack-server'; /** * A Builder that executes a development server based on the provided browser target option. @@ -44,16 +44,19 @@ export function execute( return defer(() => initialize(options, projectName, context)).pipe( switchMap(({ builderName, normalizedOptions }) => { - // Issue a warning that the dev-server does not currently support the experimental esbuild- - // based builder and will use Webpack. + // Use vite-based development server for esbuild-based builds if (builderName === '@angular-devkit/build-angular:browser-esbuild') { - context.logger.warn( - 'WARNING: The experimental esbuild-based builder is not currently supported ' + - 'by the dev-server. The stable Webpack-based builder will be used instead.', + return defer(() => import('./vite-server')).pipe( + switchMap(({ serveWithVite }) => serveWithVite(normalizedOptions, builderName, context)), ); } - return serveWebpackBrowser(normalizedOptions, builderName, context, transforms); + // Use Webpack for all other browser targets + return defer(() => import('./webpack-server')).pipe( + switchMap(({ serveWebpackBrowser }) => + serveWebpackBrowser(normalizedOptions, builderName, context, transforms), + ), + ); }), ); } diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/load-proxy-config.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/load-proxy-config.ts new file mode 100644 index 000000000000..9d8b29f17cce --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/load-proxy-config.ts @@ -0,0 +1,98 @@ +/** + * @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 { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { assertIsError } from '../../utils/error'; +import { loadEsmModule } from '../../utils/load-esm'; + +export async function loadProxyConfiguration(root: string, proxyConfig: string | undefined) { + if (!proxyConfig) { + return undefined; + } + + const proxyPath = resolve(root, proxyConfig); + + if (!existsSync(proxyPath)) { + throw new Error(`Proxy configuration file ${proxyPath} does not exist.`); + } + + switch (extname(proxyPath)) { + case '.json': { + const content = await readFile(proxyPath, 'utf-8'); + + const { parse, printParseErrorCode } = await import('jsonc-parser'); + const parseErrors: import('jsonc-parser').ParseError[] = []; + const proxyConfiguration = parse(content, parseErrors, { allowTrailingComma: true }); + + if (parseErrors.length > 0) { + let errorMessage = `Proxy configuration file ${proxyPath} contains parse errors:`; + for (const parseError of parseErrors) { + const { line, column } = getJsonErrorLineColumn(parseError.offset, content); + errorMessage += `\n[${line}, ${column}] ${printParseErrorCode(parseError.error)}`; + } + throw new Error(errorMessage); + } + + return proxyConfiguration; + } + case '.mjs': + // Load the ESM configuration file using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + return (await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath))).default; + case '.cjs': + return require(proxyPath); + default: + // The file could be either CommonJS or ESM. + // CommonJS is tried first then ESM if loading fails. + try { + return require(proxyPath); + } catch (e) { + assertIsError(e); + if (e.code === 'ERR_REQUIRE_ESM') { + // Load the ESM configuration file using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + return (await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath))).default; + } + + throw e; + } + } +} + +/** + * Calculates the line and column for an error offset in the content of a JSON file. + * @param location The offset error location from the beginning of the content. + * @param content The full content of the file containing the error. + * @returns An object containing the line and column + */ +function getJsonErrorLineColumn(offset: number, content: string) { + if (offset === 0) { + return { line: 1, column: 1 }; + } + + let line = 0; + let position = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + ++line; + + const nextNewline = content.indexOf('\n', position); + if (nextNewline === -1 || nextNewline > offset) { + break; + } + + position = nextNewline + 1; + } + + return { line, column: offset - position + 1 }; +} diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts new file mode 100644 index 000000000000..9114ffb7eef0 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/vite-server.ts @@ -0,0 +1,267 @@ +/** + * @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 type { BuilderContext } from '@angular-devkit/architect'; +import type { json } from '@angular-devkit/core'; +import assert from 'node:assert'; +import { BinaryLike, createHash } from 'node:crypto'; +import path from 'node:path'; +import { InlineConfig, ViteDevServer, createServer, normalizePath } from 'vite'; +import { buildEsbuildBrowser } from '../browser-esbuild'; +import type { Schema as BrowserBuilderOptions } from '../browser-esbuild/schema'; +import { loadProxyConfiguration } from './load-proxy-config'; +import type { NormalizedDevServerOptions } from './options'; +import type { DevServerBuilderOutput } from './webpack-server'; + +interface OutputFileRecord { + text: string; + size: number; + hash?: Buffer; + updated: boolean; +} + +function hashContent(contents: BinaryLike): Buffer { + // TODO: Consider xxhash + return createHash('sha256').update(contents).digest(); +} + +export async function* serveWithVite( + serverOptions: NormalizedDevServerOptions, + builderName: string, + context: BuilderContext, +): AsyncIterableIterator { + // Get the browser configuration from the target name. + const rawBrowserOptions = (await context.getTargetOptions( + serverOptions.browserTarget, + )) as json.JsonObject & BrowserBuilderOptions; + + const browserOptions = (await context.validateOptions( + { + ...rawBrowserOptions, + watch: serverOptions.watch, + poll: serverOptions.poll, + verbose: serverOptions.verbose, + } as json.JsonObject & BrowserBuilderOptions, + builderName, + )) as json.JsonObject & BrowserBuilderOptions; + + let server: ViteDevServer | undefined; + const outputFiles = new Map(); + const assets = new Map(); + // TODO: Switch this to an architect schedule call when infrastructure settings are supported + for await (const result of buildEsbuildBrowser(browserOptions, context, { write: false })) { + assert(result.outputFiles, 'Builder did not provide result files.'); + + // Analyze result files for changes + const seen = new Set(['/index.html']); + for (const file of result.outputFiles) { + const filePath = '/' + normalizePath(file.path); + seen.add(filePath); + + // Skip analysis of sourcemaps + if (filePath.endsWith('.map')) { + outputFiles.set(filePath, { + text: file.text, + size: file.contents.byteLength, + updated: false, + }); + + continue; + } + + let fileHash: Buffer | undefined; + const existingRecord = outputFiles.get(filePath); + if (existingRecord && existingRecord.size === file.contents.byteLength) { + // Only hash existing file when needed + if (existingRecord.hash === undefined) { + existingRecord.hash = hashContent(existingRecord.text); + } + + // Compare against latest result output + fileHash = hashContent(file.contents); + if (fileHash.equals(existingRecord.hash)) { + // Same file + existingRecord.updated = false; + continue; + } + } + + outputFiles.set(filePath, { + text: file.text, + size: file.contents.byteLength, + hash: fileHash, + updated: true, + }); + } + + // Clear stale output files + for (const file of outputFiles.keys()) { + if (!seen.has(file)) { + outputFiles.delete(file); + } + } + + assets.clear(); + if (result.assetFiles) { + for (const asset of result.assetFiles) { + assets.set('/' + normalizePath(asset.destination), asset.source); + } + } + + if (server) { + // Invalidate any updated files + for (const [file, record] of outputFiles) { + if (record.updated) { + const updatedModules = server.moduleGraph.getModulesByFile(file); + updatedModules?.forEach((m) => server?.moduleGraph.invalidateModule(m)); + } + } + + // Send reload command to clients + if (serverOptions.liveReload) { + context.logger.info('Reloading client(s)...'); + + server.ws.send({ + type: 'full-reload', + path: '*', + }); + } + } else { + // Setup server and start listening + server = await setupServer(serverOptions, outputFiles, assets); + + await server.listen(); + + // log connection information + server.printUrls(); + } + + // TODO: adjust output typings to reflect both development servers + yield { success: true } as unknown as DevServerBuilderOutput; + } + + await server?.close(); +} + +async function setupServer( + serverOptions: NormalizedDevServerOptions, + outputFiles: Map, + assets: Map, +): Promise { + const proxy = await loadProxyConfiguration( + serverOptions.workspaceRoot, + serverOptions.proxyConfig, + ); + + const configuration: InlineConfig = { + configFile: false, + envFile: false, + cacheDir: path.join(serverOptions.cacheOptions.path, 'vite'), + root: serverOptions.workspaceRoot, + publicDir: false, + esbuild: false, + mode: 'development', + appType: 'spa', + css: { + devSourcemap: true, + }, + server: { + port: serverOptions.port, + strictPort: true, + host: serverOptions.host, + open: serverOptions.open, + headers: serverOptions.headers, + https: serverOptions.ssl, + proxy, + // Currently does not appear to be a way to disable file watching directly so ignore all files + watch: { + ignored: ['**/*'], + }, + }, + plugins: [ + { + name: 'vite:angular-memory', + // Ensures plugin hooks run before built-in Vite hooks + enforce: 'pre', + async resolveId(source, importer) { + if (importer && source.startsWith('.')) { + // Remove query if present + const [importerFile] = importer.split('?', 1); + + source = normalizePath(path.join(path.dirname(importerFile), source)); + } + + const [file] = source.split('?', 1); + if (outputFiles.has(file)) { + return source; + } + }, + load(id) { + const [file] = id.split('?', 1); + const code = outputFiles.get(file)?.text; + + return ( + code && { + code, + map: outputFiles.get(file + '.map')?.text, + } + ); + }, + configureServer(server) { + // Assets get handled first + server.middlewares.use(function angularAssetsMiddleware(req, res, next) { + if (req.url) { + // Rewrite all build assets to a vite raw fs URL + const assetSource = assets.get(req.url); + if (assetSource !== undefined) { + req.url = `/@fs/${assetSource}`; + } + } + next(); + }); + + // Returning a function, installs middleware after the main transform middleware but + // before the built-in HTML middleware + return () => + server.middlewares.use(function angularIndexMiddleware(req, res, next) { + if (req.url === '/' || req.url === `/index.html`) { + const rawHtml = outputFiles.get('/index.html')?.text; + if (rawHtml) { + server + .transformIndexHtml(req.url, rawHtml, req.originalUrl) + .then((processedHtml) => { + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'no-cache'); + if (serverOptions.headers) { + Object.entries(serverOptions.headers).forEach(([name, value]) => + res.setHeader(name, value), + ); + } + res.end(processedHtml); + }) + .catch((error) => next(error)); + + return; + } + } + + next(); + }); + }, + }, + ], + optimizeDeps: { + // TODO: Consider enabling for known safe dependencies (@angular/* ?) + disabled: true, + }, + }; + + const server = await createServer(configuration); + + return server; +} diff --git a/yarn.lock b/yarn.lock index 30f900121402..edffab11eef9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5653,7 +5653,7 @@ esbuild@0.17.11: "@esbuild/win32-ia32" "0.17.11" "@esbuild/win32-x64" "0.17.11" -esbuild@0.17.12, esbuild@^0.17.0: +esbuild@0.17.12, esbuild@^0.17.0, esbuild@^0.17.5: version "0.17.12" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.12.tgz#2ad7523bf1bc01881e9d904bc04e693bd3bdcf2f" integrity sha512-bX/zHl7Gn2CpQwcMtRogTTBf9l1nl+H6R8nUbjk+RuKqAE3+8FDulLA+pHvX7aA7Xe07Iwa+CWvy9I8Y2qqPKQ== @@ -9341,7 +9341,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.21, postcss@^8.2.14, postcss@^8.3.7, postcss@^8.4.16, postcss@^8.4.19: +postcss@8.4.21, postcss@^8.2.14, postcss@^8.3.7, postcss@^8.4.16, postcss@^8.4.19, postcss@^8.4.21: version "8.4.21" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== @@ -9976,9 +9976,16 @@ rimraf@~2.4.0: glob "^6.0.1" rollup@^3.0.0: - version "3.20.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.20.0.tgz#ce7bd88449a776b9f75bf4e35959e25fbd3f51b1" - integrity sha512-YsIfrk80NqUDrxrjWPXUa7PWvAfegZEXHuPsEZg58fGCdjL1I9C1i/NaG+L+27kxxwkrG/QEDEQc8s/ynXWWGQ== + version "3.17.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.17.2.tgz#a4ecd29c488672a0606e41ef57474fad715750a9" + integrity sha512-qMNZdlQPCkWodrAZ3qnJtvCAl4vpQ8q77uEujVCCbC/6CLB7Lcmvjq7HyiOSnf4fxTT9XgsE36oLHJBH49xjqA== + optionalDependencies: + fsevents "~2.3.2" + +rollup@^3.18.0: + version "3.20.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.20.2.tgz#f798c600317f216de2e4ad9f4d9ab30a89b690ff" + integrity sha512-3zwkBQl7Ai7MFYQE0y1MeQ15+9jsi7XxfrqwTb/9EK8D9C9+//EBR4M+CuA1KODRaNbFez/lWxA5vhEGZp4MUg== optionalDependencies: fsevents "~2.3.2" @@ -11362,6 +11369,18 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +vite@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.2.1.tgz#6c2eb337b0dfd80a9ded5922163b94949d7fc254" + integrity sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg== + dependencies: + esbuild "^0.17.5" + postcss "^8.4.21" + resolve "^1.22.1" + rollup "^3.18.0" + optionalDependencies: + fsevents "~2.3.2" + void-elements@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"