diff --git a/modules/common/package.json b/modules/common/package.json index cea06eaa8..09f5e58fc 100644 --- a/modules/common/package.json +++ b/modules/common/package.json @@ -9,7 +9,9 @@ ], "peerDependencies": { "@angular/common": "NG_VERSION", - "@angular/core": "NG_VERSION" + "@angular/core": "NG_VERSION", + "@angular/router": "NG_VERSION", + "@nguniversal/module-map-ngfactory-loader": "0.0.0-PLACEHOLDER" }, "ng-update": { "packageGroup": "NG_UPDATE_PACKAGE_GROUP" diff --git a/modules/common/public_api.ts b/modules/common/public_api.ts index 09b8c3b81..2790a4d07 100644 --- a/modules/common/public_api.ts +++ b/modules/common/public_api.ts @@ -7,4 +7,5 @@ */ export { TransferHttpCacheModule } from './src/transfer_http'; export { StateTransferInitializerModule } from './src/state-transfer-initializer/module'; +export { lsRoutes } from './src/ls-routes'; export * from './private_api'; diff --git a/modules/common/spec/ls-routes.spec.ts b/modules/common/spec/ls-routes.spec.ts new file mode 100644 index 000000000..7befcf563 --- /dev/null +++ b/modules/common/spec/ls-routes.spec.ts @@ -0,0 +1,154 @@ +import { lsRoutes } from '../src/ls-routes'; +import { enableProdMode, NgModule, Component, CompilerFactory, Compiler } from '@angular/core'; +import { async } from '@angular/core/testing'; +import { ResourceLoader } from '@angular/compiler'; +import { RouterModule, Route } from '@angular/router'; +import { BrowserModule } from '@angular/platform-browser'; +import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader'; +import { ServerModule, platformDynamicServer } from '@angular/platform-server'; +import { ɵFileLoader as FileLoader } from '@nguniversal/common'; + +@Component({selector: 'lazy', template: 'lazy'}) +export class LazyComponent {} + +@NgModule({ + imports: [RouterModule.forChild([ + {path: 'lazy-a', component: LazyComponent} + ])], + declarations: [ LazyComponent ] +}) +export class LazyModule {} + +function assignComponent(route: Route, comp: any) { + route.component = comp; + if (route.children) { + route.children = route.children.map(r => assignComponent(r, comp)); + } + return route; +} + + +async function createFactoryAndGetRoutes(routeConfig: Route[], + compiler: Compiler, moduleMap: {[key: string]: any} = {} ) { + + @Component({ selector: 'a', template: 'a' }) + class MockComponent { } + + @NgModule({ + imports: [ + BrowserModule, + RouterModule.forRoot(routeConfig.map(r => assignComponent(r, MockComponent))), + ], + declarations: [MockComponent] + }) + class MockModule { } + @NgModule({ + imports: [ + ServerModule, + MockModule, + ModuleMapLoaderModule + ] + }) + class MockServerModule {} + const factory = await compiler.compileModuleAsync(MockServerModule); + + return lsRoutes('flatPaths', factory, moduleMap); +} + +describe('ls-routes', () => { + let compiler: Compiler; + beforeAll(() => { + enableProdMode(); + const compilerFactory = platformDynamicServer().injector.get(CompilerFactory); + compiler = compilerFactory.createCompiler([ + { + providers: [ + { provide: ResourceLoader, useClass: FileLoader, deps: [] } + ] + } + ]); + }); + + it('should resolve a single path', async(() => { + createFactoryAndGetRoutes([ + { path: 'a' } + ], compiler).then(routes => { + expect(routes).toContain('/a'); + }); + })); + it('should resolve a multiple paths', async(() => { + createFactoryAndGetRoutes([ + { path: 'a' }, + { path: 'b' }, + { path: 'c' }, + ], compiler).then(routes => { + expect(routes).toContain('/a'); + expect(routes).toContain('/b'); + expect(routes).toContain('/c'); + }); + })); + it('should resolve nested paths', async(() => { + createFactoryAndGetRoutes([ + { + path: 'a', + children: [ + { path: 'a-a' }, + { path: 'a-b' } + ] + }, + ], compiler).then(routes => { + expect(routes).toContain('/a/a-a'); + expect(routes).toContain('/a/a-b'); + }); + })); + it('should resolve a string loaded loadChildren', async(() => { + const moduleMap = { './ls-routes.spec.ts#LazyModule': LazyModule }; + createFactoryAndGetRoutes([ + { + path: 'a', + loadChildren: './ls-routes.spec.ts#LazyModule' + } + ], compiler, moduleMap).then(routes => { + expect(routes).toContain('/a/lazy-a'); + }); + })); + it('should resolve a function loaded loadChildren', async(() => { + createFactoryAndGetRoutes([ + { + path: 'a', + loadChildren: () => compiler.compileModuleSync(LazyModule) + } + ], compiler).then(routes => { + expect(routes).toContain('/a/lazy-a'); + }); + })); + it('should resolve a function loaded promise loadChildren', async(() => { + createFactoryAndGetRoutes([ + { + path: 'a', + loadChildren: () => compiler.compileModuleAsync(LazyModule) as any + } + ], compiler).then(routes => { + expect(routes).toContain('/a/lazy-a'); + }); + })); + it('should correctly merge nested routes with empty string ', async(() => { + createFactoryAndGetRoutes([ + { + path: '', + children: [ + { + path: '', + children: [ + { path: '' }, + { path: 'level3'} + ] + } + ] + } + ], compiler).then(routes => { + expect(routes).toContain('/'); + expect(routes).toContain('/level3'); + }); + })); +}); diff --git a/modules/common/src/ls-routes/index.ts b/modules/common/src/ls-routes/index.ts new file mode 100644 index 000000000..5faeb9c95 --- /dev/null +++ b/modules/common/src/ls-routes/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +export * from './ls-routes'; diff --git a/modules/common/src/ls-routes/ls-routes.ts b/modules/common/src/ls-routes/ls-routes.ts new file mode 100644 index 000000000..bafc44237 --- /dev/null +++ b/modules/common/src/ls-routes/ls-routes.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import 'zone.js/dist/zone-node'; +import { ReflectiveInjector, NgModuleFactoryLoader, + NgModuleFactory, Injector, NgZone } from '@angular/core'; +import { platformServer } from '@angular/platform-server'; +import { ROUTES, Route } from '@angular/router'; +import { Observable } from 'rxjs'; +import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader'; + +let loader: NgModuleFactoryLoader; + +export function lsRoutes( + returnType: 'flatPaths' | 'nestedPaths' | 'full', + serverFactory: NgModuleFactory, + lazyModuleMap?: any +) { + const ngZone = new NgZone({ enableLongStackTrace: false }); + const rootInjector = ReflectiveInjector.resolveAndCreate( + [ + { provide: NgZone, useValue: ngZone }, + provideModuleMap(lazyModuleMap) + ], + platformServer().injector + ); + const moduleRef = serverFactory.create(rootInjector); + loader = moduleRef.injector.get(NgModuleFactoryLoader); + return Promise.all(createModule(serverFactory, rootInjector)) + .then(routes => { + if (returnType === 'full') { + return routes; + } + if (returnType === 'nestedPaths') { + return flattenRouteToPath(routes); + } + if (returnType === 'flatPaths') { + return flattenArray(flattenRouteToPath(routes)); + } + throw new Error('you must provide a supported returnType'); + }); +} + +function flattenArray(array: T[] | T): V[] { + return !Array.isArray(array) ? array : [].concat.apply([], array.map(r => flattenArray(r))); +} + +function flattenRouteToPath(routes: Route[]): (string[] | string)[] { + return routes.map(route => { + if (!route.children) { + return route.path ? '/' + route.path : '/'; + } else { + // extra flatten here for nested routes + return flattenArray(flattenRouteToPath(route.children)) + .map(childRoute => (!route.path ? '' : '/' + route.path) + childRoute); + } + }); +} + +function coerceIntoPromise(mightBePromise: Observable | Promise | T): Promise { + if (mightBePromise instanceof Observable) { + return mightBePromise.toPromise(); + } + return Promise.resolve(mightBePromise); +} + +function extractRoute(route: Route, injector: Injector): Promise { + if (route.loadChildren) { + return resolveLazyChildren(route, injector); + } + if (route.children) { + return Promise.all(route.children.map(r => extractRoute(r, injector))) + .then(routes => { + route.children = routes; + return route; + }); + } + return Promise.resolve(route); +} + +function resolveLazyChildren(route: Route, injector: Injector): Promise { + let nextFactory: Promise>; + if (typeof route.loadChildren === 'function') { + nextFactory = coerceIntoPromise>( + route.loadChildren() as NgModuleFactory | Promise> + ); + } else { + nextFactory = loader.load(route.loadChildren as string); + } + return nextFactory + .then(factory => Promise.all(createModule(factory, injector))) + .then(children => { + route.children = children; + delete route.loadChildren; + return route; + }); +} + +function createModule(factory: NgModuleFactory, parentInjector: Injector): Promise[] { + + const moduleRef = factory.create(parentInjector); + const routes = moduleRef.injector.get(ROUTES); + + return flattenArray(routes) + .map(route => { + if (!route.loadChildren) { + // no lazy loaded paths so we can return the routes directly + return extractRoute(route, parentInjector); + } else { + return resolveLazyChildren(route, moduleRef.injector); + } + }); +} diff --git a/package.json b/package.json index e25e10eff..f7683170b 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@angular/common": "^6.0.0", "@angular/compiler": "^6.0.0", "@angular/compiler-cli": "^6.0.0", + "@angular/router": "^6.0.0", "@angular/core": "^6.0.0", "@angular/http": "^6.0.0", "@angular/platform-browser": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index ffc0962c4..2d5ff710b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -71,6 +71,12 @@ tslib "^1.9.0" xhr2 "^0.1.4" +"@angular/router@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-6.0.2.tgz#a7c925751accede6c5b4a8369170a53f3a48b940" + dependencies: + tslib "^1.9.0" + "@bazel/ibazel@^0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@bazel/ibazel/-/ibazel-0.3.1.tgz#5f02f208f138e581bbdb1534d5c013d7a0ac9799"