diff --git a/docs/generated/packages/react.json b/docs/generated/packages/react.json index 335aa360f3..348282dfc6 100644 --- a/docs/generated/packages/react.json +++ b/docs/generated/packages/react.json @@ -1391,6 +1391,49 @@ "implementation": "/packages/react/src/generators/setup-tailwind/setup-tailwind#setupTailwindGenerator.ts", "aliases": [], "path": "/packages/react/src/generators/setup-tailwind/schema.json" + }, + { + "name": "setup-ssr", + "factory": "./src/generators/setup-ssr/setup-ssr#setupSsrGenerator", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "GeneratorAngularUniversalSetup", + "cli": "nx", + "title": "Generate Angular Universal (SSR) setup for an Angular App", + "description": "Create the additional configuration required to enable SSR via Angular Universal for an Angular application.", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the application to add SSR support to.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What app would you like to add SSR support to?", + "x-dropdown": "projects" + }, + "appComponentImportPath": { + "type": "string", + "description": "The import path of the component, relative to project sourceRoot.", + "default": "app/app" + }, + "serverPort": { + "type": "number", + "default": 4200, + "description": "The port for the Express server." + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting the workspace after the generator completes." + } + }, + "required": ["project"], + "additionalProperties": false, + "presets": [] + }, + "description": "Set up SSR configuration for a project.", + "hidden": false, + "implementation": "/packages/react/src/generators/setup-ssr/setup-ssr#setupSsrGenerator.ts", + "aliases": [], + "path": "/packages/react/src/generators/setup-ssr/schema.json" } ], "executors": [ diff --git a/docs/generated/packages/webpack.json b/docs/generated/packages/webpack.json index dc1b4258e7..bcc9900f25 100644 --- a/docs/generated/packages/webpack.json +++ b/docs/generated/packages/webpack.json @@ -785,6 +785,49 @@ "aliases": [], "hidden": false, "path": "/packages/webpack/src/executors/dev-server/schema.json" + }, + { + "name": "ssr-dev-server", + "implementation": "/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts", + "schema": { + "version": 2, + "outputCapture": "direct-nodejs", + "title": "Web SSR Dev Server", + "description": "Serve a SSR application.", + "cli": "nx", + "type": "object", + "properties": { + "browserTarget": { + "type": "string", + "description": "Target which builds the browser application." + }, + "serverTarget": { + "type": "string", + "description": "Target which builds the server application." + }, + "port": { + "type": "number", + "description": "The port to be set on `process.env.PORT` for use in the server.", + "default": 4200 + }, + "browserTargetOptions": { + "type": "object", + "description": "Additional options to pass into the browser build target.", + "default": {} + }, + "serverTargetOptions": { + "type": "object", + "description": "Additional options to pass into the server build target.", + "default": {} + } + }, + "required": ["browserTarget", "serverTarget"], + "presets": [] + }, + "description": "Serve a SSR application.", + "aliases": [], + "hidden": false, + "path": "/packages/webpack/src/executors/ssr-dev-server/schema.json" } ] } diff --git a/docs/packages.json b/docs/packages.json index 00fbf1cc24..0d607e0a52 100644 --- a/docs/packages.json +++ b/docs/packages.json @@ -300,7 +300,8 @@ "remote", "cypress-component-configuration", "component-test", - "setup-tailwind" + "setup-tailwind", + "setup-ssr" ] } }, @@ -390,7 +391,7 @@ "description": "The Nx Plugin for Webpack contains executors and generators that support building applications using Webpack.", "path": "generated/packages/webpack.json", "schemas": { - "executors": ["webpack", "dev-server"], + "executors": ["webpack", "dev-server", "ssr-dev-server"], "generators": ["init", "webpack-project"] } }, diff --git a/e2e/react/src/react.test.ts b/e2e/react/src/react.test.ts index 41e3fc1c89..60aa64d8ad 100644 --- a/e2e/react/src/react.test.ts +++ b/e2e/react/src/react.test.ts @@ -23,7 +23,7 @@ describe('React Applications', () => { afterEach(() => cleanupProject()); - it('should be able to generate a react app + lib', async () => { + it('should be able to generate a react app + lib (with CSR and SSR)', async () => { const appName = uniq('app'); const libName = uniq('lib'); const libWithNoComponents = uniq('lib'); @@ -60,6 +60,18 @@ describe('React Applications', () => { checkLinter: true, checkE2E: true, }); + + // Set up SSR and check app + runCLI(`generate @nrwl/react:setup-ssr ${appName}`); + checkFilesExist(`apps/${appName}/src/main.server.tsx`); + checkFilesExist(`apps/${appName}/server.ts`); + + await testGeneratedApp(appName, { + checkSourceMap: false, + checkStyles: false, + checkLinter: false, + checkE2E: true, + }); }, 500000); it('should generate app with legacy-ie support', async () => { @@ -86,7 +98,7 @@ describe('React Applications', () => { checkFilesExist(...filesToCheck); expect(readFile(`dist/apps/${appName}/index.html`)).toContain( - `` + '' ); }, 250_000); @@ -149,7 +161,7 @@ describe('React Applications', () => { if (opts.checkStyles) { expect(readFile(`dist/apps/${appName}/index.html`)).toContain( - `` + '' ); } diff --git a/packages/esbuild/src/executors/esbuild/esbuild.impl.ts b/packages/esbuild/src/executors/esbuild/esbuild.impl.ts index 4fea12a30d..87bfb959dd 100644 --- a/packages/esbuild/src/executors/esbuild/esbuild.impl.ts +++ b/packages/esbuild/src/executors/esbuild/esbuild.impl.ts @@ -15,7 +15,7 @@ import { normalizeOptions } from './lib/normalize'; import { EsBuildExecutorOptions } from './schema'; import { removeSync, writeJsonSync } from 'fs-extra'; -import { createAsyncIterable } from '@nrwl/js/src/utils/create-async-iterable/create-async-iteratable'; +import { createAsyncIterable } from '@nrwl/js/src/utils/async-iterable/create-async-iterable'; import { buildEsbuildOptions } from './lib/build-esbuild-options'; import { getExtraDependencies } from './lib/get-extra-dependencies'; import { DependentBuildableProjectNode } from '@nrwl/workspace/src/utilities/buildable-libs-utils'; diff --git a/packages/react/src/utils/async-iterator.ts b/packages/js/src/utils/async-iterable/combine-async-iteratable-iterators.ts similarity index 60% rename from packages/react/src/utils/async-iterator.ts rename to packages/js/src/utils/async-iterable/combine-async-iteratable-iterators.ts index 366ebbb216..4046de85ca 100644 --- a/packages/react/src/utils/async-iterator.ts +++ b/packages/js/src/utils/async-iterable/combine-async-iteratable-iterators.ts @@ -1,4 +1,4 @@ -export async function* combineAsyncIterators( +export async function* combineAsyncIterableIterators( ...iterators: { 0: AsyncIterableIterator } & AsyncIterableIterator[] ) { let [options] = iterators; @@ -48,31 +48,3 @@ function getNextAsyncIteratorFactory(options) { } }; } - -export async function* mapAsyncIterator( - data: AsyncIterableIterator, - transform: (input: I, index?: number, data?: AsyncIterableIterator) => O -) { - async function* f() { - const generator = data[Symbol.asyncIterator] || data[Symbol.iterator]; - const iterator = generator.call(data); - let index = 0; - let item = await iterator.next(); - while (!item.done) { - yield await transform(await item.value, index, data); - index++; - item = await iterator.next(); - } - } - return yield* f(); -} - -export async function* tapAsyncIterator( - data: AsyncIterableIterator, - fn: (input: I) => void -) { - return yield* mapAsyncIterator(data, (x) => { - fn(x); - return x; - }); -} diff --git a/packages/js/src/utils/async-iterable/combine-async-iteratables-iterators.spec.ts b/packages/js/src/utils/async-iterable/combine-async-iteratables-iterators.spec.ts new file mode 100644 index 0000000000..2c0d5ca02b --- /dev/null +++ b/packages/js/src/utils/async-iterable/combine-async-iteratables-iterators.spec.ts @@ -0,0 +1,53 @@ +import { combineAsyncIterableIterators } from './combine-async-iteratable-iterators'; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('combineAsyncIterators', () => { + it('should merge iterators', async () => { + async function* a() { + await delay(20); + yield 'a'; + } + + async function* b() { + await delay(0); + yield 'b'; + } + + const c = combineAsyncIterableIterators(a(), b()); + const results = []; + + for await (const x of c) { + results.push(x); + } + + expect(results).toEqual(['b', 'a']); + }); + + it('should throw when one iterator throws', async () => { + async function* a() { + await delay(20); + yield 'a'; + } + + async function* b() { + throw new Error('threw in b'); + } + + const c = combineAsyncIterableIterators(a(), b()); + + async function* d() { + yield* c; + } + + try { + for await (const x of d()) { + } + throw new Error('should not reach here'); + } catch (e) { + expect(e.message).toMatch(/threw in b/); + } + }); +}); diff --git a/packages/js/src/utils/create-async-iterable/create-async-iterable.spec.ts b/packages/js/src/utils/async-iterable/create-async-iterable.spec.ts similarity index 95% rename from packages/js/src/utils/create-async-iterable/create-async-iterable.spec.ts rename to packages/js/src/utils/async-iterable/create-async-iterable.spec.ts index 2c999bc014..faedf38e62 100644 --- a/packages/js/src/utils/create-async-iterable/create-async-iterable.spec.ts +++ b/packages/js/src/utils/async-iterable/create-async-iterable.spec.ts @@ -1,4 +1,4 @@ -import { createAsyncIterable } from './create-async-iteratable'; +import { createAsyncIterable } from './create-async-iterable'; describe(createAsyncIterable.name, () => { test('simple callback', async () => { diff --git a/packages/js/src/utils/create-async-iterable/create-async-iteratable.ts b/packages/js/src/utils/async-iterable/create-async-iterable.ts similarity index 100% rename from packages/js/src/utils/create-async-iterable/create-async-iteratable.ts rename to packages/js/src/utils/async-iterable/create-async-iterable.ts diff --git a/packages/js/src/utils/async-iterable/map-async-iteratable.spec.ts b/packages/js/src/utils/async-iterable/map-async-iteratable.spec.ts new file mode 100644 index 0000000000..c91bf57563 --- /dev/null +++ b/packages/js/src/utils/async-iterable/map-async-iteratable.spec.ts @@ -0,0 +1,20 @@ +import { mapAsyncIterable } from './map-async-iteratable'; + +describe('mapAsyncIterator', () => { + it('should map over values', async () => { + async function* f() { + yield 1; + yield 2; + yield 3; + } + + const c = mapAsyncIterable(f(), (x) => x * 2); + const results = []; + + for await (const x of c) { + results.push(x); + } + + expect(results).toEqual([2, 4, 6]); + }); +}); diff --git a/packages/js/src/utils/async-iterable/map-async-iteratable.ts b/packages/js/src/utils/async-iterable/map-async-iteratable.ts new file mode 100644 index 0000000000..6ff58ff44a --- /dev/null +++ b/packages/js/src/utils/async-iterable/map-async-iteratable.ts @@ -0,0 +1,22 @@ +export async function* mapAsyncIterable( + data: AsyncIterable | AsyncIterableIterator, + transform: ( + input: I, + index?: number, + data?: AsyncIterable | AsyncIterableIterator + ) => O +) { + async function* f() { + const generator = data[Symbol.asyncIterator] || data[Symbol.iterator]; + const iterator = generator.call(data); + let index = 0; + let item = await iterator.next(); + while (!item.done) { + yield await transform(await item.value, index, data); + index++; + item = await iterator.next(); + } + } + + return yield* f(); +} diff --git a/packages/js/src/utils/async-iterable/tap-async-iteratable.spec.ts b/packages/js/src/utils/async-iterable/tap-async-iteratable.spec.ts new file mode 100644 index 0000000000..6ba8d07940 --- /dev/null +++ b/packages/js/src/utils/async-iterable/tap-async-iteratable.spec.ts @@ -0,0 +1,25 @@ +import { tapAsyncIterator } from './tap-async-iteratable'; + +describe('tapAsyncIterator', () => { + it('should tap values', async () => { + async function* f() { + yield 1; + yield 2; + yield 3; + } + + const tapped = []; + const results = []; + + const c = tapAsyncIterator(f(), (x) => { + tapped.push(`tap: ${x}`); + }); + + for await (const x of c) { + results.push(x); + } + + expect(tapped).toEqual(['tap: 1', 'tap: 2', 'tap: 3']); + expect(results).toEqual([1, 2, 3]); + }); +}); diff --git a/packages/js/src/utils/async-iterable/tap-async-iteratable.ts b/packages/js/src/utils/async-iterable/tap-async-iteratable.ts new file mode 100644 index 0000000000..8ff02bf62e --- /dev/null +++ b/packages/js/src/utils/async-iterable/tap-async-iteratable.ts @@ -0,0 +1,11 @@ +import { mapAsyncIterable } from './map-async-iteratable'; + +export async function* tapAsyncIterator( + data: AsyncIterable | AsyncIterableIterator, + fn: (input: I) => void +) { + return yield* mapAsyncIterable(data, (x) => { + fn(x); + return x; + }); +} diff --git a/packages/js/src/utils/swc/compile-swc.ts b/packages/js/src/utils/swc/compile-swc.ts index 59b10b3c34..c597470802 100644 --- a/packages/js/src/utils/swc/compile-swc.ts +++ b/packages/js/src/utils/swc/compile-swc.ts @@ -1,7 +1,7 @@ import { cacheDir, ExecutorContext, logger } from '@nrwl/devkit'; import { exec, execSync } from 'child_process'; import { removeSync } from 'fs-extra'; -import { createAsyncIterable } from '../create-async-iterable/create-async-iteratable'; +import { createAsyncIterable } from '../async-iterable/create-async-iterable'; import { NormalizedSwcExecutorOptions, SwcCliOptions } from '../schema'; import { printDiagnostics } from '../typescript/print-diagnostics'; import { runTypeCheck, TypeCheckOptions } from '../typescript/run-type-check'; diff --git a/packages/js/src/utils/typescript/compile-typescript-files.ts b/packages/js/src/utils/typescript/compile-typescript-files.ts index 1dde8e35f8..88e5d13110 100644 --- a/packages/js/src/utils/typescript/compile-typescript-files.ts +++ b/packages/js/src/utils/typescript/compile-typescript-files.ts @@ -4,7 +4,7 @@ import { TypeScriptCompilationOptions, } from '@nrwl/workspace/src/utilities/typescript/compilation'; import type { Diagnostic } from 'typescript'; -import { createAsyncIterable } from '../create-async-iterable/create-async-iteratable'; +import { createAsyncIterable } from '../async-iterable/create-async-iterable'; import { NormalizedExecutorOptions } from '../schema'; const TYPESCRIPT_FOUND_N_ERRORS_WATCHING_FOR_FILE_CHANGES = 6194; diff --git a/packages/react/generators.json b/packages/react/generators.json index 54627227f8..7232910d7f 100644 --- a/packages/react/generators.json +++ b/packages/react/generators.json @@ -95,6 +95,13 @@ "schema": "./src/generators/setup-tailwind/schema.json", "description": "Set up Tailwind configuration for a project.", "hidden": false + }, + + "setup-ssr": { + "factory": "./src/generators/setup-ssr/setup-ssr#setupSsrSchematic", + "schema": "./src/generators/setup-ssr/schema.json", + "description": "Set up SSR configuration for a project.", + "hidden": false } }, "generators": { @@ -204,6 +211,13 @@ "schema": "./src/generators/setup-tailwind/schema.json", "description": "Set up Tailwind configuration for a project.", "hidden": false + }, + + "setup-ssr": { + "factory": "./src/generators/setup-ssr/setup-ssr#setupSsrGenerator", + "schema": "./src/generators/setup-ssr/schema.json", + "description": "Set up SSR configuration for a project.", + "hidden": false } } } diff --git a/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts b/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts index 0b0bdb35fa..39ca116422 100644 --- a/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts +++ b/packages/react/src/executors/module-federation-dev-server/module-federation-dev-server.impl.ts @@ -3,10 +3,8 @@ import devServerExecutor from '@nrwl/webpack/src/executors/dev-server/dev-server import { WebDevServerOptions } from '@nrwl/webpack/src/executors/dev-server/schema'; import { join } from 'path'; import * as chalk from 'chalk'; -import { - combineAsyncIterators, - tapAsyncIterator, -} from '../../utils/async-iterator'; +import { combineAsyncIterableIterators } from '@nrwl/js/src/utils/async-iterable/combine-async-iteratable-iterators'; +import { tapAsyncIterator } from '@nrwl/js/src/utils/async-iterable/tap-async-iteratable'; type ModuleFederationDevServerOptions = WebDevServerOptions & { devRemotes?: string | string[]; @@ -50,7 +48,7 @@ export default async function* moduleFederationDevServer( for (const app of knownRemotes) { const [appName] = Array.isArray(app) ? app : [app]; const isDev = devServeApps.includes(appName); - iter = combineAsyncIterators( + iter = combineAsyncIterableIterators( iter, await runExecutor( { diff --git a/packages/react/src/generators/setup-ssr/files/server.ts__tmpl__ b/packages/react/src/generators/setup-ssr/files/server.ts__tmpl__ new file mode 100644 index 0000000000..51bb68c042 --- /dev/null +++ b/packages/react/src/generators/setup-ssr/files/server.ts__tmpl__ @@ -0,0 +1,24 @@ +import express from 'express'; +import { handleRequest } from './src/main.server'; +import * as path from 'path'; + +const port = process.env['port'] || 4200; +const app = express(); + +const browserDist = path.join(process.cwd(), '<%= browserBuildOutputPath %>'); +const indexPath =path.join(browserDist, 'index.html'); + + app.get( + '*.*', + express.static(browserDist, { + maxAge: '1y', + }) + ); + +app.use('*', handleRequest(indexPath)); + +const server = app.listen(port, () => { + // Server has started +}); + +server.on('error', console.error); diff --git a/packages/react/src/generators/setup-ssr/files/src/main.server.tsx__tmpl__ b/packages/react/src/generators/setup-ssr/files/src/main.server.tsx__tmpl__ new file mode 100644 index 0000000000..a371c8b7c2 --- /dev/null +++ b/packages/react/src/generators/setup-ssr/files/src/main.server.tsx__tmpl__ @@ -0,0 +1,47 @@ +import type { Request, Response } from 'express'; +import * as fs from 'fs'; +import * as ReactDOMServer from 'react-dom/server'; +import isbot from 'isbot' + +import App from './<%= appComponentImport %>'; + + +let indexHtml: null | string = null; + +export function handleRequest(indexPath: string) { + return function render(req: Request, res: Response) { + let didError = false; + + if (!indexHtml) { + indexHtml = fs.readFileSync(indexPath).toString(); + } + + const [htmlStart, htmlEnd] = indexHtml.split(`
`); + + // For bots (e.g. search engines), the content will not be streamed but render all at once. + // For users, content should be streamed to the user as they are ready. + const callbackName = isbot(req.headers['user-agent']) ? 'onAllReady' : 'onShellReady'; + + const stream = ReactDOMServer.renderToPipeableStream( + , + { + [callbackName]() { + res.statusCode = didError ? 500 : 200; + res.setHeader('Content-type', 'text/html; charset=utf-8'); + res.write(`${htmlStart}
`); + stream.pipe(res); + res.write(`
${htmlEnd}`); + }, + onShellError(error) { + console.error(error); + res.statusCode = 500; + res.send('

Server Error

'); + }, + onError(error) { + didError = true; + console.error(error); + } + } + ); + } +} diff --git a/packages/react/src/generators/setup-ssr/files/tsconfig.server.json__tmpl__ b/packages/react/src/generators/setup-ssr/files/tsconfig.server.json__tmpl__ new file mode 100644 index 0000000000..6a76a67360 --- /dev/null +++ b/packages/react/src/generators/setup-ssr/files/tsconfig.server.json__tmpl__ @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "outDir": "../../out-tsc/server", + "target": "es2019", + "types": [ + "node" + ] + }, + "include": [ + "src/main.server.tsx", + "server.ts", + ] +} \ No newline at end of file diff --git a/packages/react/src/generators/setup-ssr/schema.d.ts b/packages/react/src/generators/setup-ssr/schema.d.ts new file mode 100644 index 0000000000..50b3735c93 --- /dev/null +++ b/packages/react/src/generators/setup-ssr/schema.d.ts @@ -0,0 +1,6 @@ +export interface Schema { + project: string; + appComponentImportPath: string; + serverPort?: number; + skipFormat?: boolean; +} diff --git a/packages/react/src/generators/setup-ssr/schema.json b/packages/react/src/generators/setup-ssr/schema.json new file mode 100644 index 0000000000..2d6470fbab --- /dev/null +++ b/packages/react/src/generators/setup-ssr/schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "GeneratorAngularUniversalSetup", + "cli": "nx", + "title": "Generate Angular Universal (SSR) setup for an Angular App", + "description": "Create the additional configuration required to enable SSR via Angular Universal for an Angular application.", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the application to add SSR support to.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What app would you like to add SSR support to?", + "x-dropdown": "projects" + }, + "appComponentImportPath": { + "type": "string", + "description": "The import path of the component, relative to project sourceRoot.", + "default": "app/app" + }, + "serverPort": { + "type": "number", + "default": 4200, + "description": "The port for the Express server." + }, + "skipFormat": { + "type": "boolean", + "description": "Skip formatting the workspace after the generator completes." + } + }, + "required": ["project"], + "additionalProperties": false +} diff --git a/packages/react/src/generators/setup-ssr/setup-ssr.ts b/packages/react/src/generators/setup-ssr/setup-ssr.ts new file mode 100644 index 0000000000..d98c033c4f --- /dev/null +++ b/packages/react/src/generators/setup-ssr/setup-ssr.ts @@ -0,0 +1,175 @@ +import { + addDependenciesToPackageJson, + convertNxGenerator, + formatFiles, + generateFiles, + joinPathFragments, + readProjectConfiguration, + readWorkspaceConfiguration, + Tree, + updateProjectConfiguration, + updateWorkspaceConfiguration, +} from '@nrwl/devkit'; + +import type { Schema } from './schema'; +import { + expressVersion, + isbotVersion, + typesExpressVersion, +} from '../../utils/versions'; + +export async function setupSsrGenerator(tree: Tree, options: Schema) { + const projectConfig = readProjectConfiguration(tree, options.project); + const projectRoot = projectConfig.root; + const appImportCandidates = [ + options.appComponentImportPath, + 'app', + 'App', + 'app/App', + 'App/App', + ]; + const appComponentImport = appImportCandidates.find( + (app) => + tree.exists(joinPathFragments(projectConfig.sourceRoot, `${app}.tsx`)) || + tree.exists(joinPathFragments(projectConfig.sourceRoot, `${app}.jsx`)) || + tree.exists(joinPathFragments(projectConfig.sourceRoot, `${app}.js`)) + ); + + if (!appComponentImport) { + throw new Error( + `Cannot find an import path for component. Try passing setting --appComponentImportPath option.` + ); + } + + if (!projectConfig.targets.build || !projectConfig.targets.serve) { + throw new Error( + `Project ${options.project} does not have build and serve targets` + ); + } + + if (projectConfig.targets.server) { + throw new Error(`Project ${options.project} already has a server target.`); + } + + const originalOutputPath = projectConfig.targets.build?.options?.outputPath; + + if (!originalOutputPath) { + throw new Error( + `Project ${options.project} does not contain a outputPath for the build target.` + ); + } + + projectConfig.targets.build.options.outputPath = joinPathFragments( + originalOutputPath, + 'browser' + ); + projectConfig.targets = { + ...projectConfig.targets, + server: { + executor: '@nrwl/webpack:webpack', + outputs: ['{options.outputPath}'], + defaultConfiguration: 'production', + options: { + target: 'node', + main: `${projectRoot}/server.ts`, + outputPath: joinPathFragments(originalOutputPath, 'server'), + tsConfig: `${projectRoot}/tsconfig.server.json`, + compiler: 'babel', + externalDependencies: 'all', + outputHashing: 'none', + webpackConfig: '@nrwl/react/plugins/webpack', + }, + configurations: { + development: { + optimization: false, + sourceMap: true, + }, + production: { + fileReplacements: [ + { + replace: `${projectRoot}/src/environments/environment.ts`, + with: `${projectRoot}/src/environments/environment.prod.ts`, + }, + ], + sourceMap: false, + }, + }, + }, + 'serve-browser': projectConfig.targets.serve, + 'serve-server': { + executor: '@nrwl/js:node', + defaultConfiguration: 'development', + options: { + buildTarget: `${options.project}:server:development`, + buildTargetOptions: { + watch: true, + }, + }, + configurations: { + development: {}, + production: { + buildTarget: `${options.project}:server:production`, + }, + }, + }, + serve: { + executor: '@nrwl/webpack:ssr-dev-server', + defaultConfiguration: 'development', + options: { + browserTarget: `${options.project}:build:development`, + serverTarget: `${options.project}:serve-server:development`, + port: options.serverPort, + browserTargetOptions: { + watch: true, + }, + }, + configurations: { + development: {}, + production: { + browserTarget: `${options.project}:build:production`, + serverTarget: `${options.project}:serve-server:production`, + }, + }, + }, + }; + + updateProjectConfiguration(tree, options.project, projectConfig); + + const workspace = readWorkspaceConfiguration(tree); + if ( + workspace.tasksRunnerOptions?.default && + !workspace.tasksRunnerOptions.default.options.cacheableOperations['server'] + ) { + workspace.tasksRunnerOptions.default.options.cacheableOperations = [ + ...workspace.tasksRunnerOptions.default.options.cacheableOperations, + 'server', + ]; + } + + generateFiles(tree, joinPathFragments(__dirname, 'files'), projectRoot, { + tmpl: '', + appComponentImport, + browserBuildOutputPath: projectConfig.targets.build.options.outputPath, + }); + + updateWorkspaceConfiguration(tree, workspace); + + const installTask = addDependenciesToPackageJson( + tree, + { + express: expressVersion, + isbot: isbotVersion, + }, + { + '@types/express': typesExpressVersion, + } + ); + + await formatFiles(tree); + + return installTask; +} + +export default setupSsrGenerator; + +export const setupSsrSchematic = convertNxGenerator(setupSsrGenerator); diff --git a/packages/react/src/utils/async-iterator.spec.ts b/packages/react/src/utils/async-iterator.spec.ts deleted file mode 100644 index 7ba53d2d7f..0000000000 --- a/packages/react/src/utils/async-iterator.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - mapAsyncIterator, - combineAsyncIterators, - tapAsyncIterator, -} from './async-iterator'; - -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe('combineAsyncIterators', () => { - it('should merge iterators', async () => { - async function* a() { - await delay(20); - yield 'a'; - } - - async function* b() { - await delay(0); - yield 'b'; - } - - const c = combineAsyncIterators(a(), b()); - const results = []; - - for await (const x of c) { - results.push(x); - } - - expect(results).toEqual(['b', 'a']); - }); - - it('should throw when one iterator throws', async () => { - async function* a() { - await delay(20); - yield 'a'; - } - - async function* b() { - throw new Error('threw in b'); - } - - const c = combineAsyncIterators(a(), b()); - - async function* d() { - yield* c; - } - - try { - for await (const x of d()) { - } - throw new Error('should not reach here'); - } catch (e) { - expect(e.message).toMatch(/threw in b/); - } - }); -}); - -describe('mapAsyncIterator', () => { - it('should map over values', async () => { - async function* f() { - yield 1; - yield 2; - yield 3; - } - - const c = mapAsyncIterator(f(), (x) => x * 2); - const results = []; - - for await (const x of c) { - results.push(x); - } - - expect(results).toEqual([2, 4, 6]); - }); -}); - -describe('tapAsyncIterator', () => { - it('should tap values', async () => { - async function* f() { - yield 1; - yield 2; - yield 3; - } - - const tapped = []; - const results = []; - - const c = tapAsyncIterator(f(), (x) => { - tapped.push(`tap: ${x}`); - }); - - for await (const x of c) { - results.push(x); - } - - expect(tapped).toEqual(['tap: 1', 'tap: 2', 'tap: 3']); - expect(results).toEqual([1, 2, 3]); - }); -}); diff --git a/packages/react/src/utils/versions.ts b/packages/react/src/utils/versions.ts index 68f328a551..bea00cb0bd 100755 --- a/packages/react/src/utils/versions.ts +++ b/packages/react/src/utils/versions.ts @@ -40,3 +40,7 @@ export const tsLibVersion = '^2.3.0'; export const postcssVersion = '8.4.16'; export const tailwindcssVersion = '3.1.8'; export const autoprefixerVersion = '10.4.12'; + +export const expressVersion = '^4.18.1'; +export const typesExpressVersion = '4.17.13'; +export const isbotVersion = '^3.6.5'; diff --git a/packages/webpack/executors.json b/packages/webpack/executors.json index 506ddcab74..41fc3cf300 100644 --- a/packages/webpack/executors.json +++ b/packages/webpack/executors.json @@ -9,6 +9,11 @@ "implementation": "./src/executors/dev-server/compat", "schema": "./src/executors/dev-server/schema.json", "description": "Serve a web application." + }, + "ssr-dev-server": { + "implementation": "./src/executors/ssr-dev-server/compat", + "schema": "./src/executors/ssr-dev-server/schema.json", + "description": "Serve a SSR application." } }, "executors": { @@ -21,6 +26,11 @@ "implementation": "./src/executors/dev-server/dev-server.impl", "schema": "./src/executors/dev-server/schema.json", "description": "Serve a web application." + }, + "ssr-dev-server": { + "implementation": "./src/executors/ssr-dev-server/ssr-dev-server.impl", + "schema": "./src/executors/ssr-dev-server/schema.json", + "description": "Serve a SSR application." } } } diff --git a/packages/webpack/src/executors/ssr-dev-server/compat.ts b/packages/webpack/src/executors/ssr-dev-server/compat.ts new file mode 100644 index 0000000000..1c387a219e --- /dev/null +++ b/packages/webpack/src/executors/ssr-dev-server/compat.ts @@ -0,0 +1,5 @@ +import { convertNxExecutor } from '@nrwl/devkit'; + +import ssrDevServerExecutor from './ssr-dev-server.impl'; + +export default convertNxExecutor(ssrDevServerExecutor); diff --git a/packages/webpack/src/executors/ssr-dev-server/lib/wait-until-server-is-listening.ts b/packages/webpack/src/executors/ssr-dev-server/lib/wait-until-server-is-listening.ts new file mode 100644 index 0000000000..35de9978cd --- /dev/null +++ b/packages/webpack/src/executors/ssr-dev-server/lib/wait-until-server-is-listening.ts @@ -0,0 +1,38 @@ +import * as net from 'net'; + +export function waitUntilServerIsListening(port: number): Promise { + const allowedErrorCodes = ['ECONNREFUSED', 'ECONNRESET']; + const maxAttempts = 25; + let attempts = 0; + const client = new net.Socket(); + const cleanup = () => { + client.removeAllListeners('connect'); + client.removeAllListeners('error'); + client.end(); + client.destroy(); + client.unref(); + }; + + return new Promise((resolve, reject) => { + const listen = () => { + client.once('connect', () => { + cleanup(); + resolve(); + }); + client.on('error', (err) => { + if ( + attempts > maxAttempts || + !allowedErrorCodes.includes(err['code']) + ) { + cleanup(); + reject(err); + } else { + attempts++; + setTimeout(listen, 100 * attempts); + } + }); + client.connect({ port, host: 'localhost' }); + }; + listen(); + }); +} diff --git a/packages/webpack/src/executors/ssr-dev-server/schema.d.ts b/packages/webpack/src/executors/ssr-dev-server/schema.d.ts new file mode 100644 index 0000000000..ac62f9eab2 --- /dev/null +++ b/packages/webpack/src/executors/ssr-dev-server/schema.d.ts @@ -0,0 +1,11 @@ +interface TargetOptions { + [key: string]: string | boolean | number | TargetOptions; +} + +export interface WebSsrDevServerOptions { + browserTarget: string; + serverTarget: string; + port: number; + browserTargetOptions: TargetOptions; + serverTargetOptions: TargetOptions; +} diff --git a/packages/webpack/src/executors/ssr-dev-server/schema.json b/packages/webpack/src/executors/ssr-dev-server/schema.json new file mode 100644 index 0000000000..fd21ad5ef1 --- /dev/null +++ b/packages/webpack/src/executors/ssr-dev-server/schema.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "outputCapture": "direct-nodejs", + "title": "Web SSR Dev Server", + "description": "Serve a SSR application.", + "cli": "nx", + "type": "object", + "properties": { + "browserTarget": { + "type": "string", + "description": "Target which builds the browser application." + }, + "serverTarget": { + "type": "string", + "description": "Target which builds the server application." + }, + "port": { + "type": "number", + "description": "The port to be set on `process.env.PORT` for use in the server.", + "default": 4200 + }, + "browserTargetOptions": { + "type": "object", + "description": "Additional options to pass into the browser build target.", + "default": {} + }, + "serverTargetOptions": { + "type": "object", + "description": "Additional options to pass into the server build target.", + "default": {} + } + }, + "required": ["browserTarget", "serverTarget"] +} diff --git a/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts b/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts new file mode 100644 index 0000000000..6d3a127bad --- /dev/null +++ b/packages/webpack/src/executors/ssr-dev-server/ssr-dev-server.impl.ts @@ -0,0 +1,72 @@ +import { + ExecutorContext, + parseTargetString, + readTargetOptions, + runExecutor, +} from '@nrwl/devkit'; +import * as chalk from 'chalk'; +import { combineAsyncIterableIterators } from '@nrwl/js/src/utils/async-iterable/combine-async-iteratable-iterators'; + +import { WebpackExecutorOptions } from '../webpack/schema'; +import { WebSsrDevServerOptions } from './schema'; +import { waitUntilServerIsListening } from './lib/wait-until-server-is-listening'; + +export async function* ssrDevServerExecutor( + options: WebSsrDevServerOptions, + context: ExecutorContext +) { + const browserTarget = parseTargetString(options.browserTarget); + const serverTarget = parseTargetString(options.serverTarget); + const browserOptions = readTargetOptions( + browserTarget, + context + ); + const serverOptions = readTargetOptions( + serverTarget, + context + ); + + const runBrowser = await runExecutor<{ + success: boolean; + baseUrl?: string; + }>( + browserTarget, + { ...browserOptions, ...options.browserTargetOptions }, + context + ); + const runServer = await runExecutor<{ + success: boolean; + baseUrl?: string; + }>( + serverTarget, + { ...serverOptions, ...options.serverTargetOptions }, + context + ); + let browserBuilt = false; + let nodeStarted = false; + const combined = combineAsyncIterableIterators(runBrowser, runServer); + + process.env['port'] = `${options.port}`; + + for await (const output of combined) { + if (!output.success) throw new Error('Could not build application'); + if (output.options.target === 'node') { + nodeStarted = true; + } else if (output.options?.target === 'web') { + browserBuilt = true; + } + + if (nodeStarted && browserBuilt) { + await waitUntilServerIsListening(options.port); + console.log( + `[ ${chalk.green('ready')} ] on http://localhost:${options.port}` + ); + yield { + ...output, + baseUrl: `http://localhost:${options.port}`, + }; + } + } +} + +export default ssrDevServerExecutor; diff --git a/packages/webpack/src/executors/webpack/webpack.impl.ts b/packages/webpack/src/executors/webpack/webpack.impl.ts index f41c38268e..454dc4fd44 100644 --- a/packages/webpack/src/executors/webpack/webpack.impl.ts +++ b/packages/webpack/src/executors/webpack/webpack.impl.ts @@ -91,11 +91,16 @@ async function getWebpackConfigs( } export type WebpackExecutorEvent = - | { success: false; outfile?: string } + | { + success: false; + outfile?: string; + options?: WebpackExecutorOptions; + } | { success: true; outfile: string; emittedFiles: EmittedFile[]; + options?: WebpackExecutorOptions; }; export async function* webpackExecutor( @@ -129,6 +134,7 @@ export async function* webpackExecutor( options.outputPath, options.outputFileName ), + options, }; } } @@ -204,6 +210,7 @@ export async function* webpackExecutor( options.outputFileName ), emittedFiles: [...emittedFiles1, ...emittedFiles2], + options, }; }) )