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