Router.ts 13 KB

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