From 583de0fa0c59432be56781e8bbf9b385800221d9 Mon Sep 17 00:00:00 2001 From: Shigma Date: Fri, 19 Apr 2024 15:38:02 +0800 Subject: [PATCH] feat(webui): setup webui server --- .github/workflows/build.yml | 58 +++++ .github/workflows/stale.yml | 21 ++ LICENSE | 21 ++ plugins/webui/package.json | 80 +++++++ plugins/webui/src/browser/index.ts | 25 +++ plugins/webui/src/index.ts | 5 + plugins/webui/src/node/index.ts | 331 +++++++++++++++++++++++++++++ plugins/webui/tsconfig.json | 10 + tsconfig.json | 18 ++ 9 files changed, 569 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/stale.yml create mode 100644 LICENSE create mode 100644 plugins/webui/package.json create mode 100644 plugins/webui/src/browser/index.ts create mode 100644 plugins/webui/src/index.ts create mode 100644 plugins/webui/src/node/index.ts create mode 100644 plugins/webui/tsconfig.json create mode 100644 tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..5836e8e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,58 @@ +name: Build + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + - name: Install + run: yarn --no-immutable + - name: Lint + run: yarn lint + + build: + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + - name: Install + run: yarn --no-immutable + - name: Build + run: yarn build + + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: [18, 20] + + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: yarn --no-immutable + - name: Unit Test + run: yarn test:json + - name: Report Coverage + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage/coverage-final.json + name: codecov diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..5a7a2ec --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,21 @@ +name: Stale + +on: + schedule: + - cron: 30 7 * * * + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v4 + with: + stale-issue-label: stale + stale-issue-message: | + This issue is stale because it has been open 30 days with no activity. + Remove stale label or comment or this will be closed in 5 days. + close-issue-message: | + This issue was closed because it has been stalled for 5 days with no activity. + days-before-issue-stale: 30 + days-before-issue-close: 5 + any-of-labels: needs repro, await feedback diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eaa233c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-present Shigma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/webui/package.json b/plugins/webui/package.json new file mode 100644 index 0000000..931ae0f --- /dev/null +++ b/plugins/webui/package.json @@ -0,0 +1,80 @@ +{ + "name": "@cordisjs/plugin-webui", + "description": "Web User Interface for Koishi", + "version": "0.28.3", + "main": "lib/node/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "node": { + "require": "./lib/node/index.js", + "import": "./lib/node/index.mjs" + }, + "browser": "./lib/browser/index.mjs", + "types": "./lib/index.d.ts" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "app", + "lib", + "dist", + "src" + ], + "author": "Shigma ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cordisjs/webui.git", + "directory": "plugins/webui" + }, + "bugs": { + "url": "https://github.com/cordisjs/webui/issues" + }, + "keywords": [ + "cordis", + "plugin", + "frontend", + "webui", + "console", + "service" + ], + "cordis": { + "description": { + "en": "Web UI interface", + "zh": "网页控制台" + }, + "service": { + "implements": [ + "webui" + ] + } + }, + "peerDependencies": { + "@cordisjs/client": "^0.28.3", + "cordis": "^3.13.6" + }, + "peerDependenciesMeta": { + "@cordisjs/client": { + "optional": true + } + }, + "devDependencies": { + "@cordisjs/client": "^0.28.3", + "@cordisjs/loader": "^0.8.6", + "@cordisjs/server": "^0.1.8", + "@maikolib/vite-plugin-yaml": "^1.0.1", + "@types/uuid": "^8.3.4", + "@vitejs/plugin-vue": "^4.6.2", + "unocss": "^0.58.6", + "vite": "^4.5.3" + }, + "dependencies": { + "@cordisjs/webui": "^0.28.3", + "cosmokit": "^1.6.2", + "open": "^8.4.2", + "uuid": "^8.3.2", + "ws": "^8.16.0" + } +} diff --git a/plugins/webui/src/browser/index.ts b/plugins/webui/src/browser/index.ts new file mode 100644 index 0000000..a99d7cb --- /dev/null +++ b/plugins/webui/src/browser/index.ts @@ -0,0 +1,25 @@ +import { Schema } from 'cordis' +import { makeArray } from 'cosmokit' +import { Console, Entry } from '@cordisjs/webui' +import {} from '@cordisjs/loader' + +export * from '@cordisjs/webui' + +class BrowserConsole extends Console { + start() { + this.accept(this.ctx.loader[Symbol.for('koishi.socket')]) + } + + resolveEntry(files: Entry.Files) { + if (typeof files === 'string' || Array.isArray(files)) return makeArray(files) + return makeArray(files.prod) + } +} + +namespace BrowserConsole { + export interface Config {} + + export const Config: Schema = Schema.object({}) +} + +export default BrowserConsole diff --git a/plugins/webui/src/index.ts b/plugins/webui/src/index.ts new file mode 100644 index 0000000..1678297 --- /dev/null +++ b/plugins/webui/src/index.ts @@ -0,0 +1,5 @@ +// placeholder file, do not modify +import Console from './node' + +export default Console +export * from './node' diff --git a/plugins/webui/src/node/index.ts b/plugins/webui/src/node/index.ts new file mode 100644 index 0000000..870c6cd --- /dev/null +++ b/plugins/webui/src/node/index.ts @@ -0,0 +1,331 @@ +import { Context, Schema } from 'cordis' +import { Dict, makeArray, noop, Time } from 'cosmokit' +import { WebSocketLayer } from '@cordisjs/server' +import { Console, Entry } from '@cordisjs/webui' +import { FileSystemServeOptions, ViteDevServer } from 'vite' +import { extname, resolve } from 'path' +import { createReadStream, existsSync, promises as fs, Stats } from 'fs' +import open from 'open' +import { createRequire } from 'module' +import { fileURLToPath, pathToFileURL } from 'url' + +declare module 'cordis' { + interface EnvData { + clientCount?: number + } +} + +export * from '@cordisjs/webui' + +function escapeHTML(source: string, inline = false) { + const result = (source ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + return inline + ? result.replace(/"/g, '"') + : result +} + +export interface ClientConfig { + devMode: boolean + uiPath: string + endpoint: string + static?: boolean + heartbeat?: HeartbeatConfig + proxyBase?: string +} + +interface HeartbeatConfig { + interval?: number + timeout?: number +} + +class NodeConsole extends Console { + static inject = ['server'] + + // workaround for edge case (collision with @cordisjs/plugin-config) + private _config: NodeConsole.Config + + public vite: ViteDevServer + public root: string + public layer: WebSocketLayer + + constructor(public ctx: Context, config: NodeConsole.Config) { + super(ctx) + this.config = config + + this.layer = ctx.server.ws(config.apiPath, (socket, request) => { + // @types/ws does not provide typings for `dispatchEvent` + this.accept(socket as any, request) + }) + + ctx.on('console/connection', () => { + const loader = ctx.get('loader') + if (!loader) return + loader.envData.clientCount = this.layer.clients.size + }) + + // @ts-ignore + const base = import.meta.url || pathToFileURL(__filename).href + const require = createRequire(base) + this.root = config.devMode + ? resolve(require.resolve('@cordisjs/client/package.json'), '../app') + : fileURLToPath(new URL('../../dist', base)) + } + + // @ts-ignore FIXME + get config() { + return this._config + } + + set config(value) { + this._config = value + } + + createGlobal() { + const global = {} as ClientConfig + const { devMode, uiPath, apiPath, selfUrl, heartbeat } = this.config + global.devMode = devMode + global.uiPath = uiPath + global.heartbeat = heartbeat + global.endpoint = selfUrl + apiPath + const proxy = this.ctx.get('server.proxy') + if (proxy) global.proxyBase = proxy.config.path + '/' + return global + } + + async start() { + if (this.config.devMode) await this.createVite() + this.serveAssets() + + this.ctx.on('server/ready', () => { + let { host, port } = this.ctx.server + if (['0.0.0.0', '::'].includes(host)) host = '127.0.0.1' + const target = `http://${host}:${port}${this.config.uiPath}` + if (this.config.open && !this.ctx.get('loader')?.envData.clientCount && !process.env.KOISHI_AGENT) { + open(target) + } + this.ctx.logger.info('webui is available at %c', target) + }) + } + + private getFiles(files: Entry.Files) { + if (typeof files === 'string' || Array.isArray(files)) return files + if (!this.config.devMode) return files.prod + if (!existsSync(files.dev)) return files.prod + return files.dev + } + + resolveEntry(files: Entry.Files, key: string) { + const { devMode, uiPath } = this.config + const filenames: string[] = [] + for (const local of makeArray(this.getFiles(files))) { + const filename = devMode ? '/vite/@fs/' + local : uiPath + '/@plugin-' + key + if (extname(local)) { + filenames.push(filename) + } else { + filenames.push(filename + '/index.js') + if (existsSync(local + '/style.css')) { + filenames.push(filename + '/style.css') + } + } + } + return filenames + } + + private serveAssets() { + const { uiPath } = this.config + + this.ctx.server.get(uiPath + '(/.+)*', async (ctx, next) => { + await next() + if (ctx.body || ctx.response.body) return + + // add trailing slash and redirect + if (ctx.path === uiPath && !uiPath.endsWith('/')) { + return ctx.redirect(ctx.path + '/') + } + + const name = ctx.path.slice(uiPath.length).replace(/^\/+/, '') + const sendFile = (filename: string) => { + ctx.type = extname(filename) + return ctx.body = createReadStream(filename) + } + + if (name.startsWith('@plugin-')) { + const [key] = name.slice(8).split('/', 1) + if (this.entries[key]) { + const files = makeArray(this.getFiles(this.entries[key].files)) + const filename = files[0] + name.slice(8 + key.length) + ctx.type = extname(filename) + if (this.config.devMode || ctx.type !== 'application/javascript') { + return sendFile(filename) + } + + // we only transform js imports in production mode + const source = await fs.readFile(filename, 'utf8') + return ctx.body = await this.transformImport(source) + } else { + return ctx.status = 404 + } + } + + const filename = resolve(this.root, name) + if (!filename.startsWith(this.root) && !filename.includes('node_modules')) { + return ctx.status = 403 + } + + const stats = await fs.stat(filename).catch(noop) + if (stats?.isFile()) return sendFile(filename) + const template = await fs.readFile(resolve(this.root, 'index.html'), 'utf8') + ctx.type = 'html' + ctx.body = await this.transformHtml(template) + }) + } + + private async transformImport(source: string) { + let output = '' + let cap: RegExpExecArray + while ((cap = /((?:^|;)import\b[^'"]+\bfrom\s*)(['"])([^'"]+)\2;/m.exec(source))) { + const [stmt, left, quote, path] = cap + output += source.slice(0, cap.index) + left + quote + ({ + 'vue': '../vue.js', + 'vue-router': '../vue-router.js', + '@vueuse/core': '../vueuse.js', + '@cordisjs/client': '../client.js', + }[path] ?? path) + quote + ';' + source = source.slice(cap.index + stmt.length) + } + return output + source + } + + private async transformHtml(template: string) { + const { uiPath, head = [] } = this.config + if (this.vite) { + template = await this.vite.transformIndexHtml(uiPath, template) + } else { + template = template.replace(/(href|src)="(?=\/)/g, (_, $1) => `${$1}="${uiPath}`) + } + let headInjection = `` + for (const { tag, attrs = {}, content } of head) { + const attrString = Object.entries(attrs).map(([key, value]) => ` ${key}="${escapeHTML(value ?? '', true)}"`).join('') + headInjection += `<${tag}${attrString}>${content ?? ''}` + } + return template.replace('', headInjection + '<title>') + } + + private async createVite() { + const { cacheDir, dev } = this.config + const { createServer } = require('@cordisjs/client/lib') as typeof import('@cordisjs/client/lib') + + this.vite = await createServer(this.ctx.baseDir, { + cacheDir: resolve(this.ctx.baseDir, cacheDir), + server: { + fs: dev.fs, + }, + }) + + this.ctx.server.all('/vite(/.+)*', (ctx) => new Promise((resolve) => { + this.vite.middlewares(ctx.req, ctx.res, resolve) + })) + + this.ctx.on('dispose', () => this.vite.close()) + } + + stop() { + this.layer.close() + } +} + +namespace NodeConsole { + export interface Dev { + fs: FileSystemServeOptions + } + + export const Dev: Schema<Dev> = Schema.object({ + fs: Schema.object({ + strict: Schema.boolean().default(true), + allow: Schema.array(String).default(null), + deny: Schema.array(String).default(null), + }).hidden(), + }) + + export interface Head { + tag: string + attrs?: Dict<string> + content?: string + } + + export const Head: Schema<Head> = Schema.intersect([ + Schema.object({ + tag: Schema.union([ + 'title', + 'link', + 'meta', + 'script', + 'style', + Schema.string(), + ]).required(), + }), + Schema.union([ + Schema.object({ + tag: Schema.const('title').required(), + content: Schema.string().role('textarea'), + }), + Schema.object({ + tag: Schema.const('link').required(), + attrs: Schema.dict(Schema.string()).role('table'), + }), + Schema.object({ + tag: Schema.const('meta').required(), + attrs: Schema.dict(Schema.string()).role('table'), + }), + Schema.object({ + tag: Schema.const('script').required(), + attrs: Schema.dict(Schema.string()).role('table'), + content: Schema.string().role('textarea'), + }), + Schema.object({ + tag: Schema.const('style').required(), + attrs: Schema.dict(Schema.string()).role('table'), + content: Schema.string().role('textarea'), + }), + Schema.object({ + tag: Schema.string().required(), + attrs: Schema.dict(Schema.string()).role('table'), + content: Schema.string().role('textarea'), + }), + ]), + ]) + + export interface Config { + uiPath?: string + devMode?: boolean + cacheDir?: string + open?: boolean + head?: Head[] + selfUrl?: string + apiPath?: string + heartbeat?: HeartbeatConfig + dev?: Dev + } + + export const Config: Schema<Config> = Schema.intersect([ + Schema.object({ + uiPath: Schema.string().default(''), + apiPath: Schema.string().default('/status'), + selfUrl: Schema.string().role('link').default(''), + open: Schema.boolean(), + head: Schema.array(Head), + heartbeat: Schema.object({ + interval: Schema.number().default(Time.second * 30), + timeout: Schema.number().default(Time.minute), + }), + devMode: Schema.boolean().default(process.env.NODE_ENV === 'development').hidden(), + cacheDir: Schema.string().default('cache/vite').hidden(), + dev: Dev, + }), + ]) +} + +export default NodeConsole diff --git a/plugins/webui/tsconfig.json b/plugins/webui/tsconfig.json new file mode 100644 index 0000000..7753829 --- /dev/null +++ b/plugins/webui/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outFile": "lib/index.d.ts", + }, + "include": [ + "src", + ], +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c79bd09 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@cordisjs/client/lib": [ + "packages/client/src", + ], + "@cordisjs/plugin-*": [ + "plugins/*/src", + ], + "@cordisjs/*": [ + "packages/*/src", + ], + }, + }, + "files": [], +}