Router.js 16 KB

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