Cache.ts (6673B)
1 /** 2 * @license 3 * Copyright 2023 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import fs from 'node:fs'; 8 import os from 'node:os'; 9 import path from 'node:path'; 10 11 import debug from 'debug'; 12 13 import { 14 Browser, 15 type BrowserPlatform, 16 executablePathByBrowser, 17 getVersionComparator, 18 } from './browser-data/browser-data.js'; 19 import {detectBrowserPlatform} from './detectPlatform.js'; 20 21 const debugCache = debug('puppeteer:browsers:cache'); 22 23 /** 24 * @public 25 */ 26 export class InstalledBrowser { 27 browser: Browser; 28 buildId: string; 29 platform: BrowserPlatform; 30 readonly executablePath: string; 31 32 #cache: Cache; 33 34 /** 35 * @internal 36 */ 37 constructor( 38 cache: Cache, 39 browser: Browser, 40 buildId: string, 41 platform: BrowserPlatform, 42 ) { 43 this.#cache = cache; 44 this.browser = browser; 45 this.buildId = buildId; 46 this.platform = platform; 47 this.executablePath = cache.computeExecutablePath({ 48 browser, 49 buildId, 50 platform, 51 }); 52 } 53 54 /** 55 * Path to the root of the installation folder. Use 56 * {@link computeExecutablePath} to get the path to the executable binary. 57 */ 58 get path(): string { 59 return this.#cache.installationDir( 60 this.browser, 61 this.platform, 62 this.buildId, 63 ); 64 } 65 66 readMetadata(): Metadata { 67 return this.#cache.readMetadata(this.browser); 68 } 69 70 writeMetadata(metadata: Metadata): void { 71 this.#cache.writeMetadata(this.browser, metadata); 72 } 73 } 74 75 /** 76 * @internal 77 */ 78 export interface ComputeExecutablePathOptions { 79 /** 80 * Determines which platform the browser will be suited for. 81 * 82 * @defaultValue **Auto-detected.** 83 */ 84 platform?: BrowserPlatform; 85 /** 86 * Determines which browser to launch. 87 */ 88 browser: Browser; 89 /** 90 * Determines which buildId to download. BuildId should uniquely identify 91 * binaries and they are used for caching. 92 */ 93 buildId: string; 94 } 95 96 export interface Metadata { 97 // Maps an alias (canary/latest/dev/etc.) to a buildId. 98 aliases: Record<string, string>; 99 } 100 101 /** 102 * The cache used by Puppeteer relies on the following structure: 103 * 104 * - rootDir 105 * -- <browser1> | browserRoot(browser1) 106 * ---- <platform>-<buildId> | installationDir() 107 * ------ the browser-platform-buildId 108 * ------ specific structure. 109 * -- <browser2> | browserRoot(browser2) 110 * ---- <platform>-<buildId> | installationDir() 111 * ------ the browser-platform-buildId 112 * ------ specific structure. 113 * @internal 114 */ 115 export class Cache { 116 #rootDir: string; 117 118 constructor(rootDir: string) { 119 this.#rootDir = rootDir; 120 } 121 122 /** 123 * @internal 124 */ 125 get rootDir(): string { 126 return this.#rootDir; 127 } 128 129 browserRoot(browser: Browser): string { 130 return path.join(this.#rootDir, browser); 131 } 132 133 metadataFile(browser: Browser): string { 134 return path.join(this.browserRoot(browser), '.metadata'); 135 } 136 137 readMetadata(browser: Browser): Metadata { 138 const metatadaPath = this.metadataFile(browser); 139 if (!fs.existsSync(metatadaPath)) { 140 return {aliases: {}}; 141 } 142 // TODO: add type-safe parsing. 143 const data = JSON.parse(fs.readFileSync(metatadaPath, 'utf8')); 144 if (typeof data !== 'object') { 145 throw new Error('.metadata is not an object'); 146 } 147 return data; 148 } 149 150 writeMetadata(browser: Browser, metadata: Metadata): void { 151 const metatadaPath = this.metadataFile(browser); 152 fs.mkdirSync(path.dirname(metatadaPath), {recursive: true}); 153 fs.writeFileSync(metatadaPath, JSON.stringify(metadata, null, 2)); 154 } 155 156 resolveAlias(browser: Browser, alias: string): string | undefined { 157 const metadata = this.readMetadata(browser); 158 if (alias === 'latest') { 159 return Object.values(metadata.aliases || {}) 160 .sort(getVersionComparator(browser)) 161 .at(-1); 162 } 163 return metadata.aliases[alias]; 164 } 165 166 installationDir( 167 browser: Browser, 168 platform: BrowserPlatform, 169 buildId: string, 170 ): string { 171 return path.join(this.browserRoot(browser), `${platform}-${buildId}`); 172 } 173 174 clear(): void { 175 fs.rmSync(this.#rootDir, { 176 force: true, 177 recursive: true, 178 maxRetries: 10, 179 retryDelay: 500, 180 }); 181 } 182 183 uninstall( 184 browser: Browser, 185 platform: BrowserPlatform, 186 buildId: string, 187 ): void { 188 const metadata = this.readMetadata(browser); 189 for (const alias of Object.keys(metadata.aliases)) { 190 if (metadata.aliases[alias] === buildId) { 191 delete metadata.aliases[alias]; 192 } 193 } 194 fs.rmSync(this.installationDir(browser, platform, buildId), { 195 force: true, 196 recursive: true, 197 maxRetries: 10, 198 retryDelay: 500, 199 }); 200 } 201 202 getInstalledBrowsers(): InstalledBrowser[] { 203 if (!fs.existsSync(this.#rootDir)) { 204 return []; 205 } 206 const types = fs.readdirSync(this.#rootDir); 207 const browsers = types.filter((t): t is Browser => { 208 return (Object.values(Browser) as string[]).includes(t); 209 }); 210 return browsers.flatMap(browser => { 211 const files = fs.readdirSync(this.browserRoot(browser)); 212 return files 213 .map(file => { 214 const result = parseFolderPath( 215 path.join(this.browserRoot(browser), file), 216 ); 217 if (!result) { 218 return null; 219 } 220 return new InstalledBrowser( 221 this, 222 browser, 223 result.buildId, 224 result.platform as BrowserPlatform, 225 ); 226 }) 227 .filter((item: InstalledBrowser | null): item is InstalledBrowser => { 228 return item !== null; 229 }); 230 }); 231 } 232 233 computeExecutablePath(options: ComputeExecutablePathOptions): string { 234 options.platform ??= detectBrowserPlatform(); 235 if (!options.platform) { 236 throw new Error( 237 `Cannot download a binary for the provided platform: ${os.platform()} (${os.arch()})`, 238 ); 239 } 240 try { 241 options.buildId = 242 this.resolveAlias(options.browser, options.buildId) ?? options.buildId; 243 } catch { 244 debugCache('could not read .metadata file for the browser'); 245 } 246 const installationDir = this.installationDir( 247 options.browser, 248 options.platform, 249 options.buildId, 250 ); 251 return path.join( 252 installationDir, 253 executablePathByBrowser[options.browser]( 254 options.platform, 255 options.buildId, 256 ), 257 ); 258 } 259 } 260 261 function parseFolderPath( 262 folderPath: string, 263 ): {platform: string; buildId: string} | undefined { 264 const name = path.basename(folderPath); 265 const splits = name.split('-'); 266 if (splits.length !== 2) { 267 return; 268 } 269 const [platform, buildId] = splits; 270 if (!buildId || !platform) { 271 return; 272 } 273 return {platform, buildId}; 274 }