Skip to content
Permalink
Browse files

Introduce AoT predictive prefetching (#94)

* feat: add guess aot and refactor initialize

* feat: add requestIdleCallback

* style: fix linting errors

* test: fix broken tests

* style: fix linting error

* feat: add prefetch-aot plugin

* fix: prefetch instruction generation

* feat: add logging

* fix: point to correct guess template

* fix: drop graph from aot template

* fix: properly map routes to chunks

* feat: add more logging

* fix: paths for the aot runtime

* refactor: move aot behavior to aot directory

* test: fix broken webpack config

* fix: generated prefetching instructions

* refactor: move the prefetch instructions in the end

* test: add angular spec files

* test: update specs

* Improve Angular example

* Add Angular e2e test

* Update packages/guess-webpack/src/aot/guess-aot.ts

Co-Authored-By: Ward Peeters <ward@coding-tech.com>

* Warn only on debug

* Prefetch initial chunks

* Add e2e assertion for prefetching

* Drop comment

* Update test fixture & drop comment

* Update formatting

* Update e2e test
  • Loading branch information...
mgechev committed May 30, 2019
1 parent 7da91de commit 63f13a284b6e7a1e6ad24d763798a25fb63de98e
Showing with 11,579 additions and 142 deletions.
  1. +1 −0 .travis.yml
  2. +29 −0 infra/e2e.ts
  3. +1 −0 package.json
  4. +13 −4 packages/guess-parser/src/angular/index.ts
  5. +2 −1 packages/guess-parser/test/angular.spec.ts
  6. +5 −0 packages/guess-webpack/src/aot/aot.tpl
  7. +62 −0 packages/guess-webpack/src/aot/guess-aot.ts
  8. +18 −1 packages/guess-webpack/src/declarations.ts
  9. +63 −31 packages/guess-webpack/src/guess-webpack.ts
  10. +264 −0 packages/guess-webpack/src/prefetch-aot-plugin.ts
  11. +43 −60 packages/guess-webpack/src/prefetch-plugin.ts
  12. +0 −5 packages/guess-webpack/src/runtime/guess.tpl
  13. +25 −24 packages/guess-webpack/src/runtime/guess.ts
  14. +11 −11 packages/guess-webpack/src/runtime/runtime.ts
  15. +58 −0 packages/guess-webpack/src/utils.ts
  16. +13 −0 packages/guess-webpack/test/fixtures/angular/.editorconfig
  17. +46 −0 packages/guess-webpack/test/fixtures/angular/.gitignore
  18. +27 −0 packages/guess-webpack/test/fixtures/angular/README.md
  19. +136 −0 packages/guess-webpack/test/fixtures/angular/angular.json
  20. +28 −0 packages/guess-webpack/test/fixtures/angular/e2e/protractor.conf.js
  21. +23 −0 packages/guess-webpack/test/fixtures/angular/e2e/src/app.e2e-spec.ts
  22. +11 −0 packages/guess-webpack/test/fixtures/angular/e2e/src/app.po.ts
  23. +13 −0 packages/guess-webpack/test/fixtures/angular/e2e/tsconfig.e2e.json
  24. +10,102 −0 packages/guess-webpack/test/fixtures/angular/package-lock.json
  25. +49 −0 packages/guess-webpack/test/fixtures/angular/package.json
  26. +9 −0 packages/guess-webpack/test/fixtures/angular/routes.json
  27. +25 −0 packages/guess-webpack/test/fixtures/angular/src/app/app-routing.module.ts
  28. 0 packages/guess-webpack/test/fixtures/angular/src/app/app.component.css
  29. +4 −0 packages/guess-webpack/test/fixtures/angular/src/app/app.component.html
  30. +27 −0 packages/guess-webpack/test/fixtures/angular/src/app/app.component.spec.ts
  31. +10 −0 packages/guess-webpack/test/fixtures/angular/src/app/app.component.ts
  32. +14 −0 packages/guess-webpack/test/fixtures/angular/src/app/app.module.ts
  33. +7 −0 packages/guess-webpack/test/fixtures/angular/src/app/bar/bar.component.ts
  34. +8 −0 packages/guess-webpack/test/fixtures/angular/src/app/bar/bar.module.ts
  35. +20 −0 packages/guess-webpack/test/fixtures/angular/src/app/foo/baz/baz-routing.module.ts
  36. +7 −0 packages/guess-webpack/test/fixtures/angular/src/app/foo/baz/baz.component.ts
  37. +10 −0 packages/guess-webpack/test/fixtures/angular/src/app/foo/baz/baz.module.ts
  38. +25 −0 packages/guess-webpack/test/fixtures/angular/src/app/foo/foo-routing.module.ts
  39. +7 −0 packages/guess-webpack/test/fixtures/angular/src/app/foo/foo.component.ts
  40. +10 −0 packages/guess-webpack/test/fixtures/angular/src/app/foo/foo.module.ts
  41. 0 packages/guess-webpack/test/fixtures/angular/src/assets/.gitkeep
  42. +11 −0 packages/guess-webpack/test/fixtures/angular/src/browserslist
  43. +3 −0 packages/guess-webpack/test/fixtures/angular/src/environments/environment.prod.ts
  44. +16 −0 packages/guess-webpack/test/fixtures/angular/src/environments/environment.ts
  45. BIN packages/guess-webpack/test/fixtures/angular/src/favicon.ico
  46. +14 −0 packages/guess-webpack/test/fixtures/angular/src/index.html
  47. +32 −0 packages/guess-webpack/test/fixtures/angular/src/karma.conf.js
  48. +12 −0 packages/guess-webpack/test/fixtures/angular/src/main.ts
  49. +63 −0 packages/guess-webpack/test/fixtures/angular/src/polyfills.ts
  50. +1 −0 packages/guess-webpack/test/fixtures/angular/src/styles.css
  51. +20 −0 packages/guess-webpack/test/fixtures/angular/src/test.ts
  52. +11 −0 packages/guess-webpack/test/fixtures/angular/src/tsconfig.app.json
  53. +18 −0 packages/guess-webpack/test/fixtures/angular/src/tsconfig.spec.json
  54. +17 −0 packages/guess-webpack/test/fixtures/angular/src/tslint.json
  55. +22 −0 packages/guess-webpack/test/fixtures/angular/tsconfig.json
  56. +75 −0 packages/guess-webpack/test/fixtures/angular/tslint.json
  57. +16 −0 packages/guess-webpack/test/fixtures/angular/webpack.extra.js
  58. +3 −1 packages/guess-webpack/test/fixtures/delegate/webpack.config.js
  59. +1 −0 packages/guess-webpack/test/fixtures/prefetch/webpack.config.js
  60. +3 −3 packages/guess-webpack/test/unit/runtime.spec.ts
  61. +15 −1 packages/guess-webpack/webpack.config.js
@@ -7,6 +7,7 @@ install: npm run bootstrap
script:
- npm run build
- npm run test:ci
- npm run e2e

git:
depth: 5
@@ -0,0 +1,29 @@
import { execSync } from 'child_process';
import { readFileSync } from 'fs';

const enterTest = 'cd packages/guess-webpack/test/fixtures/angular';
execSync(`${enterTest} && npm i`);
execSync(
`${enterTest} && ./node_modules/.bin/ng build --extra-webpack-config webpack.extra.js`
);

// Prefetching instruction for baz
const fooModule = readFileSync('packages/guess-webpack/test/fixtures/angular/dist/angular/foo-foo-module.js').toString();
if (fooModule.indexOf(`__GUESS__.p(['baz-baz-module.js',1]`) < 0) {
console.error('Cannot find prefetching instructions');
process.exit(1);
}

// No prefetching instructions
const bazModule = readFileSync('packages/guess-webpack/test/fixtures/angular/dist/angular/baz-baz-module.js').toString();
if (bazModule.indexOf('__GUESS__') >= 0) {
console.error('Found prefetching instructions in bundle with no neighbors');
process.exit(1);
}

// No runtime
const mainModule = readFileSync('packages/guess-webpack/test/fixtures/angular/dist/angular/vendor.js').toString();
if (mainModule.indexOf('__GUESS__.p(') < 0 && mainModule.indexOf('__GUESS__.p=') < 0) {
console.error('Unable to find runtime or initial prefetching instruction');
process.exit(1);
}
@@ -7,6 +7,7 @@
"bootstrap": "npm i && lerna bootstrap",
"build": "lerna run build",
"publish": "lerna publish",
"e2e": "ts-node infra/e2e.ts",
"test": "ts-node infra/test.ts --watch",
"pretest": "ts-node infra/pretest.ts",
"test:ci": "ts-node infra/pretest.ts && ts-node infra/test.ts",
@@ -310,9 +310,7 @@ export const parseRoutes = (tsconfig: string): RoutingModule[] => {
if (!route.parentModulePath || !route.lazy) {
continue;
}
if (route.lazy) {
moduleToRoute[route.modulePath] = route;
}
moduleToRoute[route.modulePath] = route;
parentToModule[route.parentModulePath] = route;
}

@@ -341,12 +339,23 @@ export const parseRoutes = (tsconfig: string): RoutingModule[] => {
);
}

const map: {[key: string]: RoutingModule} = {};
for (const route of routes) {
const path = newRoutePaths.get(route);
if (path) {
route.path = path;
}
route.path = '/' + route.path;
// Saves us from cases when:
// - 'foo' is lazy
// - '' is default route in the FooModule
// we don't want to have once:
// - '/foo' for the lazy route declaration
// - '/foo' for the route in the lazy module
if (!map[route.path] || !map[route.path].lazy) {
map[route.path] = route;
}
}
return routes;

return Object.keys(map).map(r => map[r]);
};
@@ -18,7 +18,7 @@ describe('Angular parser', () => {

it('should produce routes', () => {
const routes = parseRoutes('packages/guess-parser/test/fixtures/angular/src/tsconfig.app.json');
expect(routes).toBeInstanceOf(Array);
expect(routes instanceof Array).toBeTruthy();
const allRoutes = new Set(routes.map(r => r.path));
[...allRoutes].forEach(r => expect(fixtureRoutes).toContain(r));
expect(allRoutes.size).toEqual(fixtureRoutes.size);
@@ -28,6 +28,7 @@ describe('Angular parser', () => {
const routes = parseRoutes('packages/guess-parser/test/fixtures/angular/src/tsconfig.app.json');
const route = routes.find(r => r.path === '/foo');
expect(route!.modulePath.endsWith('foo.module.ts')).toBeTruthy();
expect(route!.lazy).toBeTruthy();
expect(route!.parentModulePath!.endsWith('app.module.ts')).toBeTruthy();
});
});
@@ -0,0 +1,5 @@
import { initialize } from './guess-aot';

(function(g, thresholds) {
initialize(g, thresholds);
})(typeof window === 'undefined' ? global : window, <%= THRESHOLDS %>);
@@ -0,0 +1,62 @@
import { PrefetchConfig } from '../declarations';

type ConnectionEffectiveType = '4g' | '3g' | '2g' | 'slow-2g';

const support = (feature: string) => {
if (typeof document === 'undefined') {
return false;
}
const fakeLink = document.createElement('link') as any;
try {
if (fakeLink.relList && typeof fakeLink.relList.supports === 'function') {
return fakeLink.relList.supports(feature);
}
} catch (err) {
return false;
}
};

const linkPrefetchStrategy = (url: string) => {
if (typeof document === 'undefined') {
return;
}
const link = document.createElement('link');
link.setAttribute('rel', 'prefetch');
link.setAttribute('href', url);
const parentElement =
document.head || document.getElementsByName('script')[0].parentNode;
parentElement.appendChild(link);
};

const supportedPrefetchStrategy = support('prefetch')
? linkPrefetchStrategy
: (url: string) => import(url);

const preFetched: { [key: string]: boolean } = {};

const prefetch = (basePath: string, url: string) => {
url = basePath + url;
if (preFetched[url]) {
return;
}
preFetched[url] = true;
supportedPrefetchStrategy(url);
};

const getConnection = (global: any): ConnectionEffectiveType => {
if (!global.navigator || !global.navigator || !global.navigator.connection) {
return '3g';
}
return global.navigator.connection.effectiveType || '3g';
};

export const initialize = (
g: any,
t: PrefetchConfig,
) => {
const idle = g.requestIdleCallback || ((cb: Function) => setTimeout(cb, 0));
g.__GUESS__ = {};
g.__GUESS__.p = (...p: [string, number][]) => {
idle(() => p.forEach(c => c[1] >= t[getConnection(g)] ? prefetch('', c[0]) : void 0))
};
};
@@ -61,5 +61,22 @@ export interface PrefetchNeighbor {
}

export interface PrefetchGraph {
[node: string]: PrefetchNeighbor[];
[route: string]: PrefetchNeighbor[];
}

export interface PrefetchAotNeighbor {
probability: number;
chunk: string;
}

export interface PrefetchAotGraph {
[route: string]: PrefetchAotNeighbor[];
}

export interface PrefetchAotPluginConfig {
debug?: boolean;
data: Graph;
basePath: string;
prefetchConfig?: PrefetchConfig;
routes: RoutingModule[];
}
@@ -1,6 +1,11 @@
import { RouteProvider, PrefetchConfig } from './declarations';
import { PrefetchPlugin } from './prefetch-plugin';
import { Graph, RoutingModule, Period, ProjectLayout } from '../../common/interfaces';
import { PrefetchAotPlugin } from './prefetch-aot-plugin';
import {
Graph,
RoutingModule,
Period,
} from '../../common/interfaces';
import { getReport } from './ga-provider';

export interface RuntimeConfig {
@@ -16,6 +21,7 @@ export interface GuessPluginConfig {
GA?: string;
jwt?: any;
period?: Period;
debug?: boolean;
reportProvider?: (...args: any[]) => Promise<Graph>;

/** @internal */
@@ -26,6 +32,18 @@ export interface GuessPluginConfig {
runtime?: RuntimeConfig;
}

const extractRoutes = (config: GuessPluginConfig): Promise<RoutingModule[]> => {
if (config.routeProvider === false || config.routeProvider === undefined) {
return Promise.resolve([]);
}
if (typeof config.routeProvider === 'function') {
return Promise.resolve(config.routeProvider());
}
throw new Error(
'The routeProvider should be either set to false or a function which returns the routes in the app.'
);
};

export class GuessPlugin {
constructor(private _config: GuessPluginConfig) {
if ((this._config.GA || this._config.jwt) && this._config.reportProvider) {
@@ -42,21 +60,21 @@ export class GuessPlugin {
}

apply(compiler: any) {
compiler.plugin('emit', (compilation: any, cb: any) => this._execute(compilation, cb));
compiler.plugin('emit', (compilation: any, cb: any) =>
this._execute(compilation, cb)
);
}

private _execute(compilation: any, cb: any) {
extractRoutes(this._config).then(routes => {
return this._getReport(routes).then(
data => {
return this._executePrefetchPlugin(data, routes, compilation, cb);
},
err => {
console.error(err);
cb();
throw err;
}
);
extractRoutes(this._config).then(async routes => {
try {
const data = await this._getReport(routes);
return this._executePrefetchPlugin(data, routes, compilation, cb);
} catch (err) {
console.error(err);
cb();
throw err;
}
});
}

@@ -74,24 +92,38 @@ export class GuessPlugin {
}
}

private _executePrefetchPlugin(data: Graph, routes: RoutingModule[], compilation: any, cb: any) {
private _executePrefetchPlugin(
data: Graph,
routes: RoutingModule[],
compilation: any,
cb: any
) {
const { runtime } = this._config;
new PrefetchPlugin({
data,
basePath: runtime ? (runtime.basePath === undefined ? '' : runtime.basePath) : '',
prefetchConfig: runtime ? runtime.prefetchConfig : undefined,
routes,
delegate: runtime ? !!runtime.delegate : true
}).execute(compilation, cb);
if (runtime && runtime.delegate) {
new PrefetchPlugin({
data,
debug: this._config.debug,
basePath: runtime
? runtime.basePath === undefined
? ''
: runtime.basePath
: '',
prefetchConfig: runtime ? runtime.prefetchConfig : undefined,
routes,
delegate: runtime ? !!runtime.delegate : true
}).execute(compilation, cb);
} else {
new PrefetchAotPlugin({
data,
debug: this._config.debug,
basePath: runtime
? runtime.basePath === undefined
? ''
: runtime.basePath
: '',
prefetchConfig: runtime ? runtime.prefetchConfig : undefined,
routes,
}).execute(compilation, cb);
}
}
}

const extractRoutes = (config: GuessPluginConfig): Promise<RoutingModule[]> => {
if (config.routeProvider === false || config.routeProvider === undefined) {
return Promise.resolve([]);
}
if (typeof config.routeProvider === 'function') {
return Promise.resolve(config.routeProvider());
}
throw new Error('The routeProvider should be either set to false or a function which returns the routes in the app.');
};

0 comments on commit 63f13a2

Please sign in to comment.
You can’t perform that action at this time.