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