first commit
This commit is contained in:
37
services/web/frontend/js/infrastructure/error-boundary.jsx
Normal file
37
services/web/frontend/js/infrastructure/error-boundary.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { captureException } from './error-reporter'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
|
||||
function errorHandler(error, componentStack) {
|
||||
captureException(error, {
|
||||
extra: {
|
||||
componentStack,
|
||||
},
|
||||
tags: {
|
||||
handler: 'react-error-boundary',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function DefaultFallbackComponent() {
|
||||
return <></>
|
||||
}
|
||||
|
||||
function withErrorBoundary(WrappedComponent, FallbackComponent) {
|
||||
function ErrorBoundaryWrapper(props) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={FallbackComponent || DefaultFallbackComponent}
|
||||
onError={errorHandler}
|
||||
>
|
||||
<WrappedComponent {...props} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
ErrorBoundaryWrapper.propTypes = WrappedComponent.propTypes
|
||||
ErrorBoundaryWrapper.displayName = `WithErrorBoundaryWrapper${
|
||||
WrappedComponent.displayName || WrappedComponent.name || 'Component'
|
||||
}`
|
||||
return ErrorBoundaryWrapper
|
||||
}
|
||||
|
||||
export default withErrorBoundary
|
128
services/web/frontend/js/infrastructure/error-reporter.ts
Normal file
128
services/web/frontend/js/infrastructure/error-reporter.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Conditionally enable Sentry based on whether the DSN token is set
|
||||
import getMeta from '../utils/meta'
|
||||
import OError from '@overleaf/o-error'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
const {
|
||||
sentryAllowedOriginRegex,
|
||||
sentryDsn,
|
||||
sentryEnvironment,
|
||||
sentryRelease,
|
||||
} = getMeta('ol-ExposedSettings')
|
||||
|
||||
const reporterPromise = sentryDsn ? sentryReporter() : nullReporter()
|
||||
|
||||
function sentryReporter() {
|
||||
return (
|
||||
import(/* webpackMode: "eager" */ '@sentry/browser')
|
||||
.then(Sentry => {
|
||||
let eventCount = 0
|
||||
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
environment: sentryEnvironment,
|
||||
release: sentryRelease,
|
||||
autoSessionTracking: false,
|
||||
|
||||
// Ignore errors unless they come from our origins
|
||||
// Adapted from: https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
|
||||
allowUrls: [new RegExp(sentryAllowedOriginRegex)],
|
||||
|
||||
ignoreErrors: [
|
||||
// Ignore very noisy error
|
||||
'SecurityError: Permission denied to access property "pathname" on cross-origin object',
|
||||
// Ignore unhandled error that is "expected" - see https://github.com/overleaf/issues/issues/3321
|
||||
/^Missing PDF/,
|
||||
// Ignore "expected" error from aborted fetch - see https://github.com/overleaf/issues/issues/3321
|
||||
/^AbortError/,
|
||||
// Ignore spurious error from Ace internals - see https://github.com/overleaf/issues/issues/3321
|
||||
'ResizeObserver loop limit exceeded',
|
||||
'ResizeObserver loop completed with undelivered notifications.',
|
||||
// Microsoft Outlook SafeLink crawler
|
||||
// https://forum.sentry.io/t/unhandledrejection-non-error-promise-rejection-captured-with-value/14062
|
||||
/Non-Error promise rejection captured with value: Object Not Found Matching Id/,
|
||||
// Ignore CM6 error until upgraded
|
||||
"Cannot read properties of undefined (reading 'length')",
|
||||
// Ignore Angular digest iteration limit - see https://github.com/overleaf/internal/issues/15750
|
||||
'10 $digest() iterations reached',
|
||||
// Ignore a frequent unhandled promise rejection
|
||||
/Non-Error promise rejection captured with keys: currentTarget, detail, isTrusted, target/,
|
||||
/Non-Error promise rejection captured with keys: message, status/,
|
||||
],
|
||||
|
||||
denyUrls: [
|
||||
// Chrome extensions
|
||||
/extensions\//i,
|
||||
/^chrome:\/\//i,
|
||||
],
|
||||
|
||||
beforeSend(event) {
|
||||
// Limit number of events sent to Sentry to 100 events "per page load",
|
||||
// (i.e. the cap will be reset if the page is reloaded). This prevent
|
||||
// hitting their server-side event cap.
|
||||
eventCount++
|
||||
if (eventCount > 100) {
|
||||
return null // Block the event from sending
|
||||
}
|
||||
|
||||
// Do not send events related to third party code (extensions)
|
||||
if (
|
||||
(event.extra?.arguments as { type: string }[] | undefined)?.[0]
|
||||
?.type === 'UNSTABLE_editor:extensions'
|
||||
) {
|
||||
return null // Block the event from sending
|
||||
}
|
||||
|
||||
return event
|
||||
},
|
||||
})
|
||||
|
||||
Sentry.setUser({ id: getMeta('ol-user_id') })
|
||||
|
||||
const splitTestAssignments = getMeta('ol-splitTestVariants')
|
||||
if (splitTestAssignments) {
|
||||
for (const [name, value] of Object.entries(splitTestAssignments)) {
|
||||
// Ensure Sentry tag name is within the 32-character limit
|
||||
Sentry.setTag(`ol.${name}`.slice(0, 32), value.toString())
|
||||
}
|
||||
}
|
||||
|
||||
return Sentry
|
||||
})
|
||||
// If Sentry fails to load, use the null reporter instead
|
||||
.catch(error => {
|
||||
debugConsole.error(error)
|
||||
return nullReporter()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function nullReporter() {
|
||||
return Promise.resolve({
|
||||
captureException: debugConsole.error,
|
||||
captureMessage: debugConsole.error,
|
||||
})
|
||||
}
|
||||
|
||||
// https://develop.sentry.dev/sdk/data-model/event-payloads/contexts/
|
||||
// https://docs.sentry.io/platforms/javascript/enriching-events/context/#passing-context-directly
|
||||
type Options = {
|
||||
tags?: Record<string, any>
|
||||
extra?: Record<string, any>
|
||||
}
|
||||
|
||||
export function captureException(err: Error, options?: Options) {
|
||||
options = options || {}
|
||||
const extra = Object.assign(OError.getFullInfo(err), options.extra || {})
|
||||
const fullStack = OError.getFullStack(err)
|
||||
if (err.stack !== fullStack) {
|
||||
// Attach tracebacks from OError.tag() and OError.cause.
|
||||
extra.fullStack = fullStack
|
||||
}
|
||||
reporterPromise.then(reporter =>
|
||||
reporter.captureException(err, {
|
||||
...options,
|
||||
extra,
|
||||
})
|
||||
)
|
||||
}
|
109
services/web/frontend/js/infrastructure/event-tracking.ts
Normal file
109
services/web/frontend/js/infrastructure/event-tracking.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import sessionStorage from './session-storage'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
type Segmentation = Record<
|
||||
string,
|
||||
string | number | boolean | undefined | unknown | any // TODO: RecurlyError
|
||||
>
|
||||
|
||||
const CACHE_KEY = 'mbEvents'
|
||||
|
||||
function alreadySent(key: string) {
|
||||
const eventCache = sessionStorage.getItem(CACHE_KEY) || {}
|
||||
return !!eventCache[key]
|
||||
}
|
||||
function markAsSent(key: string) {
|
||||
const eventCache = sessionStorage.getItem(CACHE_KEY) || {}
|
||||
eventCache[key] = true
|
||||
sessionStorage.setItem(CACHE_KEY, eventCache)
|
||||
}
|
||||
|
||||
export function send(
|
||||
category: string,
|
||||
action: string,
|
||||
label?: string,
|
||||
value?: string
|
||||
) {
|
||||
if (typeof window.ga === 'function') {
|
||||
window.ga('send', 'event', category, action, label, value)
|
||||
}
|
||||
}
|
||||
|
||||
export function sendOnce(
|
||||
category: string,
|
||||
action: string,
|
||||
label: string,
|
||||
value: string
|
||||
) {
|
||||
if (alreadySent(category)) return
|
||||
if (typeof window.ga !== 'function') return
|
||||
|
||||
window.ga('send', 'event', category, action, label, value)
|
||||
markAsSent(category)
|
||||
}
|
||||
|
||||
export function sendMB(key: string, segmentation: Segmentation = {}) {
|
||||
if (!segmentation.page) {
|
||||
segmentation.page = window.location.pathname
|
||||
}
|
||||
|
||||
if (getMeta('ol-customerIoEnabled')) {
|
||||
segmentation['customerio-integration'] = 'enabled'
|
||||
}
|
||||
|
||||
sendBeacon(key, segmentation)
|
||||
|
||||
if (typeof window.gtag !== 'function') return
|
||||
if (['paywall-click', 'paywall-prompt', 'plans-page-click'].includes(key)) {
|
||||
window.gtag('event', key, segmentation)
|
||||
}
|
||||
}
|
||||
|
||||
export function sendMBOnce(key: string, segmentation: Segmentation = {}) {
|
||||
if (alreadySent(key)) return
|
||||
sendMB(key, segmentation)
|
||||
markAsSent(key)
|
||||
}
|
||||
|
||||
export function sendMBSampled(
|
||||
key: string,
|
||||
segmentation: Segmentation = {},
|
||||
rate = 0.01
|
||||
) {
|
||||
if (Math.random() < rate) {
|
||||
sendMB(key, segmentation)
|
||||
}
|
||||
}
|
||||
|
||||
const sentOncePerPageLoad = new Set()
|
||||
|
||||
export function sendMBOncePerPageLoad(
|
||||
key: string,
|
||||
segmentation: Segmentation = {}
|
||||
) {
|
||||
if (sentOncePerPageLoad.has(key)) return
|
||||
sendMB(key, segmentation)
|
||||
sentOncePerPageLoad.add(key)
|
||||
}
|
||||
|
||||
// Use breakpoint @screen-xs-max from less:
|
||||
// @screen-xs-max: (@screen-sm-min - 1);
|
||||
// @screen-sm-min: @screen-sm;
|
||||
// @screen-sm: 768px;
|
||||
export const isSmallDevice = window.screen.width < 768
|
||||
|
||||
function sendBeacon(key: string, data: Segmentation) {
|
||||
if (!navigator || !navigator.sendBeacon) return
|
||||
if (!getMeta('ol-ExposedSettings').isOverleaf) return
|
||||
|
||||
data._csrf = getMeta('ol-csrfToken')
|
||||
const blob = new Blob([JSON.stringify(data)], {
|
||||
type: 'application/json; charset=UTF-8',
|
||||
})
|
||||
try {
|
||||
navigator.sendBeacon(`/event/${key}`, blob)
|
||||
} catch (error) {
|
||||
// Ignored. There's a range of browser for which `navigator.sendBeacon` is available but
|
||||
// will throw an error if it's called with an unacceptable mime-typed Blob as the data.
|
||||
}
|
||||
}
|
253
services/web/frontend/js/infrastructure/fetch-json.ts
Normal file
253
services/web/frontend/js/infrastructure/fetch-json.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// fetch wrapper to make simple JSON requests:
|
||||
// - send the CSRF token in the request
|
||||
// - set the JSON content-type in the request headers
|
||||
// - throw errors on non-ok response
|
||||
// - parse JSON response body, unless response is empty
|
||||
import OError from '@overleaf/o-error'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
type FetchPath = string
|
||||
// Custom config types are merged with `fetch`s RequestInit type
|
||||
type FetchConfig = {
|
||||
swallowAbortError?: boolean
|
||||
body?: Record<string, unknown>
|
||||
} & Omit<RequestInit, 'body'>
|
||||
|
||||
export function getJSON<T = any>(path: FetchPath, options?: FetchConfig) {
|
||||
return fetchJSON<T>(path, { ...options, method: 'GET' })
|
||||
}
|
||||
|
||||
export function postJSON<T = any>(path: FetchPath, options?: FetchConfig) {
|
||||
return fetchJSON<T>(path, { ...options, method: 'POST' })
|
||||
}
|
||||
|
||||
export function putJSON<T = any>(path: FetchPath, options?: FetchConfig) {
|
||||
return fetchJSON<T>(path, { ...options, method: 'PUT' })
|
||||
}
|
||||
|
||||
export function deleteJSON<T = any>(path: FetchPath, options?: FetchConfig) {
|
||||
return fetchJSON<T>(path, { ...options, method: 'DELETE' })
|
||||
}
|
||||
|
||||
function getErrorMessageForStatusCode(statusCode?: number) {
|
||||
if (!statusCode) {
|
||||
return 'Unknown Error'
|
||||
}
|
||||
|
||||
const statusCodes: { readonly [K: number]: string } = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
429: 'Too Many Requests',
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
}
|
||||
|
||||
return statusCodes[statusCode] ?? `Unexpected Error: ${statusCode}`
|
||||
}
|
||||
|
||||
export class FetchError extends OError {
|
||||
public url: string
|
||||
public options?: RequestInit
|
||||
public response?: Response
|
||||
public data?: any
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
response?: Response,
|
||||
data?: any
|
||||
) {
|
||||
// On HTTP2, the `statusText` property is not set,
|
||||
// so this `message` will be undefined. We need to
|
||||
// set a message based on the response `status`, so
|
||||
// our error UI rendering will work
|
||||
if (!message) {
|
||||
message = getErrorMessageForStatusCode(response?.status)
|
||||
}
|
||||
|
||||
super(message, { statusCode: response ? response.status : undefined })
|
||||
|
||||
this.url = url
|
||||
this.options = options
|
||||
this.response = response
|
||||
this.data = data
|
||||
}
|
||||
|
||||
getErrorMessageKey() {
|
||||
return this.data?.message?.key as string | undefined
|
||||
}
|
||||
|
||||
getUserFacingMessage() {
|
||||
const statusCode = this.response?.status
|
||||
const defaultMessage = getErrorMessageForStatusCode(statusCode)
|
||||
const message = (this.data?.message?.text || this.data?.message) as
|
||||
| string
|
||||
| undefined
|
||||
if (message && message !== defaultMessage) return message
|
||||
|
||||
const statusCodes: { readonly [K: number]: string } = {
|
||||
400: 'Invalid Request. Please correct the data and try again.',
|
||||
403: 'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.',
|
||||
429: 'Too many attempts. Please wait for a while and try again.',
|
||||
}
|
||||
|
||||
return statusCode && statusCodes[statusCode]
|
||||
? statusCodes[statusCode]
|
||||
: 'Something went wrong. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJSON<T>(
|
||||
path: FetchPath,
|
||||
{
|
||||
body,
|
||||
headers = {},
|
||||
method = 'GET',
|
||||
credentials = 'same-origin',
|
||||
swallowAbortError = true,
|
||||
...otherOptions
|
||||
}: FetchConfig
|
||||
) {
|
||||
const options: RequestInit = {
|
||||
...otherOptions,
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Csrf-Token': getMeta('ol-csrfToken'),
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials,
|
||||
method,
|
||||
}
|
||||
|
||||
if (body !== undefined) {
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
// The returned Promise and the `.then(handleSuccess, handleError)` handlers are needed
|
||||
// to avoid calling `finally` in a Promise chain (and thus updating the component's state)
|
||||
// after a component has unmounted.
|
||||
// `resolve` will be called when the request succeeds, `reject` will be called when the request fails,
|
||||
// but nothing will be called if the request is cancelled via an AbortController.
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
fetch(path, options).then(
|
||||
response => {
|
||||
return parseResponseBody(response).then(
|
||||
data => {
|
||||
if (response.ok) {
|
||||
resolve(data)
|
||||
} else {
|
||||
// the response from the server was not 2xx
|
||||
reject(
|
||||
new FetchError(
|
||||
response.statusText,
|
||||
path,
|
||||
options,
|
||||
response,
|
||||
data
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
error => {
|
||||
// swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
|
||||
if (swallowAbortError && error.name === 'AbortError') {
|
||||
// the fetch request was aborted while reading/parsing the response body
|
||||
return
|
||||
}
|
||||
// parsing the response body failed
|
||||
reject(
|
||||
new FetchError(
|
||||
'There was an error parsing the response body',
|
||||
path,
|
||||
options,
|
||||
response
|
||||
).withCause(error)
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
error => {
|
||||
// swallow the error if the fetch was cancelled (e.g. by cancelling an AbortController on component unmount)
|
||||
if (swallowAbortError && error.name === 'AbortError') {
|
||||
// the fetch request was aborted before a response was returned
|
||||
return
|
||||
}
|
||||
// the fetch failed
|
||||
reject(
|
||||
new FetchError(
|
||||
'There was an error fetching the JSON',
|
||||
path,
|
||||
options
|
||||
).withCause(error)
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
|
||||
if (!contentType) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (/application\/json/.test(contentType)) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
if (/text\/plain/.test(contentType)) {
|
||||
const message = await response.text()
|
||||
|
||||
return { message }
|
||||
}
|
||||
|
||||
if (/text\/html/.test(contentType)) {
|
||||
const message = await response.text()
|
||||
|
||||
// only use HTML responses which don't start with `<`
|
||||
if (!/^\s*</.test(message)) {
|
||||
return { message }
|
||||
}
|
||||
}
|
||||
|
||||
// response body ignored as content-type is either not set (e.g. 204
|
||||
// responses) or unsupported
|
||||
return {}
|
||||
}
|
||||
|
||||
export function getErrorMessageKey(error: Error | null) {
|
||||
if (!error) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
return error.getErrorMessageKey()
|
||||
}
|
||||
|
||||
return error.message
|
||||
}
|
||||
|
||||
export function getUserFacingMessage(error: Error | null) {
|
||||
if (!error) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
return error.getUserFacingMessage()
|
||||
}
|
||||
|
||||
return error.message
|
||||
}
|
||||
|
||||
export function isRateLimited(error?: Error | FetchError | any) {
|
||||
if (error && error instanceof FetchError) {
|
||||
return error.response?.status === 429
|
||||
}
|
||||
return false
|
||||
}
|
15
services/web/frontend/js/infrastructure/hotjar-snippet.js
Normal file
15
services/web/frontend/js/infrastructure/hotjar-snippet.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// tracking code from https://help.hotjar.com/hc/en-us/articles/115009336727-How-to-Install-Your-Hotjar-Tracking-Code
|
||||
export const initializeHotjar = (hotjarId, hotjarVersion) =>
|
||||
(function (h, o, t, j, a, r) {
|
||||
h.hj =
|
||||
h.hj ||
|
||||
function () {
|
||||
;(h.hj.q = h.hj.q || []).push(arguments)
|
||||
}
|
||||
h._hjSettings = { hjid: hotjarId, hjsv: hotjarVersion }
|
||||
a = o.getElementsByTagName('head')[0]
|
||||
r = o.createElement('script')
|
||||
r.async = 1
|
||||
r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv
|
||||
a.appendChild(r)
|
||||
})(window, document, 'https://static.hotjar.com/c/hotjar-', '.js?sv=')
|
43
services/web/frontend/js/infrastructure/hotjar.ts
Normal file
43
services/web/frontend/js/infrastructure/hotjar.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import getMeta from '@/utils/meta'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { initializeHotjar } from '@/infrastructure/hotjar-snippet'
|
||||
|
||||
const { hotjarId, hotjarVersion } = getMeta('ol-ExposedSettings')
|
||||
const shouldLoadHotjar = getMeta('ol-shouldLoadHotjar')
|
||||
|
||||
let hotjarInitialized = false
|
||||
|
||||
if (hotjarId && hotjarVersion && shouldLoadHotjar) {
|
||||
const loadHotjar = () => {
|
||||
// consent needed
|
||||
if (!document.cookie.split('; ').some(item => item === 'oa=1')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(hotjarId) || !/^\d+$/.test(hotjarVersion)) {
|
||||
debugConsole.error('Invalid Hotjar id or version')
|
||||
return
|
||||
}
|
||||
|
||||
// avoid inserting twice
|
||||
if (!hotjarInitialized) {
|
||||
debugConsole.log('Loading Hotjar')
|
||||
hotjarInitialized = true
|
||||
initializeHotjar(hotjarId, hotjarVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// load when idle, if supported
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
window.requestIdleCallback(loadHotjar)
|
||||
} else {
|
||||
loadHotjar()
|
||||
}
|
||||
|
||||
// listen for consent
|
||||
window.addEventListener('cookie-consent', event => {
|
||||
if ((event as CustomEvent<boolean>).detail) {
|
||||
loadHotjar()
|
||||
}
|
||||
})
|
||||
}
|
52
services/web/frontend/js/infrastructure/local-storage.ts
Normal file
52
services/web/frontend/js/infrastructure/local-storage.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* localStorage can throw browser exceptions, for example if it is full We don't
|
||||
* use localStorage for anything critical, so in that case just fail gracefully.
|
||||
*/
|
||||
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
/**
|
||||
* Catch, log and otherwise ignore errors.
|
||||
*
|
||||
* @param {function} fn localStorage function to call
|
||||
* @param {string?} key Key passed to the localStorage function (if any)
|
||||
* @param {any?} value Value passed to the localStorage function (if any)
|
||||
*/
|
||||
const callSafe = function (
|
||||
fn: (...args: any) => any,
|
||||
key?: string,
|
||||
value?: any
|
||||
) {
|
||||
try {
|
||||
return fn(key, value)
|
||||
} catch (e) {
|
||||
debugConsole.error('localStorage exception', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getItem = function (key: string) {
|
||||
const value = localStorage.getItem(key)
|
||||
return value === null ? null : JSON.parse(value)
|
||||
}
|
||||
|
||||
const setItem = function (key: string, value: any) {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
const clear = function () {
|
||||
localStorage.clear()
|
||||
}
|
||||
|
||||
const removeItem = function (key: string) {
|
||||
return localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
const customLocalStorage = {
|
||||
getItem: (key: string) => callSafe(getItem, key),
|
||||
setItem: (key: string, value: any) => callSafe(setItem, key, value),
|
||||
clear: () => callSafe(clear),
|
||||
removeItem: (key: string) => callSafe(removeItem, key),
|
||||
}
|
||||
|
||||
export default customLocalStorage
|
201
services/web/frontend/js/infrastructure/project-snapshot.ts
Normal file
201
services/web/frontend/js/infrastructure/project-snapshot.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import pLimit from 'p-limit'
|
||||
import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
|
||||
import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
|
||||
import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
|
||||
|
||||
const DOWNLOAD_BLOBS_CONCURRENCY = 10
|
||||
|
||||
/**
|
||||
* Project snapshot container with on-demand refresh
|
||||
*/
|
||||
export class ProjectSnapshot {
|
||||
private projectId: string
|
||||
private snapshot: Snapshot
|
||||
private version: number
|
||||
private blobStore: SimpleBlobStore
|
||||
private refreshPromise: Promise<void>
|
||||
private initialized: boolean
|
||||
private refreshing: boolean
|
||||
private queued: boolean
|
||||
|
||||
constructor(projectId: string) {
|
||||
this.projectId = projectId
|
||||
this.snapshot = new Snapshot()
|
||||
this.version = 0
|
||||
this.refreshPromise = Promise.resolve()
|
||||
this.initialized = false
|
||||
this.refreshing = false
|
||||
this.queued = false
|
||||
this.blobStore = new SimpleBlobStore(this.projectId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a refresh of the snapshot.
|
||||
*
|
||||
* When the returned promise resolves, the snapshot is guaranteed to have been
|
||||
* updated at least to the version of the document that was current when the
|
||||
* function was called.
|
||||
*/
|
||||
async refresh() {
|
||||
if (this.queued) {
|
||||
// There already is a queued refresh that will run after this call.
|
||||
// Just wait for it to complete.
|
||||
await this.refreshPromise
|
||||
} else if (this.refreshing) {
|
||||
// There is a refresh running, but no queued refresh. Queue a refresh
|
||||
// after this one and make it the new promise to wait for.
|
||||
this.refreshPromise = this.queueRefresh()
|
||||
await this.refreshPromise
|
||||
} else {
|
||||
// There is no refresh running. Start one.
|
||||
this.refreshPromise = this.startRefresh()
|
||||
await this.refreshPromise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of paths to editable docs.
|
||||
*/
|
||||
getDocPaths(): string[] {
|
||||
const allPaths = this.snapshot.getFilePathnames()
|
||||
return allPaths.filter(path => this.snapshot.getFile(path)?.isEditable())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the doc content at the given path.
|
||||
*/
|
||||
getDocContents(path: string): string | null {
|
||||
const file = this.snapshot.getFile(path)
|
||||
if (file == null) {
|
||||
return null
|
||||
}
|
||||
return file.getContent({ filterTrackedDeletes: true }) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately start a refresh
|
||||
*/
|
||||
private async startRefresh() {
|
||||
this.refreshing = true
|
||||
try {
|
||||
if (!this.initialized) {
|
||||
await this.initialize()
|
||||
} else {
|
||||
await this.loadChanges()
|
||||
}
|
||||
} finally {
|
||||
this.refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a refresh after the currently running refresh
|
||||
*/
|
||||
private async queueRefresh() {
|
||||
this.queued = true
|
||||
try {
|
||||
await this.refreshPromise
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
this.queued = false
|
||||
await this.startRefresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the snapshot using the project's latest chunk.
|
||||
*
|
||||
* This is run on the first refresh.
|
||||
*/
|
||||
private async initialize() {
|
||||
await flushHistory(this.projectId)
|
||||
const chunk = await fetchLatestChunk(this.projectId)
|
||||
this.snapshot = chunk.getSnapshot()
|
||||
this.snapshot.applyAll(chunk.getChanges())
|
||||
this.version = chunk.getEndVersion()
|
||||
await this.loadDocs()
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply changes since the last refresh.
|
||||
*
|
||||
* This is run on the second and subsequent refreshes
|
||||
*/
|
||||
private async loadChanges() {
|
||||
await flushHistory(this.projectId)
|
||||
const changes = await fetchLatestChanges(this.projectId, this.version)
|
||||
this.snapshot.applyAll(changes)
|
||||
this.version += changes.length
|
||||
await this.loadDocs()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all editable docs in the snapshot.
|
||||
*
|
||||
* This is done by converting any lazy file data into an "eager" file data. If
|
||||
* a doc is already loaded, the load is a no-op.
|
||||
*/
|
||||
private async loadDocs() {
|
||||
const paths = this.getDocPaths()
|
||||
const limit = pLimit(DOWNLOAD_BLOBS_CONCURRENCY)
|
||||
await Promise.all(
|
||||
paths.map(path =>
|
||||
limit(async () => {
|
||||
const file = this.snapshot.getFile(path)
|
||||
await file?.load('eager', this.blobStore)
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob store that fetches blobs from the history service
|
||||
*/
|
||||
class SimpleBlobStore {
|
||||
private projectId: string
|
||||
|
||||
constructor(projectId: string) {
|
||||
this.projectId = projectId
|
||||
}
|
||||
|
||||
async getString(hash: string): Promise<string> {
|
||||
return await fetchBlob(this.projectId, hash)
|
||||
}
|
||||
|
||||
async getObject(hash: string) {
|
||||
const blob = await this.getString(hash)
|
||||
return JSON.parse(blob)
|
||||
}
|
||||
}
|
||||
|
||||
async function flushHistory(projectId: string) {
|
||||
await postJSON(`/project/${projectId}/flush`)
|
||||
}
|
||||
|
||||
async function fetchLatestChunk(projectId: string): Promise<Chunk> {
|
||||
const response = await getJSON<{ chunk: RawChunk }>(
|
||||
`/project/${projectId}/latest/history`
|
||||
)
|
||||
return Chunk.fromRaw(response.chunk)
|
||||
}
|
||||
|
||||
async function fetchLatestChanges(
|
||||
projectId: string,
|
||||
version: number
|
||||
): Promise<Change[]> {
|
||||
const response = await getJSON<RawChange[]>(
|
||||
`/project/${projectId}/changes?since=${version}`
|
||||
)
|
||||
return response.map(Change.fromRaw).filter(change => change != null)
|
||||
}
|
||||
|
||||
async function fetchBlob(projectId: string, hash: string): Promise<string> {
|
||||
const url = `/project/${projectId}/blob/${hash}`
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new FetchError('Failed to fetch blob', url, undefined, res)
|
||||
}
|
||||
return await res.text()
|
||||
}
|
13
services/web/frontend/js/infrastructure/promise.ts
Normal file
13
services/web/frontend/js/infrastructure/promise.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* run `fn` in series for all values, and resolve with an array of the results
|
||||
*/
|
||||
export const mapSeries = async <T = any, V = any>(
|
||||
values: T[],
|
||||
fn: (item: T) => Promise<V>
|
||||
) => {
|
||||
const output: V[] = []
|
||||
for (const value of values) {
|
||||
output.push(await fn(value))
|
||||
}
|
||||
return output
|
||||
}
|
52
services/web/frontend/js/infrastructure/session-storage.ts
Normal file
52
services/web/frontend/js/infrastructure/session-storage.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* sessionStorage can throw browser exceptions, for example if it is full.
|
||||
* We don't use sessionStorage for anything critical, so in that case just fail gracefully.
|
||||
*/
|
||||
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
/**
|
||||
* Catch, log and otherwise ignore errors.
|
||||
*
|
||||
* @param {function} fn sessionStorage function to call
|
||||
* @param {string?} key Key passed to the sessionStorage function (if any)
|
||||
* @param {any?} value Value passed to the sessionStorage function (if any)
|
||||
*/
|
||||
const callSafe = function (
|
||||
fn: (...args: any) => any,
|
||||
key?: string,
|
||||
value?: any
|
||||
) {
|
||||
try {
|
||||
return fn(key, value)
|
||||
} catch (e) {
|
||||
debugConsole.error('sessionStorage exception', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getItem = function (key: string) {
|
||||
const value = sessionStorage.getItem(key)
|
||||
return value === null ? null : JSON.parse(value)
|
||||
}
|
||||
|
||||
const setItem = function (key: string, value: any) {
|
||||
sessionStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
const clear = function () {
|
||||
sessionStorage.clear()
|
||||
}
|
||||
|
||||
const removeItem = function (key: string) {
|
||||
return sessionStorage.removeItem(key)
|
||||
}
|
||||
|
||||
const customSessionStorage = {
|
||||
getItem: (key: string) => callSafe(getItem, key),
|
||||
setItem: (key: string, value: any) => callSafe(setItem, key, value),
|
||||
clear: () => callSafe(clear),
|
||||
removeItem: (key: string) => callSafe(removeItem, key),
|
||||
}
|
||||
|
||||
export default customSessionStorage
|
Reference in New Issue
Block a user