plugin.ts 7.6 KB
Newer Older
A
Asher 已提交
1
import { field, Logger } from "@coder/logger"
A
Fix CI  
Anmol Sethi 已提交
2
import * as express from "express"
3
import * as fs from "fs"
A
Fix CI  
Anmol Sethi 已提交
4
import * as path from "path"
5
import * as semver from "semver"
A
Anmol Sethi 已提交
6
import * as pluginapi from "../../typings/pluginapi"
7
import { version } from "./constants"
A
Asher 已提交
8
import { proxy } from "./proxy"
A
Fix CI  
Anmol Sethi 已提交
9
import * as util from "./util"
A
Asher 已提交
10
import { Router as WsRouter, WebsocketRouter } from "./wsRouter"
11
const fsp = fs.promises
A
Asher 已提交
12

A
Asher 已提交
13 14 15 16 17 18 19 20 21
/**
 * Inject code-server when `require`d. This is required because the API provides
 * more than just types so these need to be provided at run-time.
 */
const originalLoad = require("module")._load
// eslint-disable-next-line @typescript-eslint/no-explicit-any
require("module")._load = function (request: string, parent: object, isMain: boolean): any {
  if (request === "code-server") {
    return {
A
Asher 已提交
22
      express,
A
Asher 已提交
23
      field,
A
Asher 已提交
24
      proxy,
A
Asher 已提交
25
      WsRouter,
A
Asher 已提交
26 27 28 29 30
    }
  }
  return originalLoad.apply(this, [request, parent, isMain])
}

31
interface Plugin extends pluginapi.Plugin {
32
  /**
33 34
   * These fields are populated from the plugin's package.json
   * and now guaranteed to exist.
35
   */
36 37
  name: string
  version: string
38 39 40 41 42

  /**
   * path to the node module on the disk.
   */
  modulePath: string
A
Asher 已提交
43 44
}

45
interface Application extends pluginapi.Application {
A
Anmol Sethi 已提交
46 47 48
  /*
   * Clone of the above without functions.
   */
A
Asher 已提交
49
  plugin: Omit<Plugin, "init" | "deinit" | "router" | "applications">
A
Asher 已提交
50 51
}

52
/**
A
Anmol Sethi 已提交
53
 * PluginAPI implements the plugin API described in typings/pluginapi.d.ts
54
 * Please see that file for details.
55
 */
56
export class PluginAPI {
57
  private readonly plugins = new Map<string, Plugin>()
58 59 60 61 62 63 64 65 66
  private readonly logger: Logger

  public constructor(
    logger: Logger,
    /**
     * These correspond to $CS_PLUGIN_PATH and $CS_PLUGIN respectively.
     */
    private readonly csPlugin = "",
    private readonly csPluginPath = `${path.join(util.paths.data, "plugins")}:/usr/share/code-server/plugins`,
A
Fix CI  
Anmol Sethi 已提交
67
  ) {
68 69 70 71 72 73 74 75 76
    this.logger = logger.named("pluginapi")
  }

  /**
   * applications grabs the full list of applications from
   * all loaded plugins.
   */
  public async applications(): Promise<Application[]> {
    const apps = new Array<Application>()
A
Anmol Sethi 已提交
77
    for (const [, p] of this.plugins) {
78 79 80
      if (!p.applications) {
        continue
      }
81 82 83 84 85
      const pluginApps = await p.applications()

      // Add plugin key to each app.
      apps.push(
        ...pluginApps.map((app) => {
A
Anmol Sethi 已提交
86 87
          app = { ...app, path: path.join(p.routerPath, app.path || "") }
          app = { ...app, iconPath: path.join(app.path || "", app.iconPath) }
A
Anmol Sethi 已提交
88 89 90 91 92 93
          return {
            ...app,
            plugin: {
              name: p.name,
              version: p.version,
              modulePath: p.modulePath,
94 95 96

              displayName: p.displayName,
              description: p.description,
97
              routerPath: p.routerPath,
98
              homepageURL: p.homepageURL,
A
Anmol Sethi 已提交
99 100
            },
          }
101 102 103 104 105 106 107
        }),
      )
    }
    return apps
  }

  /**
A
Asher 已提交
108
   * mount mounts all plugin routers onto r and websocket routers onto wr.
109
   */
A
Asher 已提交
110
  public mount(r: express.Router, wr: express.Router): void {
A
Anmol Sethi 已提交
111
    for (const [, p] of this.plugins) {
A
Asher 已提交
112 113 114 115 116
      if (p.router) {
        r.use(`${p.routerPath}`, p.router())
      }
      if (p.wsRouter) {
        wr.use(`${p.routerPath}`, (p.wsRouter() as WebsocketRouter).router)
117
      }
118 119 120 121
    }
  }

  /**
A
Anmol Sethi 已提交
122 123
   * loadPlugins loads all plugins based on this.csPlugin,
   * this.csPluginPath and the built in plugins.
124
   */
A
Asher 已提交
125
  public async loadPlugins(loadBuiltin = true): Promise<void> {
126
    for (const dir of this.csPlugin.split(":")) {
127 128 129
      if (!dir) {
        continue
      }
130
      await this.loadPlugin(dir)
131 132
    }

133
    for (const dir of this.csPluginPath.split(":")) {
134 135 136
      if (!dir) {
        continue
      }
137
      await this._loadPlugins(dir)
138
    }
139

A
Asher 已提交
140 141 142
    if (loadBuiltin) {
      await this._loadPlugins(path.join(__dirname, "../../plugins"))
    }
143 144
  }

145 146 147 148 149 150 151
  /**
   * _loadPlugins is the counterpart to loadPlugins.
   *
   * It differs in that it loads all plugins in a single
   * directory whereas loadPlugins uses all available directories
   * as documented.
   */
152 153 154
  private async _loadPlugins(dir: string): Promise<void> {
    try {
      const entries = await fsp.readdir(dir, { withFileTypes: true })
A
Fix CI  
Anmol Sethi 已提交
155
      for (const ent of entries) {
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
        if (!ent.isDirectory()) {
          continue
        }
        await this.loadPlugin(path.join(dir, ent.name))
      }
    } catch (err) {
      if (err.code !== "ENOENT") {
        this.logger.warn(`failed to load plugins from ${q(dir)}: ${err.message}`)
      }
    }
  }

  private async loadPlugin(dir: string): Promise<void> {
    try {
      const str = await fsp.readFile(path.join(dir, "package.json"), {
        encoding: "utf8",
      })
      const packageJSON: PackageJSON = JSON.parse(str)
A
Anmol Sethi 已提交
174
      for (const [, p] of this.plugins) {
175 176 177 178 179 180 181
        if (p.name === packageJSON.name) {
          this.logger.warn(
            `ignoring duplicate plugin ${q(p.name)} at ${q(dir)}, using previously loaded ${q(p.modulePath)}`,
          )
          return
        }
      }
182
      const p = this._loadPlugin(dir, packageJSON)
183
      this.plugins.set(p.name, p)
184 185
    } catch (err) {
      if (err.code !== "ENOENT") {
A
Anmol Sethi 已提交
186
        this.logger.warn(`failed to load plugin: ${err.stack}`)
187 188 189 190
      }
    }
  }

191 192 193 194 195
  /**
   * _loadPlugin is the counterpart to loadPlugin and actually
   * loads the plugin now that we know there is no duplicate
   * and that the package.json has been read.
   */
196
  private _loadPlugin(dir: string, packageJSON: PackageJSON): Plugin {
197 198
    dir = path.resolve(dir)

199
    const logger = this.logger.named(packageJSON.name)
A
Fix CI  
Anmol Sethi 已提交
200
    logger.debug("loading plugin", field("plugin_dir", dir), field("package_json", packageJSON))
201

A
Anmol Sethi 已提交
202 203 204 205 206 207 208 209 210
    if (!packageJSON.name) {
      throw new Error("plugin package.json missing name")
    }
    if (!packageJSON.version) {
      throw new Error("plugin package.json missing version")
    }
    if (!packageJSON.engines || !packageJSON.engines["code-server"]) {
      throw new Error(`plugin package.json missing code-server range like:
  "engines": {
A
v3.7.0  
Anmol Sethi 已提交
211
    "code-server": "^3.7.0"
A
Anmol Sethi 已提交
212 213 214
   }
`)
    }
215
    if (!semver.satisfies(version, packageJSON.engines["code-server"])) {
A
Fix CI  
Anmol Sethi 已提交
216 217 218
      throw new Error(
        `plugin range ${q(packageJSON.engines["code-server"])} incompatible` + ` with code-server version ${version}`,
      )
219 220
    }

221 222 223 224 225
    const pluginModule = require(dir)
    if (!pluginModule.plugin) {
      throw new Error("plugin module does not export a plugin")
    }

226 227 228
    const p = {
      name: packageJSON.name,
      version: packageJSON.version,
229
      modulePath: dir,
230
      ...pluginModule.plugin,
231 232
    } as Plugin

233 234 235 236 237 238
    if (!p.displayName) {
      throw new Error("plugin missing displayName")
    }
    if (!p.description) {
      throw new Error("plugin missing description")
    }
239 240
    if (!p.routerPath) {
      throw new Error("plugin missing router path")
241
    }
A
Anmol Sethi 已提交
242 243 244
    if (!p.routerPath.startsWith("/") || p.routerPath.length < 2) {
      throw new Error(`plugin router path ${q(p.routerPath)}: invalid`)
    }
245 246 247
    if (!p.homepageURL) {
      throw new Error("plugin missing homepage")
    }
248

249 250 251 252 253 254 255
    p.init({
      logger: logger,
    })

    logger.debug("loaded")

    return p
A
Asher 已提交
256
  }
A
Asher 已提交
257 258 259 260 261 262 263 264 265 266 267 268 269 270 271

  public async dispose(): Promise<void> {
    await Promise.all(
      Array.from(this.plugins.values()).map(async (p) => {
        if (!p.deinit) {
          return
        }
        try {
          await p.deinit()
        } catch (error) {
          this.logger.error("plugin failed to deinit", field("name", p.name), field("error", error.message))
        }
      }),
    )
  }
A
Asher 已提交
272 273
}

274 275 276 277 278
interface PackageJSON {
  name: string
  version: string
  engines: {
    "code-server": string
A
Asher 已提交
279
  }
280
}
A
Asher 已提交
281

A
Anmol Sethi 已提交
282
function q(s: string | undefined): string {
283 284 285 286
  if (s === undefined) {
    s = "undefined"
  }
  return JSON.stringify(s)
A
Asher 已提交
287
}