feat(react): add SSR support to React apps (#13234)

This commit is contained in:
Jack Hsu 2022-11-21 14:22:05 -05:00 committed by GitHub
parent f394608658
commit 23e4fc77c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 741 additions and 144 deletions

View File

@ -1391,6 +1391,49 @@
"implementation": "/packages/react/src/generators/setup-tailwind/setup-tailwind#setupTailwindGenerator.ts", "implementation": "/packages/react/src/generators/setup-tailwind/setup-tailwind#setupTailwindGenerator.ts",
"aliases": [], "aliases": [],
"path": "/packages/react/src/generators/setup-tailwind/schema.json" "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 <App/> 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": [ "executors": [

View File

@ -785,6 +785,49 @@
"aliases": [], "aliases": [],
"hidden": false, "hidden": false,
"path": "/packages/webpack/src/executors/dev-server/schema.json" "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"
} }
] ]
} }

View File

@ -300,7 +300,8 @@
"remote", "remote",
"cypress-component-configuration", "cypress-component-configuration",
"component-test", "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.", "description": "The Nx Plugin for Webpack contains executors and generators that support building applications using Webpack.",
"path": "generated/packages/webpack.json", "path": "generated/packages/webpack.json",
"schemas": { "schemas": {
"executors": ["webpack", "dev-server"], "executors": ["webpack", "dev-server", "ssr-dev-server"],
"generators": ["init", "webpack-project"] "generators": ["init", "webpack-project"]
} }
}, },

View File

@ -23,7 +23,7 @@ describe('React Applications', () => {
afterEach(() => cleanupProject()); 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 appName = uniq('app');
const libName = uniq('lib'); const libName = uniq('lib');
const libWithNoComponents = uniq('lib'); const libWithNoComponents = uniq('lib');
@ -60,6 +60,18 @@ describe('React Applications', () => {
checkLinter: true, checkLinter: true,
checkE2E: 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); }, 500000);
it('should generate app with legacy-ie support', async () => { it('should generate app with legacy-ie support', async () => {
@ -86,7 +98,7 @@ describe('React Applications', () => {
checkFilesExist(...filesToCheck); checkFilesExist(...filesToCheck);
expect(readFile(`dist/apps/${appName}/index.html`)).toContain( expect(readFile(`dist/apps/${appName}/index.html`)).toContain(
`<script src="main.js" type="module"></script><script src="main.es5.js" nomodule defer></script>` '<script src="main.js" type="module"></script><script src="main.es5.js" nomodule defer></script>'
); );
}, 250_000); }, 250_000);
@ -149,7 +161,7 @@ describe('React Applications', () => {
if (opts.checkStyles) { if (opts.checkStyles) {
expect(readFile(`dist/apps/${appName}/index.html`)).toContain( expect(readFile(`dist/apps/${appName}/index.html`)).toContain(
`<link rel="stylesheet" href="styles.css">` '<link rel="stylesheet" href="styles.css">'
); );
} }

View File

@ -15,7 +15,7 @@ import { normalizeOptions } from './lib/normalize';
import { EsBuildExecutorOptions } from './schema'; import { EsBuildExecutorOptions } from './schema';
import { removeSync, writeJsonSync } from 'fs-extra'; 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 { buildEsbuildOptions } from './lib/build-esbuild-options';
import { getExtraDependencies } from './lib/get-extra-dependencies'; import { getExtraDependencies } from './lib/get-extra-dependencies';
import { DependentBuildableProjectNode } from '@nrwl/workspace/src/utilities/buildable-libs-utils'; import { DependentBuildableProjectNode } from '@nrwl/workspace/src/utilities/buildable-libs-utils';

View File

@ -1,4 +1,4 @@
export async function* combineAsyncIterators( export async function* combineAsyncIterableIterators(
...iterators: { 0: AsyncIterableIterator<any> } & AsyncIterableIterator<any>[] ...iterators: { 0: AsyncIterableIterator<any> } & AsyncIterableIterator<any>[]
) { ) {
let [options] = iterators; let [options] = iterators;
@ -48,31 +48,3 @@ function getNextAsyncIteratorFactory(options) {
} }
}; };
} }
export async function* mapAsyncIterator<T = any, I = any, O = any>(
data: AsyncIterableIterator<T>,
transform: (input: I, index?: number, data?: AsyncIterableIterator<T>) => 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<T = any, I = any, O = any>(
data: AsyncIterableIterator<T>,
fn: (input: I) => void
) {
return yield* mapAsyncIterator(data, (x) => {
fn(x);
return x;
});
}

View File

@ -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/);
}
});
});

View File

@ -1,4 +1,4 @@
import { createAsyncIterable } from './create-async-iteratable'; import { createAsyncIterable } from './create-async-iterable';
describe(createAsyncIterable.name, () => { describe(createAsyncIterable.name, () => {
test('simple callback', async () => { test('simple callback', async () => {

View File

@ -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]);
});
});

View File

@ -0,0 +1,22 @@
export async function* mapAsyncIterable<T = any, I = any, O = any>(
data: AsyncIterable<T> | AsyncIterableIterator<T>,
transform: (
input: I,
index?: number,
data?: AsyncIterable<T> | AsyncIterableIterator<T>
) => 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();
}

View File

@ -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]);
});
});

View File

@ -0,0 +1,11 @@
import { mapAsyncIterable } from './map-async-iteratable';
export async function* tapAsyncIterator<T = any, I = any, O = any>(
data: AsyncIterable<T> | AsyncIterableIterator<T>,
fn: (input: I) => void
) {
return yield* mapAsyncIterable(data, (x) => {
fn(x);
return x;
});
}

View File

@ -1,7 +1,7 @@
import { cacheDir, ExecutorContext, logger } from '@nrwl/devkit'; import { cacheDir, ExecutorContext, logger } from '@nrwl/devkit';
import { exec, execSync } from 'child_process'; import { exec, execSync } from 'child_process';
import { removeSync } from 'fs-extra'; 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 { NormalizedSwcExecutorOptions, SwcCliOptions } from '../schema';
import { printDiagnostics } from '../typescript/print-diagnostics'; import { printDiagnostics } from '../typescript/print-diagnostics';
import { runTypeCheck, TypeCheckOptions } from '../typescript/run-type-check'; import { runTypeCheck, TypeCheckOptions } from '../typescript/run-type-check';

View File

@ -4,7 +4,7 @@ import {
TypeScriptCompilationOptions, TypeScriptCompilationOptions,
} from '@nrwl/workspace/src/utilities/typescript/compilation'; } from '@nrwl/workspace/src/utilities/typescript/compilation';
import type { Diagnostic } from 'typescript'; 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'; import { NormalizedExecutorOptions } from '../schema';
const TYPESCRIPT_FOUND_N_ERRORS_WATCHING_FOR_FILE_CHANGES = 6194; const TYPESCRIPT_FOUND_N_ERRORS_WATCHING_FOR_FILE_CHANGES = 6194;

View File

@ -95,6 +95,13 @@
"schema": "./src/generators/setup-tailwind/schema.json", "schema": "./src/generators/setup-tailwind/schema.json",
"description": "Set up Tailwind configuration for a project.", "description": "Set up Tailwind configuration for a project.",
"hidden": false "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": { "generators": {
@ -204,6 +211,13 @@
"schema": "./src/generators/setup-tailwind/schema.json", "schema": "./src/generators/setup-tailwind/schema.json",
"description": "Set up Tailwind configuration for a project.", "description": "Set up Tailwind configuration for a project.",
"hidden": false "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
} }
} }
} }

View File

@ -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 { WebDevServerOptions } from '@nrwl/webpack/src/executors/dev-server/schema';
import { join } from 'path'; import { join } from 'path';
import * as chalk from 'chalk'; import * as chalk from 'chalk';
import { import { combineAsyncIterableIterators } from '@nrwl/js/src/utils/async-iterable/combine-async-iteratable-iterators';
combineAsyncIterators, import { tapAsyncIterator } from '@nrwl/js/src/utils/async-iterable/tap-async-iteratable';
tapAsyncIterator,
} from '../../utils/async-iterator';
type ModuleFederationDevServerOptions = WebDevServerOptions & { type ModuleFederationDevServerOptions = WebDevServerOptions & {
devRemotes?: string | string[]; devRemotes?: string | string[];
@ -50,7 +48,7 @@ export default async function* moduleFederationDevServer(
for (const app of knownRemotes) { for (const app of knownRemotes) {
const [appName] = Array.isArray(app) ? app : [app]; const [appName] = Array.isArray(app) ? app : [app];
const isDev = devServeApps.includes(appName); const isDev = devServeApps.includes(appName);
iter = combineAsyncIterators( iter = combineAsyncIterableIterators(
iter, iter,
await runExecutor( await runExecutor(
{ {

View File

@ -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);

View File

@ -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(`<div id="root"></div>`);
// 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(
<App />,
{
[callbackName]() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html; charset=utf-8');
res.write(`${htmlStart}<div id="root">`);
stream.pipe(res);
res.write(`</div>${htmlEnd}`);
},
onShellError(error) {
console.error(error);
res.statusCode = 500;
res.send('<!doctype html><h1>Server Error</h1>');
},
onError(error) {
didError = true;
console.error(error);
}
}
);
}
}

View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"outDir": "../../out-tsc/server",
"target": "es2019",
"types": [
"node"
]
},
"include": [
"src/main.server.tsx",
"server.ts",
]
}

View File

@ -0,0 +1,6 @@
export interface Schema {
project: string;
appComponentImportPath: string;
serverPort?: number;
skipFormat?: boolean;
}

View File

@ -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 <App/> 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
}

View File

@ -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 <App/> 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);

View File

@ -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]);
});
});

View File

@ -40,3 +40,7 @@ export const tsLibVersion = '^2.3.0';
export const postcssVersion = '8.4.16'; export const postcssVersion = '8.4.16';
export const tailwindcssVersion = '3.1.8'; export const tailwindcssVersion = '3.1.8';
export const autoprefixerVersion = '10.4.12'; export const autoprefixerVersion = '10.4.12';
export const expressVersion = '^4.18.1';
export const typesExpressVersion = '4.17.13';
export const isbotVersion = '^3.6.5';

View File

@ -9,6 +9,11 @@
"implementation": "./src/executors/dev-server/compat", "implementation": "./src/executors/dev-server/compat",
"schema": "./src/executors/dev-server/schema.json", "schema": "./src/executors/dev-server/schema.json",
"description": "Serve a web application." "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": { "executors": {
@ -21,6 +26,11 @@
"implementation": "./src/executors/dev-server/dev-server.impl", "implementation": "./src/executors/dev-server/dev-server.impl",
"schema": "./src/executors/dev-server/schema.json", "schema": "./src/executors/dev-server/schema.json",
"description": "Serve a web application." "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."
} }
} }
} }

View File

@ -0,0 +1,5 @@
import { convertNxExecutor } from '@nrwl/devkit';
import ssrDevServerExecutor from './ssr-dev-server.impl';
export default convertNxExecutor(ssrDevServerExecutor);

View File

@ -0,0 +1,38 @@
import * as net from 'net';
export function waitUntilServerIsListening(port: number): Promise<void> {
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<void>((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();
});
}

View File

@ -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;
}

View File

@ -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"]
}

View File

@ -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<WebpackExecutorOptions>(
browserTarget,
context
);
const serverOptions = readTargetOptions<WebpackExecutorOptions>(
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;

View File

@ -91,11 +91,16 @@ async function getWebpackConfigs(
} }
export type WebpackExecutorEvent = export type WebpackExecutorEvent =
| { success: false; outfile?: string } | {
success: false;
outfile?: string;
options?: WebpackExecutorOptions;
}
| { | {
success: true; success: true;
outfile: string; outfile: string;
emittedFiles: EmittedFile[]; emittedFiles: EmittedFile[];
options?: WebpackExecutorOptions;
}; };
export async function* webpackExecutor( export async function* webpackExecutor(
@ -129,6 +134,7 @@ export async function* webpackExecutor(
options.outputPath, options.outputPath,
options.outputFileName options.outputFileName
), ),
options,
}; };
} }
} }
@ -204,6 +210,7 @@ export async function* webpackExecutor(
options.outputFileName options.outputFileName
), ),
emittedFiles: [...emittedFiles1, ...emittedFiles2], emittedFiles: [...emittedFiles1, ...emittedFiles2],
options,
}; };
}) })
) )