Files
overleaf-cep/services/web/app/src/infrastructure/Modules.mjs
Anna Claire Fields 6113c6c291 Enable TS noImplicitAny in web (#31636)
GitOrigin-RevId: 18881694770f2476c475f8fef4c6a2678a2a12fe
2026-03-27 09:05:30 +00:00

273 lines
6.5 KiB
JavaScript

// @ts-check
import fs from 'node:fs'
import Path from 'node:path'
import { promisify, callbackify } from 'node:util'
import Settings from '@overleaf/settings'
import Views from './Views.mjs'
import _ from 'lodash'
import Metrics from '@overleaf/metrics'
/** @import { WebModule } from "../../../types/web-module" */
/** @import { RequestHandler } from "express" */
const MODULE_BASE_PATH = Path.join(import.meta.dirname, '/../../../modules')
/** @type {WebModule[]} */
const _modules = []
let _modulesLoaded = false
/** @type {Record<string, any>} */
const _hooks = {}
/** @type {Record<string, RequestHandler[]>} */
const _middleware = {}
/** @type {Record<string, any>} */
let _viewIncludes = {}
async function modules() {
if (!_modulesLoaded) {
const beforeLoadModules = performance.now()
await loadModules()
Metrics.gauge('web_startup', performance.now() - beforeLoadModules, 1, {
path: 'loadModules',
})
}
return _modules
}
async function loadModulesImpl() {
const settingsCheckModule = Path.join(
MODULE_BASE_PATH,
'settings-check',
'index.mjs'
)
if (fs.existsSync(settingsCheckModule)) {
await import(settingsCheckModule)
}
for (const moduleName of Settings.moduleImportSequence || []) {
const module = await import(
Path.join(MODULE_BASE_PATH, moduleName, 'index.mjs')
)
/** @type {WebModule & {name: string}} */
const loadedModule = module.default || module
loadedModule.name = moduleName
_modules.push(loadedModule)
if (loadedModule.viewIncludes) {
throw new Error(
`${moduleName}: module.viewIncludes moved into Settings.viewIncludes`
)
}
if (loadedModule.dependencies) {
for (const dependency of loadedModule.dependencies) {
if (!Settings.moduleImportSequence.includes(dependency)) {
throw new Error(
`Module '${dependency}' listed as a dependency of '${moduleName}' is missing in the moduleImportSequence. Please also verify that it is available in the current environment.`
)
}
}
}
}
_modulesLoaded = true
await attachHooks()
await attachMiddleware()
}
const loadModules = _.memoize(loadModulesImpl)
/**
* @param {any} webRouter
* @param {any} privateApiRouter
* @param {any} publicApiRouter
*/
async function applyRouter(webRouter, privateApiRouter, publicApiRouter) {
for (const module of await modules()) {
if (module.router && module.router.apply) {
await module.router.apply(webRouter, privateApiRouter, publicApiRouter)
}
}
}
/**
* @param {any} webRouter
* @param {any} privateApiRouter
* @param {any} publicApiRouter
*/
async function applyNonCsrfRouter(
webRouter,
privateApiRouter,
publicApiRouter
) {
for (const module of await modules()) {
if (module.nonCsrfRouter != null) {
module.nonCsrfRouter.apply(webRouter, privateApiRouter, publicApiRouter)
}
if (module.router && module.router.applyNonCsrfRouter) {
module.router.applyNonCsrfRouter(
webRouter,
privateApiRouter,
publicApiRouter
)
}
}
}
async function start() {
for (const module of await modules()) {
await module.start?.()
}
}
/**
* @param {any} app
*/
function loadViewIncludes(app) {
_viewIncludes = Views.compileViewIncludes(app)
}
/**
* @param {any} appOrRouter
* @param {any} middlewareName
* @param {any} [options]
*/
async function applyMiddleware(appOrRouter, middlewareName, options) {
if (!middlewareName) {
throw new Error(
'middleware name must be provided to register module middleware'
)
}
for (const module of await modules()) {
/** @type {Record<string, any>} */
const typedModule = module
if (typedModule[middlewareName]) {
typedModule[middlewareName](appOrRouter, options)
}
}
}
/**
* @param {any} view
* @param {any} locals
*/
function moduleIncludes(view, locals) {
const compiledPartials = _viewIncludes[view] || []
let html = ''
for (const /** @type {any} */ compiledPartial of compiledPartials) {
html += compiledPartial(locals)
}
return html
}
/**
* @param {any} view
*/
function moduleIncludesAvailable(view) {
return (_viewIncludes[view] || []).length > 0
}
async function linkedFileAgentsIncludes() {
/** @type {Record<string, any>} */
const agents = {}
for (const module of await modules()) {
for (const name in module.linkedFileAgents) {
const agentFunction = /** @type {Record<string, any>} */ (
module.linkedFileAgents
)[name]
agents[name] = agentFunction()
}
}
return agents
}
async function attachHooks() {
for (const module of await modules()) {
const { promises, ...hooks } = module.hooks || {}
for (const [hook, method] of Object.entries(promises || {})) {
attachHook(hook, method)
}
for (const hook in hooks || {}) {
const method = /** @type {Record<string, any>} */ (hooks)[hook]
attachHook(hook, promisify(method))
}
}
}
/**
* @param {any} name
* @param {any} method
*/
function attachHook(name, method) {
if (_hooks[name] == null) {
_hooks[name] = []
}
_hooks[name].push(method)
}
async function attachMiddleware() {
for (const module of await modules()) {
if (module.middleware) {
for (const middleware in module.middleware) {
const method = /** @type {Record<string, any>} */ (module.middleware)[
middleware
]
if (_middleware[middleware] == null) {
_middleware[middleware] = []
}
_middleware[middleware].push(method)
}
}
}
}
/**
* @param {any} name
* @param {...any} args
*/
async function fireHook(name, ...args) {
// ensure that modules are loaded if we need to fire a hook
// this can happen if a script calls a method that fires a hook
if (!_modulesLoaded) {
await loadModules()
}
const methods = _hooks[name] || []
const results = []
for (const method of methods) {
const result = await method(...args)
results.push(result)
}
return results
}
/**
* @param {string} name
*/
async function getMiddleware(name) {
// ensure that modules are loaded if we need to call a middleware
if (!_modulesLoaded) {
await loadModules()
}
return _middleware[name] || []
}
export default {
applyNonCsrfRouter,
applyRouter,
linkedFileAgentsIncludes,
loadViewIncludes,
moduleIncludes,
moduleIncludesAvailable,
applyMiddleware,
start,
hooks: {
attach: attachHook,
fire: callbackify(fireHook),
},
middleware: getMiddleware,
promises: {
hooks: {
fire: fireHook,
},
},
}