1// Copyright (C) 2018 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import * as m from 'mithril'; 16import {assertExists, assertTrue} from '../base/logging'; 17import {PageAttrs} from './pages'; 18 19export const ROUTE_PREFIX = '#!'; 20const DEFAULT_ROUTE = '/'; 21 22/* 23 * A broken down representation of a route. 24 * For instance: #!/record/gpu?local_cache_key=a0b1 25 * becomes: {page: '/record', subpage: '/gpu', args: {local_cache_key: 'a0b1'}} 26 */ 27export interface Route { 28 page: string; 29 subpage: string; 30 args: RouteArgs; 31} 32 33/* 34 * The set of args that can be set on the route via #!/page?a=1&b2. 35 * Route args are orthogonal to pages (i.e. should NOT make sense only in a 36 * only within a specific page, use /page/subpages for that). 37 * Args are !== the querystring (location.search) which is sent to the 38 * server. The route args are NOT sent to the HTTP server. 39 * Given this URL: 40 * http://host/?foo=1&bar=2#!/page/subpage?local_cache_key=a0b1&baz=3. 41 * 42 * location.search = 'foo=1&bar=2'. 43 * This is seen by the HTTP server. We really don't use querystrings as the 44 * perfetto UI is client only. 45 * 46 * location.hash = '#!/page/subpage?local_cache_key=a0b1'. 47 * This is client-only. All the routing logic in the Perfetto UI uses only 48 * this. 49 */ 50export interface RouteArgs { 51 // The local_cache_key is special and is persisted across navigations. 52 local_cache_key?: string; 53 54 // These are transient and are really set only on startup. 55 openFromAndroidBugTool?: string; 56 s?: string; // For permalinks. 57 p?: string; // DEPRECATED: for #!/record?p=cpu subpages (b/191255021). 58 url?: string; // For fetching traces from Cloud Storage. 59} 60 61export interface RoutesMap { 62 [key: string]: m.Component<PageAttrs>; 63} 64 65/* 66 * This router does two things: 67 * 1) Maps fragment paths (#!/page/subpage) to Mithril components. 68 * The route map is passed to the ctor and is later used when calling the 69 * resolve() method. 70 * 71 * 2) Handles the (optional) args, e.g. #!/page?arg=1&arg2=2. 72 * Route args are carry information that is orthogonal to the page (e.g. the 73 * trace id). 74 * local_cache_key has some special treatment: once a URL has a local_cache_key, 75 * it gets automatically appended to further navigations that don't have one. 76 * For instance if the current url is #!/viewer?local_cache_key=1234 and a later 77 * action (either user-initiated or code-initited) navigates to #!/info, the 78 * rotuer will automatically replace the history entry with 79 * #!/info?local_cache_key=1234. 80 * This is to keep propagating the trace id across page changes, for handling 81 * tab discards (b/175041881). 82 * 83 * This class does NOT deal with the "load a trace when the url contains ?url= 84 * or ?local_cache_key=". That logic lives in trace_url_handler.ts, which is 85 * triggered by Router.onRouteChanged(). 86 */ 87export class Router { 88 private readonly recentChanges: number[] = []; 89 private routes: RoutesMap; 90 91 // frontend/index.ts calls maybeOpenTraceFromRoute() + redraw here. 92 // This event is decoupled for testing and to avoid circular deps. 93 onRouteChanged: (route: Route) => (void) = () => {}; 94 95 constructor(routes: RoutesMap) { 96 assertExists(routes[DEFAULT_ROUTE]); 97 this.routes = routes; 98 window.onhashchange = (e: HashChangeEvent) => this.onHashChange(e); 99 } 100 101 private onHashChange(e: HashChangeEvent) { 102 this.crashIfLivelock(); 103 104 const oldRoute = Router.parseUrl(e.oldURL); 105 const newRoute = Router.parseUrl(e.newURL); 106 107 if (newRoute.args.local_cache_key === undefined && 108 oldRoute.args.local_cache_key) { 109 // Propagate `local_cache_key across` navigations. When a trace is loaded, 110 // the URL becomes #!/viewer?local_cache_key=123. `local_cache_key` allows 111 // reopening the trace from cache in the case of a reload or discard. 112 // When using the UI we can hit "bare" links (e.g. just '#!/info') which 113 // don't have the trace_uuid: 114 // - When clicking on an <a> element from the sidebar. 115 // - When the code calls Router.navigate(). 116 // - When the user pastes a URL from docs page. 117 // In all these cases we want to keep propagating the `local_cache_key`. 118 // We do so by re-setting the `local_cache_key` and doing a 119 // location.replace which overwrites the history entry (note 120 // location.replace is NOT just a String.replace operation). 121 newRoute.args.local_cache_key = oldRoute.args.local_cache_key; 122 } 123 124 const args = m.buildQueryString(newRoute.args); 125 let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`; 126 normalizedFragment += args.length > 0 ? '?' + args : ''; 127 if (!e.newURL.endsWith(normalizedFragment)) { 128 location.replace(normalizedFragment); 129 return; 130 } 131 132 this.onRouteChanged(newRoute); 133 } 134 135 /** 136 * Returns the component for the current route in the URL. 137 * If no route matches the URL, returns a component corresponding to 138 * |this.defaultRoute|. 139 */ 140 resolve(): m.Vnode<PageAttrs> { 141 const route = Router.parseFragment(location.hash); 142 let component = this.routes[route.page]; 143 if (component === undefined) { 144 component = assertExists(this.routes[DEFAULT_ROUTE]); 145 } 146 return m(component, {subpage: route.subpage} as PageAttrs); 147 } 148 149 static navigate(newHash: string) { 150 assertTrue(newHash.startsWith(ROUTE_PREFIX)); 151 window.location.hash = newHash; 152 } 153 154 /* 155 * Breaks down a fragment into a Route object. 156 * Sample input: 157 * '#!/record/gpu?local_cache_key=abcd-1234' 158 * Sample output: 159 * {page: '/record', subpage: '/gpu', args: {local_cache_key: 'abcd-1234'}} 160 */ 161 static parseFragment(hash: string): Route { 162 const prefixLength = ROUTE_PREFIX.length; 163 let route = ''; 164 if (hash.startsWith(ROUTE_PREFIX)) { 165 route = hash.substr(prefixLength).split('?')[0]; 166 } 167 168 let page = route; 169 let subpage = ''; 170 const splittingPoint = route.indexOf('/', 1); 171 if (splittingPoint > 0) { 172 page = route.substr(0, splittingPoint); 173 subpage = route.substr(splittingPoint); 174 } 175 176 const argsStart = hash.indexOf('?'); 177 const argsStr = argsStart < 0 ? '' : hash.substr(argsStart + 1); 178 const args = argsStr ? m.parseQueryString(hash.substr(argsStart)) : {}; 179 180 // TODO(primiano): remove this in mid-2022. trace_id is the same concept of 181 // local_cache_key. Just at some point we renamed it to make it more obvious 182 // to people that those URLs cannot be copy-pasted in bugs. For now this 183 // handles cases of reloading pages from old version. 184 if ('trace_id' in args) { 185 if (!('local_cache_key' in args)) { 186 args['local_cache_key'] = args['trace_id']; 187 } 188 delete args['trace_id']; 189 } 190 191 return {page, subpage, args}; 192 } 193 194 /* 195 * Like parseFragment() but takes a full URL. 196 */ 197 static parseUrl(url: string): Route { 198 const hashPos = url.indexOf('#'); 199 const fragment = hashPos < 0 ? '' : url.substr(hashPos); 200 return Router.parseFragment(fragment); 201 } 202 203 /* 204 * Throws if EVENT_LIMIT onhashchange events occur within WINDOW_MS. 205 */ 206 private crashIfLivelock() { 207 const WINDOW_MS = 1000; 208 const EVENT_LIMIT = 20; 209 const now = Date.now(); 210 while (this.recentChanges.length > 0 && 211 now - this.recentChanges[0] > WINDOW_MS) { 212 this.recentChanges.shift(); 213 } 214 this.recentChanges.push(now); 215 if (this.recentChanges.length > EVENT_LIMIT) { 216 throw new Error('History rewriting livelock'); 217 } 218 } 219} 220