new plugins' API
Massimo Melina committed
Jan 11, 2022 at 21:13 UTC
b70f842a457caf6c1c0fa167e8672986eef827f6
9 files changed
+77
-72
README.md
+11
-6
@@ -118,16 +118,21 @@ but nothing is preventing a single plug-in from doing both tasks.
118
119
## For plug-in makers
120
121
-What a plug-in does is declared in its `plugin.yaml` file.
121
+A plug-in must have a `plugin.yaml` file, even if empty.
122
Supported keys are:
123
124
-- `middleware` javascript file exporting a function that will be used as a middleware: it can interfere with http activity.
125
-
126
- If the function returns `true`, other executions on this http request will be interrupted.
127
- Return another function if you want to execute it in the "upstream" of middlewares.
128
-
124
- `frontend_css` path to one or more css files that you want the frontend to load.
125
126
- `frontend_js` path to one or more js files that you want the frontend to load.
127
128
Each plug-in can have a `public` folder, and its files will be accessible at `/~/plugins/PLPUGIN_NAME/FILENAME`.
129
+
130
+If a file `plugin.js` is also present, it will be required as a javascript module.
131
+The js module can export some properties. Supported ones are:
132
+
133
+- `middleware: function(Context): undefined | true` a function that will be used as a middleware: it can interfere with http activity.
134
+
135
+ To know what the Context contains please refer to [Koa's documentation](https://github.com/koajs/koa/blob/master/docs/api/context.md).
136
+ If the function returns `true`, other executions on this http request will be interrupted.
137
+ Return another function if you want to execute it in the "upstream" of middlewares.
138
+
plugins/middleware-example-disabled/plugin.js
renamed
+1
-1
@@ -1,4 +1,4 @@
1
-module.exports = function(ctx) {
1
+exports.middleware = function(ctx) {
2
ctx.body = 'This plugin is stopping you ;)'
3
return true // true = please stop
4
}
plugins/middleware-example-disabled/plugin.yaml
deleted
-2
@@ -1,2 +0,0 @@
1
-ver: 1
2
-middleware: import(blocker.js)
plugins/theme-example-disabled/plugin.js
new
+2
@@ -0,0 +1,2 @@
1
+exports.frontend_css = 'style.css'
2
+exports.frontend_js = 'test.js'
plugins/theme-example-disabled/plugin.yaml
deleted
-3
@@ -1,3 +0,0 @@
1
-ver: 1
2
-frontend_css: style.css
3
-frontend_js: test.js
\ No newline at end of file
src/index.ts
+4
@@ -94,6 +94,10 @@ app.on('error', err => {
94
console.error('server error', err)
95
})
96
97
+process.on('uncaughtException', err => {
98
+ console.error(err)
99
+})
100
+
101
let srv: Server
102
subscribeConfig({ k:'port', defaultValue: 80 }, async (port: number) => {
103
await new Promise(resolve => {
src/misc.ts
+15
@@ -1,3 +1,4 @@
1
+import { EventEmitter } from 'events'
2
import fs from 'fs/promises'
3
4
export function enforceFinal(sub:string, s:string) {
@@ -69,3 +70,17 @@ export function randomId(len = 10) {
70
.substring(2, 2+len)
71
.replace(/l/g, 'L'); // avoid confusion reading l1
72
}
73
+
74
+export function onProcessExit(cb: ()=>void) {
75
+ onFirstEvent(process, ['exit', 'SIGQUIT', 'SIGTERM', 'SIGINT'], cb)
76
+}
77
+
78
+export function onFirstEvent(emitter:EventEmitter, events: string[], cb: ()=> void) {
79
+ let already = false
80
+ for (const e of events)
81
+ emitter.on(e, () => {
82
+ if (already) return
83
+ already = true
84
+ cb()
85
+ })
86
+}
src/plugins.ts
+43
-60
@@ -6,8 +6,7 @@ import path from 'path'
6
import { PLUGINS_PUB_URI } from './const'
7
import mime from 'mime-types'
8
import Koa from 'koa'
9
-import { WatchLoadCanceller } from './watchLoad'
10
-import { getOrSet, wantArray } from './misc'
9
+import { getOrSet, onProcessExit, wantArray } from './misc'
10
11
const PATH = 'plugins'
12
@@ -18,13 +17,18 @@ export function pluginsMiddleware(): Koa.Middleware {
17
const { path } = ctx
18
const after = []
19
// run middleware plugins
21
- for (const pl of Object.values(plugins)) {
22
- const res = await pl.middleware?.(ctx)
23
- if (res === true)
24
- ctx.pluginStopped = true
25
- if (typeof res === 'function')
26
- after.push(res)
27
- }
20
+ for (const k in plugins)
21
+ try {
22
+ const pl = plugins[k]
23
+ const res = await pl.middleware?.(ctx)
24
+ if (res === true)
25
+ ctx.pluginStopped = true
26
+ if (typeof res === 'function')
27
+ after.push(res)
28
+ }
29
+ catch(e){
30
+ console.log('error middleware plugin', k)
31
+ }
32
// expose public plugins' files
33
if (path.startsWith(PLUGINS_PUB_URI)) {
34
const a = path.substring(PLUGINS_PUB_URI.length).split('/')
@@ -49,9 +53,15 @@ catch(e){
53
}
54
55
class Plugin {
56
+ js: any
57
constructor(readonly k:string, private data:any, private unwatch:()=>void){
58
+ if (!data) return
59
+ // if a previous instance is present, we are going to overwrite it, but first call its unload callback
60
+ try { plugins[k]?.data?.unload?.() }
61
+ catch(e){
62
+ console.debug('error unloading plugin', k, String(e))
63
+ }
64
plugins[k] = this // track this
54
-
65
// some validation
66
for (const k of ['frontend_css', 'frontend_js']) {
67
const v = data[k]
@@ -62,24 +72,23 @@ class Plugin {
72
console.warn('invalid', k)
73
}
74
}
65
- let v = data.middleware
66
- if (v && !(v instanceof Function)) {
67
- delete data.middleware
68
- console.warn('invalid middleware')
69
- }
75
}
76
get middleware(): undefined | PluginMiddleware {
72
- return this.data.middleware
77
+ return this.data?.middleware
78
}
79
get frontend_css(): undefined | string[] {
75
- return this.data.frontend_css
80
+ return this.data?.frontend_css
81
}
82
get frontend_js(): undefined | string[] {
78
- return this.data.frontend_js
83
+ return this.data?.frontend_js
84
}
85
86
unload() {
87
console.log('unloading plugin', this.k)
88
+ try { this.data?.unload?.() }
89
+ catch(e) {
90
+ console.debug('error unloading plugin', this.k, String(e))
91
+ }
92
delete plugins[this.k]
93
this.unwatch()
94
}
@@ -92,19 +101,22 @@ type CallMeAfter = ()=>void
101
async function rescan() {
102
console.debug('scanning plugins')
103
const found = []
95
- for (const f of await glob(PATH+'/*/plugin.yaml')) {
104
+ for (let f of await glob(PATH+'/*/plugin.js')) {
105
const k = f.split('/').slice(-2)[0]
106
if (k.endsWith('-disabled')) continue
107
found.push(k)
108
if (plugins[k]) // already loaded
109
continue
101
- const unwatch = watchLoad(f, async data => {
102
- console.log('loading plugin', k)
103
- const importCanceller = await resolveImport(data, path.resolve(PATH)+'/'+k+'/')
104
- new Plugin(k, data, () => {
105
- unwatch()
106
- importCanceller()
107
- })
110
+ f = path.resolve(f) // without this, import won't work
111
+ const unwatch = watchLoad(f, async () => {
112
+ try {
113
+ console.log('loading plugin', k)
114
+ const data = await import(f)
115
+ deleteModule(require.resolve(f)) // avoid caching
116
+ new Plugin(k, data, unwatch)
117
+ } catch (e) {
118
+ console.log('plugin error importing', k, e)
119
+ }
120
})
121
}
122
for (const k in plugins)
@@ -112,40 +124,6 @@ async function rescan() {
124
plugins[k].unload()
125
}
126
115
-const re = /^ *import\((.+)\) *$/
116
-async function resolveImport(x: Record<string,any>, basePath: string) {
117
- const cancellers: WatchLoadCanceller[] = []
118
- for (const k in x) {
119
- let v = x[k]
120
- if (!v)
121
- continue
122
- if (typeof v === 'object')
123
- await resolveImport(v, basePath)
124
- else if (typeof v === 'string' && re.test(v)) {
125
- const fn = re.exec(v)![1].replace(/\\/g, '//')
126
- if (fn.includes('..') || fn.startsWith('/') || fn.includes(':'))
127
- continue
128
- const path = basePath + fn
129
- await new Promise(resolve => // wait for first execution, so that the caller sees imported stuff instead of string
130
- cancellers.push(watchLoad(path, async () => {
131
- try {
132
- x[k] = (await import(path)).default
133
- deleteModule(require.resolve(path)) // avoid caching
134
- console.log('plugin imported', fn)
135
- } catch (e) {
136
- console.log('plugin error importing', fn, String(e))
137
- delete x[k]
138
- }
139
- resolve(0)
140
- })) )
141
- }
142
- }
143
- return () => {
144
- for (const c of cancellers)
145
- c()
146
- }
147
-}
148
-
127
function deleteModule(id: string) {
128
const { cache } = require
129
// build reversed map of dependencies
@@ -167,3 +145,8 @@ function deleteModule(id: string) {
145
recur(child.id)
146
}
147
}
148
+
149
+onProcessExit(() => {
150
+ for (const pl of Object.values(plugins))
151
+ pl.unload()
152
+})
src/serveFile.ts
+1
@@ -36,6 +36,7 @@ export function serveFile(source:string, mime?:string) : Koa.Middleware {
36
return ctx.status = METHOD_NOT_ALLOWED
37
const stats = await fs.stat(source)
38
ctx.set('Last-Modified', stats.mtime.toUTCString())
39
+ ctx.fileSource = source
40
ctx.status = 200
41
if (ctx.fresh)
42
return ctx.status = 304