@samitouri / QOSami-HFS / commits / 1208ddb9

plugins: when event handlers throw, won't break HFS and the id of the plugin will be included in the console error message

Massimo Melina committed Mar 14, 2026 at 16:03 UTC 1208ddb9f3c282748e9d0f18cc5f32b823fe1f9d
7 files changed +67 -18
frontend/src/App.ts
+1 -1
@@ -58,7 +58,7 @@ function installScript() {
58 const el = document.createElement('script')
59 el.type = 'text/javascript'
60 el.text = s
61 - el.id = 'customHtmlScript'
61 + el.setAttribute('plugin', el.id = '?customHtmlScript')
62 document.head.appendChild(el)
63 }
64
frontend/src/FilterBar.ts
+1 -1
@@ -15,7 +15,7 @@ export function FilterBar() {
15 const {t} = useI18N()
16
17 state.patternFilter = useDebounce(showFilter ? filter : '', 300)
18 - useEffect(() => onHfsEvent('entryToggleSelection', () => setAll(false)), [])
18 + useEffect(() => onHfsEvent('', 'entryToggleSelection', () => setAll(false)), [])
19
20 const tabIndex = showFilter ? undefined : -1
21 return h('div', { id: 'filter-bar', style: { display: showFilter ? undefined : 'none' } },
frontend/src/misc.ts
+25 -12
@@ -4,7 +4,7 @@ import React, { createElement as h } from 'react'
4 import { Btn, iconBtn, Spinner } from './components'
5 import { newDialog, toast } from './dialog'
6 import { Icon, IconProps } from './icons'
7 -import { Callback, Dict, domOn, getHFS, getOrSet, Html, HTTP_MESSAGES, urlParams, useBatch } from '@hfs/shared'
7 +import { Callback, Dict, domOn, getHFS, getOrSet, Html, HTTP_MESSAGES, prefix, urlParams, useBatch } from '@hfs/shared'
8 import * as cross from '../../src/cross'
9 import * as shared from '@hfs/shared'
10 import { apiCall, getNotifications, useApi } from '@hfs/shared/api'
@@ -70,7 +70,9 @@ export function hfsEvent(name: string, params?:Dict) {
70 })
71 }
72
73 -export function onHfsEvent(name: string, cb: (params:any, extra: { output: any[], setOrder: Callback<number>, preventDefault: Callback }) => any, options?: { once?: boolean }) {
73 +type HfsEventCallback = (params:any, extra: { output: any[], setOrder: Callback<number>, preventDefault: Callback }) => any
74 +export function onHfsEvent(pluginId: string, name: string, cb: HfsEventCallback, options?: { once?: boolean }) {
75 + if (!_.isFunction(cb)) return
76 const key = 'hfs.' + name
77 document.addEventListener(key, wrapper, options)
78 return () => document.removeEventListener(key, wrapper)
@@ -78,15 +80,25 @@ export function onHfsEvent(name: string, cb: (params:any, extra: { output: any[]
80 function wrapper(ev: Event) {
81 const { params, output, order } = (ev as CustomEvent).detail
82 let thisOrder
81 - const res = cb(params, {
82 - output,
83 - setOrder(x) { thisOrder = x },
84 - preventDefault: () => ev.preventDefault()
85 - })
86 - if (res !== undefined && Array.isArray(output)) {
87 - output.push(res)
88 - if (thisOrder)
89 - order[output.length - 1] = thisOrder
83 + try {
84 + const res = cb(params, {
85 + output,
86 + setOrder(x) { thisOrder = x },
87 + preventDefault: () => ev.preventDefault()
88 + })
89 + if (res === undefined) return
90 + if (Array.isArray(output)) {
91 + output.push(res instanceof Promise ? res.catch(printError) : res)
92 + if (thisOrder)
93 + order[output.length - 1] = thisOrder
94 + }
95 + }
96 + catch(e) {
97 + printError(e)
98 + }
99 +
100 + function printError(e: any) {
101 + console.error(`plugin ${pluginId} on event ${name}: ${e}`)
102 }
103 }
104 }
@@ -109,7 +121,8 @@ Object.assign(getHFS(), {
121 isShowSupported: getShowComponent,
122 misc: { ...cross, ...shared, ...thisModule },
123 emit: hfsEvent,
112 - onEvent: onHfsEvent,
124 + onEvent: (...args: Parameters<typeof onHfsEvent> extends [any, ...infer R] ? R : []) =>
125 + onHfsEvent(getHFS().getPluginKey(true) || '???', ...args),
126 watchState(k: string, cb: (v: any) => void, callNow=false) {
127 const up = k.split('upload.')[1]
128 const thisState = up ? uploadState : state as any
shared/index.ts
+10 -3
@@ -2,7 +2,10 @@
2
3 import _ from 'lodash'
4 import { apiCall } from './api'
5 -import { DAY, Dict, formatBytes, HOUR, MINUTE, objFromKeys, objSameKeys, typedEntries, wantArray } from '../src/cross'
5 +import {
6 + DAY, Dict, formatBytes, HOUR, MINUTE, objFromKeys, objSameKeys, typedEntries, wantArray, stringBefore,
7 + PLUGINS_PUB_URI
8 +} from '../src/cross'
9 export * from './react'
10 export * from './dialogs'
11 export * from './md'
@@ -27,7 +30,8 @@ export const urlParams = Object.fromEntries(new URLSearchParams(window.location.
30
31 const HFS = getHFS()
32 Object.assign(HFS, {
30 - getPluginKey: () => getScriptAttr('plugin'),
33 + getPluginKey: (quiet=false) =>
34 + getScriptAttr('plugin') ?? detectPluginId() ?? (quiet ? undefined : console.error("this function must be called synchronously during initial script evaluation")),
35 getPluginPublic: () => getScriptAttr('src')?.match(/^.*\//)?.[0],
36 getPluginConfig: () => HFS.plugins[HFS.getPluginKey()] || {},
37 loadScript: (uri: string) => loadScript(uri.includes('//') || uri.startsWith('/') ? uri : HFS.getPluginPublic() + uri),
@@ -38,6 +42,10 @@ Object.assign(HFS, {
42 })
43 formatBytes.k = HFS.kb
44
45 +function detectPluginId() {
46 + return stringBefore('/', Error().stack?.split(PLUGINS_PUB_URI)[1] || '') // generically search for the url – tested on recent versions of chrome, safari, firefox, edge
47 +}
48 +
49 export const IMAGE_FILEMASK = '*.jpg|*.jpeg|*.png|*.gif|*.svg'
50
51 //@ts-ignore
@@ -48,7 +56,6 @@ if (import.meta.env.PROD) {
56
57 function getScriptAttr(k: string) {
58 return document.currentScript?.getAttribute(k)
51 - || console.error("this function must be called at the very top of your file")
59 }
60
61 export function buildUrlQueryString(params: Dict) { // not using URLSearchParams.toString as it doesn't work on firefox50
src/cross.ts
+5
@@ -185,6 +185,11 @@ export function stringAfter(sub: string, all: string) {
185 return i < 0 ? '' : all.slice(i + sub.length)
186 }
187
188 +export function stringBefore(sub: string, all: string, returnEmptyWhenSubMissing=true) {
189 + const i = all.indexOf(sub)
190 + return i >= 0 ? all.slice(0, i + sub.length - 1) : returnEmptyWhenSubMissing ? '' : all
191 +}
192 +
193 export function truthy<T>(value: T): value is Truthy<T> {
194 return Boolean(value)
195 }
src/events.ts
+1
@@ -65,6 +65,7 @@ export class BetterEventEmitter {
65 const output: any[] = []
66 let prevented = false
67 const extra = {
68 + event,
69 output,
70 preventDefault() { prevented = true }
71 }
src/plugins.ts
+24 -1
@@ -123,14 +123,37 @@ export function getPluginConfigFields(id: string) {
123 return plugins.get(id)?.getData().config
124 }
125
126 -async function initPlugin(pl: any, morePassedToInit?: { id: string } & Dict<any>) {
126 +async function initPlugin(pl: any, morePassedToInit?: { id: string } & Dict) {
127 const undoEvents: any[] = []
128 const timeouts: NodeJS.Timeout[] = []
129 const controlledEvents = Object.create(events, objFromKeys(['on', 'once', 'multi'], k => ({
130 value() {
131 + if (k === 'multi')
132 + arguments[0] = objSameKeys(arguments[0], trap)
133 + else
134 + arguments[1] = trap(arguments[1])
135 const ret = (events[k] as any)(...arguments)
136 undoEvents.push(ret)
137 return ret
138 +
139 + function trap(cb: unknown) {
140 + return (...args: any[]) => {
141 + try {
142 + if (!_.isFunction(cb)) return
143 + const ret = cb(...args)
144 + return ret instanceof Promise ? ret.catch(printError) : ret
145 + }
146 + catch(e) {
147 + printError(e)
148 + }
149 +
150 + function printError(e: any) {
151 + const {event} = args.at(-1)
152 + console.error(`plugin ${morePassedToInit?.id || '?'} on event ${event}:`, e)
153 + }
154 + }
155 + }
156 +
157 }
158 })))
159 const res = await pl.init?.({