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?.({