project-snapshot.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import pLimit from 'p-limit'
  2. import { Change, Chunk, Snapshot, File } from 'overleaf-editor-core'
  3. import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
  4. import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
  5. const DOWNLOAD_BLOBS_CONCURRENCY = 10
  6. /**
  7. * Project snapshot container with on-demand refresh
  8. */
  9. export class ProjectSnapshot {
  10. private projectId: string
  11. private snapshot: Snapshot
  12. private version: number
  13. private blobStore: SimpleBlobStore
  14. private refreshPromise: Promise<void>
  15. private initialized: boolean
  16. private refreshing: boolean
  17. private queued: boolean
  18. constructor(projectId: string) {
  19. this.projectId = projectId
  20. this.snapshot = new Snapshot()
  21. this.version = 0
  22. this.refreshPromise = Promise.resolve()
  23. this.initialized = false
  24. this.refreshing = false
  25. this.queued = false
  26. this.blobStore = new SimpleBlobStore(this.projectId)
  27. }
  28. /**
  29. * Request a refresh of the snapshot.
  30. *
  31. * When the returned promise resolves, the snapshot is guaranteed to have been
  32. * updated at least to the version of the document that was current when the
  33. * function was called.
  34. */
  35. async refresh() {
  36. if (this.queued) {
  37. // There already is a queued refresh that will run after this call.
  38. // Just wait for it to complete.
  39. await this.refreshPromise
  40. } else if (this.refreshing) {
  41. // There is a refresh running, but no queued refresh. Queue a refresh
  42. // after this one and make it the new promise to wait for.
  43. this.refreshPromise = this.queueRefresh()
  44. await this.refreshPromise
  45. } else {
  46. // There is no refresh running. Start one.
  47. this.refreshPromise = this.startRefresh()
  48. await this.refreshPromise
  49. }
  50. }
  51. /**
  52. * Get the list of paths to editable docs.
  53. */
  54. getDocPaths(): string[] {
  55. const allPaths = this.snapshot.getFilePathnames()
  56. return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
  57. }
  58. /**
  59. * Get the list of paths to binary files.
  60. */
  61. getBinaryFilePathsWithHash(): { path: string; hash: string; size: number }[] {
  62. const allPaths = this.snapshot.getFilePathnames()
  63. const paths = []
  64. for (const path of allPaths) {
  65. const file = this.snapshot.getFile(path)
  66. if (file == null || file.isEditable()) {
  67. continue
  68. }
  69. const hash = file.getHash()
  70. const size = file.getByteLength()
  71. if (hash == null) {
  72. continue
  73. }
  74. if (size == null) {
  75. continue
  76. }
  77. paths.push({ path, hash, size })
  78. }
  79. return paths
  80. }
  81. /**
  82. * Use an algorithm similar to Kpathsea to locate files in the project snapshot:
  83. *
  84. * 1. look for the exact path relative to the root path
  85. * 2. look for the path + extension relative to the root path
  86. * 3. look for the exact path relative to the current path
  87. * 4. look for the path + extension relative to the current path
  88. */
  89. locateFile(filePath: string, currentPath = '/', extensions = ['.tex']) {
  90. // ignore absolute paths
  91. if (filePath.startsWith('/')) {
  92. return null
  93. }
  94. const snapshotPaths = new Set(this.snapshot.getFilePathnames())
  95. const baseURLs = [
  96. // relative to the root of the compile directory
  97. new URL('https://overleaf.invalid'),
  98. ]
  99. if (currentPath !== '/') {
  100. // relative to the current directory
  101. baseURLs.push(new URL(currentPath, 'https://overleaf.invalid'))
  102. }
  103. const extensionsToTest = ['', ...extensions]
  104. for (const baseURL of baseURLs) {
  105. for (const extension of extensionsToTest) {
  106. const { pathname } = new URL(`${filePath}${extension}`, baseURL)
  107. const snapshotPath = pathname.substring(1) // remove leading slash
  108. if (snapshotPaths.has(snapshotPath)) {
  109. return snapshotPath
  110. }
  111. }
  112. }
  113. return null
  114. }
  115. /**
  116. * Get the doc content at the given path.
  117. */
  118. getDocContents(path: string): string | null {
  119. const file = this.snapshot.getFile(path)
  120. if (file == null) {
  121. return null
  122. }
  123. return file.getContent({ filterTrackedDeletes: true }) ?? null
  124. }
  125. async getBinaryFileContents(
  126. path: string,
  127. options?: { maxSize?: number }
  128. ): Promise<any> {
  129. const file = this.snapshot.getFile(path)
  130. const hash = file?.getHash()
  131. const byteLength = file?.getByteLength()
  132. if (hash == null) {
  133. return null
  134. }
  135. if (byteLength == null) {
  136. return null
  137. }
  138. let blobStoreOptions
  139. if (options?.maxSize != null && byteLength > options?.maxSize) {
  140. blobStoreOptions = { maxSize: options.maxSize }
  141. }
  142. return await this.blobStore.getString(hash, blobStoreOptions)
  143. }
  144. getDocs(): Map<string, File> {
  145. const files = new Map()
  146. for (const path of this.snapshot.getFilePathnames()) {
  147. const file = this.snapshot.getFile(path)
  148. if (file?.isEditable()) {
  149. files.set(path, file)
  150. }
  151. }
  152. return files
  153. }
  154. /**
  155. * Immediately start a refresh
  156. */
  157. private async startRefresh() {
  158. this.refreshing = true
  159. try {
  160. if (!this.initialized) {
  161. await this.initialize()
  162. } else {
  163. await this.loadChanges()
  164. }
  165. } finally {
  166. this.refreshing = false
  167. }
  168. }
  169. /**
  170. * Queue a refresh after the currently running refresh
  171. */
  172. private async queueRefresh() {
  173. this.queued = true
  174. try {
  175. await this.refreshPromise
  176. } catch {
  177. // Ignore errors
  178. }
  179. this.queued = false
  180. await this.startRefresh()
  181. }
  182. /**
  183. * Initialize the snapshot using the project's latest chunk.
  184. *
  185. * This is run on the first refresh.
  186. */
  187. private async initialize() {
  188. await flushHistory(this.projectId)
  189. const chunk = await fetchLatestChunk(this.projectId)
  190. this.snapshot = chunk.getSnapshot()
  191. this.snapshot.applyAll(chunk.getChanges())
  192. this.version = chunk.getEndVersion()
  193. await this.loadDocs()
  194. this.initialized = true
  195. }
  196. /**
  197. * Apply changes since the last refresh.
  198. *
  199. * This is run on the second and subsequent refreshes
  200. */
  201. private async loadChanges() {
  202. await flushHistory(this.projectId)
  203. let hasMore = true
  204. while (hasMore) {
  205. const response = await fetchLatestChanges(this.projectId, this.version)
  206. const changes = response.changes
  207. this.snapshot.applyAll(changes)
  208. this.version += changes.length
  209. hasMore = response.hasMore
  210. }
  211. await this.loadDocs()
  212. }
  213. /**
  214. * Load all editable docs in the snapshot.
  215. *
  216. * This is done by converting any lazy file data into an "eager" file data. If
  217. * a doc is already loaded, the load is a no-op.
  218. */
  219. private async loadDocs() {
  220. const paths = this.getDocPaths()
  221. const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
  222. await Promise.all(
  223. paths.map(path =>
  224. limit(async () => {
  225. const file = this.snapshot.getFile(path)
  226. await file?.load('eager', this.blobStore)
  227. })
  228. )
  229. )
  230. }
  231. }
  232. /**
  233. * Blob store that fetches blobs from the history service
  234. */
  235. class SimpleBlobStore {
  236. private projectId: string
  237. constructor(projectId: string) {
  238. this.projectId = projectId
  239. }
  240. async getString(
  241. hash: string,
  242. options?: { maxSize?: number }
  243. ): Promise<string> {
  244. return await fetchBlob(this.projectId, hash, options)
  245. }
  246. async getObject(hash: string) {
  247. const blob = await this.getString(hash)
  248. return JSON.parse(blob)
  249. }
  250. }
  251. async function flushHistory(projectId: string) {
  252. await postJSON(`/project/${projectId}/flush`)
  253. }
  254. async function fetchLatestChunk(projectId: string): Promise<Chunk> {
  255. const response = await getJSON<{ chunk: RawChunk }>(
  256. `/project/${projectId}/latest/history`
  257. )
  258. return Chunk.fromRaw(response.chunk)
  259. }
  260. type FetchLatestChangesResponse = {
  261. changes: Change[]
  262. hasMore: boolean
  263. }
  264. type FetchLatestChangesApiResponse =
  265. | RawChange[]
  266. | {
  267. changes: RawChange[]
  268. hasMore: boolean
  269. }
  270. async function fetchLatestChanges(
  271. projectId: string,
  272. version: number
  273. ): Promise<FetchLatestChangesResponse> {
  274. // TODO: The paginated flag is a transition flag. It can be removed after this
  275. // code has been deployed for a few weeks.
  276. const response = await getJSON<FetchLatestChangesApiResponse>(
  277. `/project/${projectId}/changes?since=${version}&paginated=true`
  278. )
  279. let changes, hasMore
  280. if (Array.isArray(response)) {
  281. // deprecated response format is a simple array of changes
  282. // TODO: Remove this branch after the transition
  283. changes = response
  284. hasMore = false
  285. } else {
  286. changes = response.changes
  287. hasMore = response.hasMore
  288. }
  289. return {
  290. changes: changes.map(Change.fromRaw).filter(change => change != null),
  291. hasMore,
  292. }
  293. }
  294. async function fetchBlob(
  295. projectId: string,
  296. hash: string,
  297. options?: { maxSize?: number }
  298. ): Promise<string> {
  299. const url = `/project/${projectId}/blob/${hash}`
  300. let fetchOpts
  301. if (options?.maxSize === 0) {
  302. return ''
  303. }
  304. if (options?.maxSize) {
  305. fetchOpts = {
  306. headers: {
  307. Range: `bytes=0-${options.maxSize - 1}`,
  308. },
  309. }
  310. }
  311. const res = await fetch(url, fetchOpts)
  312. if (!res.ok) {
  313. throw new FetchError('Failed to fetch blob', url, undefined, res)
  314. }
  315. return await res.text()
  316. }