feat(react): add SSR support to React apps (#13234)
This commit is contained in:
parent
f394608658
commit
23e4fc77c9
@ -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 <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": [
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
},
|
||||
|
||||
@ -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(
|
||||
`<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);
|
||||
|
||||
@ -149,7 +161,7 @@ describe('React Applications', () => {
|
||||
|
||||
if (opts.checkStyles) {
|
||||
expect(readFile(`dist/apps/${appName}/index.html`)).toContain(
|
||||
`<link rel="stylesheet" href="styles.css">`
|
||||
'<link rel="stylesheet" href="styles.css">'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export async function* combineAsyncIterators(
|
||||
export async function* combineAsyncIterableIterators(
|
||||
...iterators: { 0: AsyncIterableIterator<any> } & AsyncIterableIterator<any>[]
|
||||
) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { createAsyncIterable } from './create-async-iteratable';
|
||||
import { createAsyncIterable } from './create-async-iterable';
|
||||
|
||||
describe(createAsyncIterable.name, () => {
|
||||
test('simple callback', async () => {
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
22
packages/js/src/utils/async-iterable/map-async-iteratable.ts
Normal file
22
packages/js/src/utils/async-iterable/map-async-iteratable.ts
Normal 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();
|
||||
}
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
11
packages/js/src/utils/async-iterable/tap-async-iteratable.ts
Normal file
11
packages/js/src/utils/async-iterable/tap-async-iteratable.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/server",
|
||||
"target": "es2019",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/main.server.tsx",
|
||||
"server.ts",
|
||||
]
|
||||
}
|
||||
6
packages/react/src/generators/setup-ssr/schema.d.ts
vendored
Normal file
6
packages/react/src/generators/setup-ssr/schema.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Schema {
|
||||
project: string;
|
||||
appComponentImportPath: string;
|
||||
serverPort?: number;
|
||||
skipFormat?: boolean;
|
||||
}
|
||||
36
packages/react/src/generators/setup-ssr/schema.json
Normal file
36
packages/react/src/generators/setup-ssr/schema.json
Normal 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
|
||||
}
|
||||
175
packages/react/src/generators/setup-ssr/setup-ssr.ts
Normal file
175
packages/react/src/generators/setup-ssr/setup-ssr.ts
Normal 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);
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
packages/webpack/src/executors/ssr-dev-server/compat.ts
Normal file
5
packages/webpack/src/executors/ssr-dev-server/compat.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { convertNxExecutor } from '@nrwl/devkit';
|
||||
|
||||
import ssrDevServerExecutor from './ssr-dev-server.impl';
|
||||
|
||||
export default convertNxExecutor(ssrDevServerExecutor);
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
11
packages/webpack/src/executors/ssr-dev-server/schema.d.ts
vendored
Normal file
11
packages/webpack/src/executors/ssr-dev-server/schema.d.ts
vendored
Normal 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;
|
||||
}
|
||||
34
packages/webpack/src/executors/ssr-dev-server/schema.json
Normal file
34
packages/webpack/src/executors/ssr-dev-server/schema.json
Normal 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"]
|
||||
}
|
||||
@ -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;
|
||||
@ -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,
|
||||
};
|
||||
})
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user