Router.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. /*
  2. Copyright 2018 Google LLC
  3. Use of this source code is governed by an MIT-style
  4. license that can be found in the LICENSE file or at
  5. https://opensource.org/licenses/MIT.
  6. */
  7. import {assert} from 'workbox-core/_private/assert.mjs';
  8. import {logger} from 'workbox-core/_private/logger.mjs';
  9. import {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';
  10. import {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';
  11. import {normalizeHandler} from './utils/normalizeHandler.mjs';
  12. import './_version.mjs';
  13. /**
  14. * The Router can be used to process a FetchEvent through one or more
  15. * [Routes]{@link workbox.routing.Route} responding with a Request if
  16. * a matching route exists.
  17. *
  18. * If no route matches a given a request, the Router will use a "default"
  19. * handler if one is defined.
  20. *
  21. * Should the matching Route throw an error, the Router will use a "catch"
  22. * handler if one is defined to gracefully deal with issues and respond with a
  23. * Request.
  24. *
  25. * If a request matches multiple routes, the **earliest** registered route will
  26. * be used to respond to the request.
  27. *
  28. * @memberof workbox.routing
  29. */
  30. class Router {
  31. /**
  32. * Initializes a new Router.
  33. */
  34. constructor() {
  35. this._routes = new Map();
  36. }
  37. /**
  38. * @return {Map<string, Array<workbox.routing.Route>>} routes A `Map` of HTTP
  39. * method name ('GET', etc.) to an array of all the corresponding `Route`
  40. * instances that are registered.
  41. */
  42. get routes() {
  43. return this._routes;
  44. }
  45. /**
  46. * Adds a fetch event listener to respond to events when a route matches
  47. * the event's request.
  48. */
  49. addFetchListener() {
  50. self.addEventListener('fetch', (event) => {
  51. const {request} = event;
  52. const responsePromise = this.handleRequest({request, event});
  53. if (responsePromise) {
  54. event.respondWith(responsePromise);
  55. }
  56. });
  57. }
  58. /**
  59. * Adds a message event listener for URLs to cache from the window.
  60. * This is useful to cache resources loaded on the page prior to when the
  61. * service worker started controlling it.
  62. *
  63. * The format of the message data sent from the window should be as follows.
  64. * Where the `urlsToCache` array may consist of URL strings or an array of
  65. * URL string + `requestInit` object (the same as you'd pass to `fetch()`).
  66. *
  67. * ```
  68. * {
  69. * type: 'CACHE_URLS',
  70. * payload: {
  71. * urlsToCache: [
  72. * './script1.js',
  73. * './script2.js',
  74. * ['./script3.js', {mode: 'no-cors'}],
  75. * ],
  76. * },
  77. * }
  78. * ```
  79. */
  80. addCacheListener() {
  81. self.addEventListener('message', async (event) => {
  82. if (event.data && event.data.type === 'CACHE_URLS') {
  83. const {payload} = event.data;
  84. if (process.env.NODE_ENV !== 'production') {
  85. logger.debug(`Caching URLs from the window`, payload.urlsToCache);
  86. }
  87. const requestPromises = Promise.all(payload.urlsToCache.map((entry) => {
  88. if (typeof entry === 'string') {
  89. entry = [entry];
  90. }
  91. const request = new Request(...entry);
  92. return this.handleRequest({request});
  93. }));
  94. event.waitUntil(requestPromises);
  95. // If a MessageChannel was used, reply to the message on success.
  96. if (event.ports && event.ports[0]) {
  97. await requestPromises;
  98. event.ports[0].postMessage(true);
  99. }
  100. }
  101. });
  102. }
  103. /**
  104. * Apply the routing rules to a FetchEvent object to get a Response from an
  105. * appropriate Route's handler.
  106. *
  107. * @param {Object} options
  108. * @param {Request} options.request The request to handle (this is usually
  109. * from a fetch event, but it does not have to be).
  110. * @param {FetchEvent} [options.event] The event that triggered the request,
  111. * if applicable.
  112. * @return {Promise<Response>|undefined} A promise is returned if a
  113. * registered route can handle the request. If there is no matching
  114. * route and there's no `defaultHandler`, `undefined` is returned.
  115. */
  116. handleRequest({request, event}) {
  117. if (process.env.NODE_ENV !== 'production') {
  118. assert.isInstance(request, Request, {
  119. moduleName: 'workbox-routing',
  120. className: 'Router',
  121. funcName: 'handleRequest',
  122. paramName: 'options.request',
  123. });
  124. }
  125. const url = new URL(request.url, location);
  126. if (!url.protocol.startsWith('http')) {
  127. if (process.env.NODE_ENV !== 'production') {
  128. logger.debug(
  129. `Workbox Router only supports URLs that start with 'http'.`);
  130. }
  131. return;
  132. }
  133. let {params, route} = this.findMatchingRoute({url, request, event});
  134. let handler = route && route.handler;
  135. let debugMessages = [];
  136. if (process.env.NODE_ENV !== 'production') {
  137. if (handler) {
  138. debugMessages.push([
  139. `Found a route to handle this request:`, route,
  140. ]);
  141. if (params) {
  142. debugMessages.push([
  143. `Passing the following params to the route's handler:`, params,
  144. ]);
  145. }
  146. }
  147. }
  148. // If we don't have a handler because there was no matching route, then
  149. // fall back to defaultHandler if that's defined.
  150. if (!handler && this._defaultHandler) {
  151. if (process.env.NODE_ENV !== 'production') {
  152. debugMessages.push(`Failed to find a matching route. Falling ` +
  153. `back to the default handler.`);
  154. // This is used for debugging in logs in the case of an error.
  155. route = '[Default Handler]';
  156. }
  157. handler = this._defaultHandler;
  158. }
  159. if (!handler) {
  160. if (process.env.NODE_ENV !== 'production') {
  161. // No handler so Workbox will do nothing. If logs is set of debug
  162. // i.e. verbose, we should print out this information.
  163. logger.debug(`No route found for: ${getFriendlyURL(url)}`);
  164. }
  165. return;
  166. }
  167. if (process.env.NODE_ENV !== 'production') {
  168. // We have a handler, meaning Workbox is going to handle the route.
  169. // print the routing details to the console.
  170. logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`);
  171. debugMessages.forEach((msg) => {
  172. if (Array.isArray(msg)) {
  173. logger.log(...msg);
  174. } else {
  175. logger.log(msg);
  176. }
  177. });
  178. // The Request and Response objects contains a great deal of information,
  179. // hide it under a group in case developers want to see it.
  180. logger.groupCollapsed(`View request details here.`);
  181. logger.log(request);
  182. logger.groupEnd();
  183. logger.groupEnd();
  184. }
  185. // Wrap in try and catch in case the handle method throws a synchronous
  186. // error. It should still callback to the catch handler.
  187. let responsePromise;
  188. try {
  189. responsePromise = handler.handle({url, request, event, params});
  190. } catch (err) {
  191. responsePromise = Promise.reject(err);
  192. }
  193. if (responsePromise && this._catchHandler) {
  194. responsePromise = responsePromise.catch((err) => {
  195. if (process.env.NODE_ENV !== 'production') {
  196. // Still include URL here as it will be async from the console group
  197. // and may not make sense without the URL
  198. logger.groupCollapsed(`Error thrown when responding to: ` +
  199. ` ${getFriendlyURL(url)}. Falling back to Catch Handler.`);
  200. logger.error(`Error thrown by:`, route);
  201. logger.error(err);
  202. logger.groupEnd();
  203. }
  204. return this._catchHandler.handle({url, event, err});
  205. });
  206. }
  207. return responsePromise;
  208. }
  209. /**
  210. * Checks a request and URL (and optionally an event) against the list of
  211. * registered routes, and if there's a match, returns the corresponding
  212. * route along with any params generated by the match.
  213. *
  214. * @param {Object} options
  215. * @param {URL} options.url
  216. * @param {Request} options.request The request to match.
  217. * @param {FetchEvent} [options.event] The corresponding event (unless N/A).
  218. * @return {Object} An object with `route` and `params` properties.
  219. * They are populated if a matching route was found or `undefined`
  220. * otherwise.
  221. */
  222. findMatchingRoute({url, request, event}) {
  223. if (process.env.NODE_ENV !== 'production') {
  224. assert.isInstance(url, URL, {
  225. moduleName: 'workbox-routing',
  226. className: 'Router',
  227. funcName: 'findMatchingRoute',
  228. paramName: 'options.url',
  229. });
  230. assert.isInstance(request, Request, {
  231. moduleName: 'workbox-routing',
  232. className: 'Router',
  233. funcName: 'findMatchingRoute',
  234. paramName: 'options.request',
  235. });
  236. }
  237. const routes = this._routes.get(request.method) || [];
  238. for (const route of routes) {
  239. let params;
  240. let matchResult = route.match({url, request, event});
  241. if (matchResult) {
  242. if (Array.isArray(matchResult) && matchResult.length > 0) {
  243. // Instead of passing an empty array in as params, use undefined.
  244. params = matchResult;
  245. } else if ((matchResult.constructor === Object &&
  246. Object.keys(matchResult).length > 0)) {
  247. // Instead of passing an empty object in as params, use undefined.
  248. params = matchResult;
  249. }
  250. // Return early if have a match.
  251. return {route, params};
  252. }
  253. }
  254. // If no match was found above, return and empty object.
  255. return {};
  256. }
  257. /**
  258. * Define a default `handler` that's called when no routes explicitly
  259. * match the incoming request.
  260. *
  261. * Without a default handler, unmatched requests will go against the
  262. * network as if there were no service worker present.
  263. *
  264. * @param {workbox.routing.Route~handlerCallback} handler A callback
  265. * function that returns a Promise resulting in a Response.
  266. */
  267. setDefaultHandler(handler) {
  268. this._defaultHandler = normalizeHandler(handler);
  269. }
  270. /**
  271. * If a Route throws an error while handling a request, this `handler`
  272. * will be called and given a chance to provide a response.
  273. *
  274. * @param {workbox.routing.Route~handlerCallback} handler A callback
  275. * function that returns a Promise resulting in a Response.
  276. */
  277. setCatchHandler(handler) {
  278. this._catchHandler = normalizeHandler(handler);
  279. }
  280. /**
  281. * Registers a route with the router.
  282. *
  283. * @param {workbox.routing.Route} route The route to register.
  284. */
  285. registerRoute(route) {
  286. if (process.env.NODE_ENV !== 'production') {
  287. assert.isType(route, 'object', {
  288. moduleName: 'workbox-routing',
  289. className: 'Router',
  290. funcName: 'registerRoute',
  291. paramName: 'route',
  292. });
  293. assert.hasMethod(route, 'match', {
  294. moduleName: 'workbox-routing',
  295. className: 'Router',
  296. funcName: 'registerRoute',
  297. paramName: 'route',
  298. });
  299. assert.isType(route.handler, 'object', {
  300. moduleName: 'workbox-routing',
  301. className: 'Router',
  302. funcName: 'registerRoute',
  303. paramName: 'route',
  304. });
  305. assert.hasMethod(route.handler, 'handle', {
  306. moduleName: 'workbox-routing',
  307. className: 'Router',
  308. funcName: 'registerRoute',
  309. paramName: 'route.handler',
  310. });
  311. assert.isType(route.method, 'string', {
  312. moduleName: 'workbox-routing',
  313. className: 'Router',
  314. funcName: 'registerRoute',
  315. paramName: 'route.method',
  316. });
  317. }
  318. if (!this._routes.has(route.method)) {
  319. this._routes.set(route.method, []);
  320. }
  321. // Give precedence to all of the earlier routes by adding this additional
  322. // route to the end of the array.
  323. this._routes.get(route.method).push(route);
  324. }
  325. /**
  326. * Unregisters a route with the router.
  327. *
  328. * @param {workbox.routing.Route} route The route to unregister.
  329. */
  330. unregisterRoute(route) {
  331. if (!this._routes.has(route.method)) {
  332. throw new WorkboxError(
  333. 'unregister-route-but-not-found-with-method', {
  334. method: route.method,
  335. }
  336. );
  337. }
  338. const routeIndex = this._routes.get(route.method).indexOf(route);
  339. if (routeIndex > -1) {
  340. this._routes.get(route.method).splice(routeIndex, 1);
  341. } else {
  342. throw new WorkboxError('unregister-route-route-not-registered');
  343. }
  344. }
  345. }
  346. export {Router};