592 lines
16 KiB
JavaScript
592 lines
16 KiB
JavaScript
"use strict";
|
|
|
|
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
|
|
|
|
exports.__esModule = true;
|
|
exports.getStaticQueryResults = getStaticQueryResults;
|
|
exports.default = exports.publicLoader = exports.setLoader = exports.ProdLoader = exports.BaseLoader = exports.PageResourceStatus = void 0;
|
|
|
|
var _prefetch = _interopRequireDefault(require("./prefetch"));
|
|
|
|
var _emitter = _interopRequireDefault(require("./emitter"));
|
|
|
|
var _findPath = require("./find-path");
|
|
|
|
/**
|
|
* Available resource loading statuses
|
|
*/
|
|
const PageResourceStatus = {
|
|
/**
|
|
* At least one of critical resources failed to load
|
|
*/
|
|
Error: `error`,
|
|
|
|
/**
|
|
* Resources loaded successfully
|
|
*/
|
|
Success: `success`
|
|
};
|
|
exports.PageResourceStatus = PageResourceStatus;
|
|
|
|
const preferDefault = m => m && m.default || m;
|
|
|
|
const stripSurroundingSlashes = s => {
|
|
s = s[0] === `/` ? s.slice(1) : s;
|
|
s = s.endsWith(`/`) ? s.slice(0, -1) : s;
|
|
return s;
|
|
};
|
|
|
|
const createPageDataUrl = path => {
|
|
const fixedPath = path === `/` ? `index` : stripSurroundingSlashes(path);
|
|
return `${__PATH_PREFIX__}/page-data/${fixedPath}/page-data.json`;
|
|
};
|
|
|
|
function doFetch(url, method = `GET`) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = new XMLHttpRequest();
|
|
req.open(method, url, true);
|
|
|
|
req.onreadystatechange = () => {
|
|
if (req.readyState == 4) {
|
|
resolve(req);
|
|
}
|
|
};
|
|
|
|
req.send(null);
|
|
});
|
|
}
|
|
|
|
const doesConnectionSupportPrefetch = () => {
|
|
if (`connection` in navigator && typeof navigator.connection !== `undefined`) {
|
|
if ((navigator.connection.effectiveType || ``).includes(`2g`)) {
|
|
return false;
|
|
}
|
|
|
|
if (navigator.connection.saveData) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const toPageResources = (pageData, component = null) => {
|
|
const page = {
|
|
componentChunkName: pageData.componentChunkName,
|
|
path: pageData.path,
|
|
webpackCompilationHash: pageData.webpackCompilationHash,
|
|
matchPath: pageData.matchPath,
|
|
staticQueryHashes: pageData.staticQueryHashes
|
|
};
|
|
return {
|
|
component,
|
|
json: pageData.result,
|
|
page
|
|
};
|
|
};
|
|
|
|
class BaseLoader {
|
|
constructor(loadComponent, matchPaths) {
|
|
this.inFlightNetworkRequests = new Map();
|
|
// Map of pagePath -> Page. Where Page is an object with: {
|
|
// status: PageResourceStatus.Success || PageResourceStatus.Error,
|
|
// payload: PageResources, // undefined if PageResourceStatus.Error
|
|
// }
|
|
// PageResources is {
|
|
// component,
|
|
// json: pageData.result,
|
|
// page: {
|
|
// componentChunkName,
|
|
// path,
|
|
// webpackCompilationHash,
|
|
// staticQueryHashes
|
|
// },
|
|
// staticQueryResults
|
|
// }
|
|
this.pageDb = new Map();
|
|
this.inFlightDb = new Map();
|
|
this.staticQueryDb = {};
|
|
this.pageDataDb = new Map();
|
|
this.prefetchTriggered = new Set();
|
|
this.prefetchCompleted = new Set();
|
|
this.loadComponent = loadComponent;
|
|
(0, _findPath.setMatchPaths)(matchPaths);
|
|
}
|
|
|
|
memoizedGet(url) {
|
|
let inFlightPromise = this.inFlightNetworkRequests.get(url);
|
|
|
|
if (!inFlightPromise) {
|
|
inFlightPromise = doFetch(url, `GET`);
|
|
this.inFlightNetworkRequests.set(url, inFlightPromise);
|
|
} // Prefer duplication with then + catch over .finally to prevent problems in ie11 + firefox
|
|
|
|
|
|
return inFlightPromise.then(response => {
|
|
this.inFlightNetworkRequests.delete(url);
|
|
return response;
|
|
}).catch(err => {
|
|
this.inFlightNetworkRequests.delete(url);
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
setApiRunner(apiRunner) {
|
|
this.apiRunner = apiRunner;
|
|
this.prefetchDisabled = apiRunner(`disableCorePrefetching`).some(a => a);
|
|
}
|
|
|
|
fetchPageDataJson(loadObj) {
|
|
const {
|
|
pagePath,
|
|
retries = 0
|
|
} = loadObj;
|
|
const url = createPageDataUrl(pagePath);
|
|
return this.memoizedGet(url).then(req => {
|
|
const {
|
|
status,
|
|
responseText
|
|
} = req; // Handle 200
|
|
|
|
if (status === 200) {
|
|
try {
|
|
const jsonPayload = JSON.parse(responseText);
|
|
|
|
if (jsonPayload.path === undefined) {
|
|
throw new Error(`not a valid pageData response`);
|
|
}
|
|
|
|
return Object.assign(loadObj, {
|
|
status: PageResourceStatus.Success,
|
|
payload: jsonPayload
|
|
});
|
|
} catch (err) {// continue regardless of error
|
|
}
|
|
} // Handle 404
|
|
|
|
|
|
if (status === 404 || status === 200) {
|
|
// If the request was for a 404 page and it doesn't exist, we're done
|
|
if (pagePath === `/404.html`) {
|
|
return Object.assign(loadObj, {
|
|
status: PageResourceStatus.Error
|
|
});
|
|
} // Need some code here to cache the 404 request. In case
|
|
// multiple loadPageDataJsons result in 404s
|
|
|
|
|
|
return this.fetchPageDataJson(Object.assign(loadObj, {
|
|
pagePath: `/404.html`,
|
|
notFound: true
|
|
}));
|
|
} // handle 500 response (Unrecoverable)
|
|
|
|
|
|
if (status === 500) {
|
|
return Object.assign(loadObj, {
|
|
status: PageResourceStatus.Error
|
|
});
|
|
} // Handle everything else, including status === 0, and 503s. Should retry
|
|
|
|
|
|
if (retries < 3) {
|
|
return this.fetchPageDataJson(Object.assign(loadObj, {
|
|
retries: retries + 1
|
|
}));
|
|
} // Retried 3 times already, result is an error.
|
|
|
|
|
|
return Object.assign(loadObj, {
|
|
status: PageResourceStatus.Error
|
|
});
|
|
});
|
|
}
|
|
|
|
loadPageDataJson(rawPath) {
|
|
const pagePath = (0, _findPath.findPath)(rawPath);
|
|
|
|
if (this.pageDataDb.has(pagePath)) {
|
|
const pageData = this.pageDataDb.get(pagePath);
|
|
|
|
if (process.env.BUILD_STAGE !== `develop` || !pageData.stale) {
|
|
return Promise.resolve(pageData);
|
|
}
|
|
}
|
|
|
|
return this.fetchPageDataJson({
|
|
pagePath
|
|
}).then(pageData => {
|
|
this.pageDataDb.set(pagePath, pageData);
|
|
return pageData;
|
|
});
|
|
}
|
|
|
|
findMatchPath(rawPath) {
|
|
return (0, _findPath.findMatchPath)(rawPath);
|
|
} // TODO check all uses of this and whether they use undefined for page resources not exist
|
|
|
|
|
|
loadPage(rawPath) {
|
|
const pagePath = (0, _findPath.findPath)(rawPath);
|
|
|
|
if (this.pageDb.has(pagePath)) {
|
|
const page = this.pageDb.get(pagePath);
|
|
|
|
if (process.env.BUILD_STAGE !== `develop` || !page.payload.stale) {
|
|
if (page.error) {
|
|
return {
|
|
error: page.error,
|
|
status: page.status
|
|
};
|
|
}
|
|
|
|
return Promise.resolve(page.payload);
|
|
}
|
|
}
|
|
|
|
if (this.inFlightDb.has(pagePath)) {
|
|
return this.inFlightDb.get(pagePath);
|
|
}
|
|
|
|
const inFlightPromise = Promise.all([this.loadAppData(), this.loadPageDataJson(pagePath)]).then(allData => {
|
|
const result = allData[1];
|
|
|
|
if (result.status === PageResourceStatus.Error) {
|
|
return {
|
|
status: PageResourceStatus.Error
|
|
};
|
|
}
|
|
|
|
let pageData = result.payload;
|
|
const {
|
|
componentChunkName,
|
|
staticQueryHashes = []
|
|
} = pageData;
|
|
const finalResult = {};
|
|
const componentChunkPromise = this.loadComponent(componentChunkName).then(component => {
|
|
finalResult.createdAt = new Date();
|
|
let pageResources;
|
|
|
|
if (!component || component instanceof Error) {
|
|
finalResult.status = PageResourceStatus.Error;
|
|
finalResult.error = component;
|
|
} else {
|
|
finalResult.status = PageResourceStatus.Success;
|
|
|
|
if (result.notFound === true) {
|
|
finalResult.notFound = true;
|
|
}
|
|
|
|
pageData = Object.assign(pageData, {
|
|
webpackCompilationHash: allData[0] ? allData[0].webpackCompilationHash : ``
|
|
});
|
|
pageResources = toPageResources(pageData, component);
|
|
} // undefined if final result is an error
|
|
|
|
|
|
return pageResources;
|
|
});
|
|
const staticQueryBatchPromise = Promise.all(staticQueryHashes.map(staticQueryHash => {
|
|
// Check for cache in case this static query result has already been loaded
|
|
if (this.staticQueryDb[staticQueryHash]) {
|
|
const jsonPayload = this.staticQueryDb[staticQueryHash];
|
|
return {
|
|
staticQueryHash,
|
|
jsonPayload
|
|
};
|
|
}
|
|
|
|
return this.memoizedGet(`${__PATH_PREFIX__}/page-data/sq/d/${staticQueryHash}.json`).then(req => {
|
|
const jsonPayload = JSON.parse(req.responseText);
|
|
return {
|
|
staticQueryHash,
|
|
jsonPayload
|
|
};
|
|
}).catch(() => {
|
|
throw new Error(`We couldn't load "${__PATH_PREFIX__}/page-data/sq/d/${staticQueryHash}.json"`);
|
|
});
|
|
})).then(staticQueryResults => {
|
|
const staticQueryResultsMap = {};
|
|
staticQueryResults.forEach(({
|
|
staticQueryHash,
|
|
jsonPayload
|
|
}) => {
|
|
staticQueryResultsMap[staticQueryHash] = jsonPayload;
|
|
this.staticQueryDb[staticQueryHash] = jsonPayload;
|
|
});
|
|
return staticQueryResultsMap;
|
|
});
|
|
return Promise.all([componentChunkPromise, staticQueryBatchPromise]).then(([pageResources, staticQueryResults]) => {
|
|
let payload;
|
|
|
|
if (pageResources) {
|
|
payload = { ...pageResources,
|
|
staticQueryResults
|
|
};
|
|
finalResult.payload = payload;
|
|
|
|
_emitter.default.emit(`onPostLoadPageResources`, {
|
|
page: payload,
|
|
pageResources: payload
|
|
});
|
|
}
|
|
|
|
this.pageDb.set(pagePath, finalResult);
|
|
|
|
if (finalResult.error) {
|
|
return {
|
|
error: finalResult.error,
|
|
status: finalResult.status
|
|
};
|
|
}
|
|
|
|
return payload;
|
|
}) // when static-query fail to load we throw a better error
|
|
.catch(err => {
|
|
return {
|
|
error: err,
|
|
status: PageResourceStatus.Error
|
|
};
|
|
});
|
|
});
|
|
inFlightPromise.then(() => {
|
|
this.inFlightDb.delete(pagePath);
|
|
}).catch(error => {
|
|
this.inFlightDb.delete(pagePath);
|
|
throw error;
|
|
});
|
|
this.inFlightDb.set(pagePath, inFlightPromise);
|
|
return inFlightPromise;
|
|
} // returns undefined if the page does not exists in cache
|
|
|
|
|
|
loadPageSync(rawPath, options = {}) {
|
|
const pagePath = (0, _findPath.findPath)(rawPath);
|
|
|
|
if (this.pageDb.has(pagePath)) {
|
|
const pageData = this.pageDb.get(pagePath);
|
|
|
|
if (pageData.payload) {
|
|
return pageData.payload;
|
|
}
|
|
|
|
if (options !== null && options !== void 0 && options.withErrorDetails) {
|
|
return {
|
|
error: pageData.error,
|
|
status: pageData.status
|
|
};
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
shouldPrefetch(pagePath) {
|
|
// Skip prefetching if we know user is on slow or constrained connection
|
|
if (!doesConnectionSupportPrefetch()) {
|
|
return false;
|
|
} // Check if the page exists.
|
|
|
|
|
|
if (this.pageDb.has(pagePath)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
prefetch(pagePath) {
|
|
if (!this.shouldPrefetch(pagePath)) {
|
|
return false;
|
|
} // Tell plugins with custom prefetching logic that they should start
|
|
// prefetching this path.
|
|
|
|
|
|
if (!this.prefetchTriggered.has(pagePath)) {
|
|
this.apiRunner(`onPrefetchPathname`, {
|
|
pathname: pagePath
|
|
});
|
|
this.prefetchTriggered.add(pagePath);
|
|
} // If a plugin has disabled core prefetching, stop now.
|
|
|
|
|
|
if (this.prefetchDisabled) {
|
|
return false;
|
|
}
|
|
|
|
const realPath = (0, _findPath.findPath)(pagePath); // Todo make doPrefetch logic cacheable
|
|
// eslint-disable-next-line consistent-return
|
|
|
|
this.doPrefetch(realPath).then(() => {
|
|
if (!this.prefetchCompleted.has(pagePath)) {
|
|
this.apiRunner(`onPostPrefetchPathname`, {
|
|
pathname: pagePath
|
|
});
|
|
this.prefetchCompleted.add(pagePath);
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
doPrefetch(pagePath) {
|
|
const pageDataUrl = createPageDataUrl(pagePath);
|
|
return (0, _prefetch.default)(pageDataUrl, {
|
|
crossOrigin: `anonymous`,
|
|
as: `fetch`
|
|
}).then(() => // This was just prefetched, so will return a response from
|
|
// the cache instead of making another request to the server
|
|
this.loadPageDataJson(pagePath));
|
|
}
|
|
|
|
hovering(rawPath) {
|
|
this.loadPage(rawPath);
|
|
}
|
|
|
|
getResourceURLsForPathname(rawPath) {
|
|
const pagePath = (0, _findPath.findPath)(rawPath);
|
|
const page = this.pageDataDb.get(pagePath);
|
|
|
|
if (page) {
|
|
const pageResources = toPageResources(page.payload);
|
|
return [...createComponentUrls(pageResources.page.componentChunkName), createPageDataUrl(pagePath)];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
isPageNotFound(rawPath) {
|
|
const pagePath = (0, _findPath.findPath)(rawPath);
|
|
const page = this.pageDb.get(pagePath);
|
|
return !page || page.notFound;
|
|
}
|
|
|
|
loadAppData(retries = 0) {
|
|
return this.memoizedGet(`${__PATH_PREFIX__}/page-data/app-data.json`).then(req => {
|
|
const {
|
|
status,
|
|
responseText
|
|
} = req;
|
|
let appData;
|
|
|
|
if (status !== 200 && retries < 3) {
|
|
// Retry 3 times incase of non-200 responses
|
|
return this.loadAppData(retries + 1);
|
|
} // Handle 200
|
|
|
|
|
|
if (status === 200) {
|
|
try {
|
|
const jsonPayload = JSON.parse(responseText);
|
|
|
|
if (jsonPayload.webpackCompilationHash === undefined) {
|
|
throw new Error(`not a valid app-data response`);
|
|
}
|
|
|
|
appData = jsonPayload;
|
|
} catch (err) {// continue regardless of error
|
|
}
|
|
}
|
|
|
|
return appData;
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
exports.BaseLoader = BaseLoader;
|
|
|
|
const createComponentUrls = componentChunkName => (window.___chunkMapping[componentChunkName] || []).map(chunk => __PATH_PREFIX__ + chunk);
|
|
|
|
class ProdLoader extends BaseLoader {
|
|
constructor(asyncRequires, matchPaths, pageData) {
|
|
const loadComponent = chunkName => {
|
|
if (!asyncRequires.components[chunkName]) {
|
|
throw new Error(`We couldn't find the correct component chunk with the name ${chunkName}`);
|
|
}
|
|
|
|
return asyncRequires.components[chunkName]().then(preferDefault) // loader will handle the case when component is error
|
|
.catch(err => err);
|
|
};
|
|
|
|
super(loadComponent, matchPaths);
|
|
|
|
if (pageData) {
|
|
this.pageDataDb.set(pageData.path, {
|
|
pagePath: pageData.path,
|
|
payload: pageData,
|
|
status: `success`
|
|
});
|
|
}
|
|
}
|
|
|
|
doPrefetch(pagePath) {
|
|
return super.doPrefetch(pagePath).then(result => {
|
|
if (result.status !== PageResourceStatus.Success) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const pageData = result.payload;
|
|
const chunkName = pageData.componentChunkName;
|
|
const componentUrls = createComponentUrls(chunkName);
|
|
return Promise.all(componentUrls.map(_prefetch.default)).then(() => pageData);
|
|
});
|
|
}
|
|
|
|
loadPageDataJson(rawPath) {
|
|
return super.loadPageDataJson(rawPath).then(data => {
|
|
if (data.notFound) {
|
|
// check if html file exist using HEAD request:
|
|
// if it does we should navigate to it instead of showing 404
|
|
return doFetch(rawPath, `HEAD`).then(req => {
|
|
if (req.status === 200) {
|
|
// page (.html file) actually exist (or we asked for 404 )
|
|
// returning page resources status as errored to trigger
|
|
// regular browser navigation to given page
|
|
return {
|
|
status: PageResourceStatus.Error
|
|
};
|
|
} // if HEAD request wasn't 200, return notFound result
|
|
// and show 404 page
|
|
|
|
|
|
return data;
|
|
});
|
|
}
|
|
|
|
return data;
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
exports.ProdLoader = ProdLoader;
|
|
let instance;
|
|
|
|
const setLoader = _loader => {
|
|
instance = _loader;
|
|
};
|
|
|
|
exports.setLoader = setLoader;
|
|
const publicLoader = {
|
|
enqueue: rawPath => instance.prefetch(rawPath),
|
|
// Real methods
|
|
getResourceURLsForPathname: rawPath => instance.getResourceURLsForPathname(rawPath),
|
|
loadPage: rawPath => instance.loadPage(rawPath),
|
|
// TODO add deprecation to v4 so people use withErrorDetails and then we can remove in v5 and change default behaviour
|
|
loadPageSync: (rawPath, options = {}) => instance.loadPageSync(rawPath, options),
|
|
prefetch: rawPath => instance.prefetch(rawPath),
|
|
isPageNotFound: rawPath => instance.isPageNotFound(rawPath),
|
|
hovering: rawPath => instance.hovering(rawPath),
|
|
loadAppData: () => instance.loadAppData()
|
|
};
|
|
exports.publicLoader = publicLoader;
|
|
var _default = publicLoader;
|
|
exports.default = _default;
|
|
|
|
function getStaticQueryResults() {
|
|
if (instance) {
|
|
return instance.staticQueryDb;
|
|
} else {
|
|
return {};
|
|
}
|
|
} |