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