project-snapshot.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. import pLimit from 'p-limit'
  2. import { Change, Chunk, Snapshot } 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 doc content at the given path.
  60. */
  61. getDocContents(path: string): string | null {
  62. const file = this.snapshot.getFile(path)
  63. if (file == null) {
  64. return null
  65. }
  66. return file.getContent({ filterTrackedDeletes: true }) ?? null
  67. }
  68. /**
  69. * Immediately start a refresh
  70. */
  71. private async startRefresh() {
  72. this.refreshing = true
  73. try {
  74. if (!this.initialized) {
  75. await this.initialize()
  76. } else {
  77. await this.loadChanges()
  78. }
  79. } finally {
  80. this.refreshing = false
  81. }
  82. }
  83. /**
  84. * Queue a refresh after the currently running refresh
  85. */
  86. private async queueRefresh() {
  87. this.queued = true
  88. try {
  89. await this.refreshPromise
  90. } catch {
  91. // Ignore errors
  92. }
  93. this.queued = false
  94. await this.startRefresh()
  95. }
  96. /**
  97. * Initialize the snapshot using the project's latest chunk.
  98. *
  99. * This is run on the first refresh.
  100. */
  101. private async initialize() {
  102. await flushHistory(this.projectId)
  103. const chunk = await fetchLatestChunk(this.projectId)
  104. this.snapshot = chunk.getSnapshot()
  105. this.snapshot.applyAll(chunk.getChanges())
  106. this.version = chunk.getEndVersion()
  107. await this.loadDocs()
  108. this.initialized = true
  109. }
  110. /**
  111. * Apply changes since the last refresh.
  112. *
  113. * This is run on the second and subsequent refreshes
  114. */
  115. private async loadChanges() {
  116. await flushHistory(this.projectId)
  117. const changes = await fetchLatestChanges(this.projectId, this.version)
  118. this.snapshot.applyAll(changes)
  119. this.version += changes.length
  120. await this.loadDocs()
  121. }
  122. /**
  123. * Load all editable docs in the snapshot.
  124. *
  125. * This is done by converting any lazy file data into an "eager" file data. If
  126. * a doc is already loaded, the load is a no-op.
  127. */
  128. private async loadDocs() {
  129. const paths = this.getDocPaths()
  130. const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
  131. await Promise.all(
  132. paths.map(path =>
  133. limit(async () => {
  134. const file = this.snapshot.getFile(path)
  135. await file?.load('eager', this.blobStore)
  136. })
  137. )
  138. )
  139. }
  140. }
  141. /**
  142. * Blob store that fetches blobs from the history service
  143. */
  144. class SimpleBlobStore {
  145. private projectId: string
  146. constructor(projectId: string) {
  147. this.projectId = projectId
  148. }
  149. async getString(hash: string): Promise<string> {
  150. return await fetchBlob(this.projectId, hash)
  151. }
  152. async getObject(hash: string) {
  153. const blob = await this.getString(hash)
  154. return JSON.parse(blob)
  155. }
  156. }
  157. async function flushHistory(projectId: string) {
  158. await postJSON(`/project/${projectId}/flush`)
  159. }
  160. async function fetchLatestChunk(projectId: string): Promise<Chunk> {
  161. const response = await getJSON<{ chunk: RawChunk }>(
  162. `/project/${projectId}/latest/history`
  163. )
  164. return Chunk.fromRaw(response.chunk)
  165. }
  166. async function fetchLatestChanges(
  167. projectId: string,
  168. version: number
  169. ): Promise<Change[]> {
  170. const response = await getJSON<RawChange[]>(
  171. `/project/${projectId}/changes?since=${version}`
  172. )
  173. return response.map(Change.fromRaw).filter(change => change != null)
  174. }
  175. async function fetchBlob(projectId: string, hash: string): Promise<string> {
  176. const url = `/project/${projectId}/blob/${hash}`
  177. const res = await fetch(url)
  178. if (!res.ok) {
  179. throw new FetchError('Failed to fetch blob', url, undefined, res)
  180. }
  181. return await res.text()
  182. }