From bf3af63b848df200ad52373b2e3ec8d2501233a0 Mon Sep 17 00:00:00 2001 From: wallpants <47203170+wallpants@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:13:20 -0600 Subject: [PATCH] feat(config): add allow_multiple_instances option When enabled, starting the plugin no longer kills instances started by other neovim processes. Instead, the server binds the configured port, incrementing it until a free one is found (up to 20 attempts). - reusePort: false is required because Bun silently enables SO_REUSEPORT for servers with "routes", which let a second instance bind an already-taken port without EADDRINUSE - :GithubPreviewStart now restarts an instance already running in the current neovim via a clean rpc stop instead of relying on the http unalive endpoint --- README.md | 8 +- app/github-preview.ts | 16 ++-- app/server/index.ts | 127 +++++++++++++++++---------- app/types.test.ts | 1 + app/types.ts | 6 ++ lua/github-preview/config.lua | 6 ++ lua/github-preview/functions.lua | 7 ++ lua/github-preview/types.lua | 1 + tests/github-preview/config_spec.lua | 6 ++ 9 files changed, 123 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 9ddea76..00f54bf 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ require("github-preview").setup({ -- port used by local server port = 6041, + -- true: instances started by other neovim processes are left running + -- and a free port is picked by incrementing "port" until one is available + -- false: starting the plugin kills any other running instance + allow_multiple_instances = false, + -- set to "true" to force single-file mode & disable repository mode single_file = false, @@ -115,7 +120,8 @@ This might happen again after a plugin update if there were any changes to the p ### `:GithubPreviewStart` -**Start** plugin. Any previously created instances are killed. +**Start** plugin. If an instance is already running in the current Neovim, it is restarted. +Instances started by other Neovim processes are killed unless `allow_multiple_instances` is enabled. ### `:GithubPreviewStop` diff --git a/app/github-preview.ts b/app/github-preview.ts index 772c0a4..4e52420 100644 --- a/app/github-preview.ts +++ b/app/github-preview.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { basename, dirname, normalize, resolve } from "node:path"; import { type Server } from "bun"; -import { NVIM_LOG_LEVELS, attach, type Nvim } from "bunvim"; +import { attach, NVIM_LOG_LEVELS, type Nvim } from "bunvim"; import { globby } from "globby"; import { isBinaryFile } from "isbinaryfile"; import { ENV } from "./env"; @@ -11,11 +11,11 @@ import { PluginPropsSchema, type Config, type ContentChange, + type CustomEvents, type GithubPreviewConfig, type PluginProps, type UpdateConfigAction, type WsServerMessage, - type CustomEvents, } from "./types"; export class GithubPreview { @@ -76,11 +76,13 @@ export class GithubPreview { const props = (await nvim.call("nvim_get_var", ["github_preview_props"])) as PluginProps; if (ENV.IS_DEV) PluginPropsSchema.parse(props); - try { - // try to unalive already running instances of github-preview - await fetch(`http://${props.config.host}:${props.config.port}${UNALIVE_URL}`); - } catch (_err) { - // no other instance running + if (!props.config.allow_multiple_instances) { + try { + // try to unalive already running instances of github-preview + await fetch(`http://${props.config.host}:${props.config.port}${UNALIVE_URL}`); + } catch (_err) { + // no other instance running + } } const repoName = await GithubPreview.getRepoName({ root: props.init.root }); diff --git a/app/server/index.ts b/app/server/index.ts index 7c11023..37d1c42 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -8,54 +8,87 @@ import { websocketHandler } from "./websocket.ts"; export const UNALIVE_URL = "/unalive"; +/** + * Ports we attempt to bind before giving up when + * allow_multiple_instances is enabled + */ +const MAX_PORT_ATTEMPTS = 20; + export function startServer(app: GithubPreview, isDev: boolean): Server { - const { port, host } = app.config.overrides; - - const server = Bun.serve({ - port: port, - routes: { - [IMAGE_PREFIX + "*"]: (req: Request) => { - app.nvim.logger?.info({ route: req.url }); - const pathname = new URL(req.url).pathname; - let filePath: string; - try { - filePath = decodeURIComponent(pathname.replace(IMAGE_PREFIX, "")); - } catch (_err) { - return new Response(null, { status: 400 }); - } - // do not serve any files outside of repo root - const fullPath = normalize(app.root + filePath); - if (!fullPath.startsWith(app.root)) { - return new Response(null, { status: 404 }); - } - app.nvim.logger?.info({ filePath: fullPath }); - // images with relative sources - const file = Bun.file(fullPath); - return new Response(file); + const { port, host, allow_multiple_instances } = app.config.overrides; + + const serve = (p: number) => + Bun.serve({ + port: p, + // Bun silently enables SO_REUSEPORT for servers with "routes", + // which lets two instances bind the same port without EADDRINUSE. + // We rely on that error to detect taken ports. + reusePort: false, + routes: { + [IMAGE_PREFIX + "*"]: (req: Request) => { + app.nvim.logger?.info({ route: req.url }); + const pathname = new URL(req.url).pathname; + let filePath: string; + try { + filePath = decodeURIComponent(pathname.replace(IMAGE_PREFIX, "")); + } catch (_err) { + return new Response(null, { status: 400 }); + } + // do not serve any files outside of repo root + const fullPath = normalize(app.root + filePath); + if (!fullPath.startsWith(app.root)) { + return new Response(null, { status: 404 }); + } + app.nvim.logger?.info({ filePath: fullPath }); + // images with relative sources + const file = Bun.file(fullPath); + return new Response(file); + }, + [UNALIVE_URL]: async (req) => { + app.nvim.logger?.info({ route: req.url }); + // This endpoint is called when starting the service to kill + // github-preview instances started by other nvim instances + await app.goodbye(); + app.nvim.detach(); + process.exit(0); + }, + "/*": index, }, - [UNALIVE_URL]: async (req) => { - app.nvim.logger?.info({ route: req.url }); - // This endpoint is called when starting the service to kill - // github-preview instances started by other nvim instances - await app.goodbye(); - app.nvim.detach(); - process.exit(0); + fetch: (req: Request, server: Server) => { + app.nvim.logger?.info({ fetchUrl: req.url }); + const upgradedToWs = server.upgrade(req); + if (upgradedToWs) { + // If client (browser) requested to upgrade connection to websocket + // and we successfully upgraded request + return; + } }, - "/*": index, - }, - fetch: (req: Request, server: Server) => { - app.nvim.logger?.info({ fetchUrl: req.url }); - const upgradedToWs = server.upgrade(req); - if (upgradedToWs) { - // If client (browser) requested to upgrade connection to websocket - // and we successfully upgraded request - return; - } - }, - websocket: websocketHandler(app), - development: isDev, - }); - - opener(`http://${host}:${port}?theme=${JSON.stringify(app.config.overrides.theme)}`); - return server; + websocket: websocketHandler(app), + development: isDev, + }); + + let server: Server | undefined; + let boundPort = port; + const maxAttempts = allow_multiple_instances ? MAX_PORT_ATTEMPTS : 1; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + boundPort = port + attempt; + server = serve(boundPort); + break; + } catch (err) { + // binding instead of checking-then-binding avoids racing + // other processes for the port + const portTaken = err instanceof Error && "code" in err && err.code === "EADDRINUSE"; + if (!portTaken || attempt === maxAttempts - 1) throw err; + } + } + if (!server) throw Error("github-preview: could not find a free port"); + + // keep config in sync with the port we actually bound, + // it may differ from the requested one when allow_multiple_instances is enabled + app.config.overrides.port = boundPort; + + opener(`http://${host}:${boundPort}?theme=${JSON.stringify(app.config.overrides.theme)}`); + return server as Server; } diff --git a/app/types.test.ts b/app/types.test.ts index 4b08996..4d08bff 100644 --- a/app/types.test.ts +++ b/app/types.test.ts @@ -5,6 +5,7 @@ import { PluginPropsSchema, ThemeSchema, type PluginProps } from "./types.ts"; export const defaultConfig: PluginProps["config"] = { host: "localhost", port: 6041, + allow_multiple_instances: false, single_file: false, theme: { name: "system", diff --git a/app/types.ts b/app/types.ts index 4e78e79..f4a115f 100644 --- a/app/types.ts +++ b/app/types.ts @@ -30,6 +30,12 @@ export const PluginPropsSchema = z.object({ host: z.string(), /** port to host the http/ws server "localhost:\{port\}" */ port: z.number(), + /** + * if true, other running github-preview instances are left alone and + * "port" is incremented until a free one is found. + * if false, other instances are killed on startup and "port" is used as is. + */ + allow_multiple_instances: z.boolean(), single_file: z.boolean(), theme: ThemeSchema, details_tags_open: z.boolean(), diff --git a/lua/github-preview/config.lua b/lua/github-preview/config.lua index 716c2ea..48f80e3 100644 --- a/lua/github-preview/config.lua +++ b/lua/github-preview/config.lua @@ -9,6 +9,11 @@ M.value = { -- port used by local server port = 6041, + -- true: instances started by other neovim processes are left running + -- and a free port is picked by incrementing "port" until one is available + -- false: starting the plugin kills any other running instance + allow_multiple_instances = false, + -- set to "true" to force single-file mode & disable repository mode single_file = false, @@ -49,6 +54,7 @@ M.validate = function() vim.validate({ host = { M.value.host, "string" }, port = { M.value.port, "number" }, + allow_multiple_instances = { M.value.allow_multiple_instances, "boolean" }, ["theme.high_contrast"] = { M.value.theme.high_contrast, "boolean" }, ["theme.name"] = { M.value.theme.name, diff --git a/lua/github-preview/functions.lua b/lua/github-preview/functions.lua index a051df1..fca5d7a 100644 --- a/lua/github-preview/functions.lua +++ b/lua/github-preview/functions.lua @@ -39,6 +39,13 @@ M.start = function() return end + -- if an instance is already running in this neovim, restart it. + -- instances started by other neovim processes are handled by the app: + -- killed by default, left alone when allow_multiple_instances is enabled + if Utils.get_client_channel() ~= nil then + M.stop() + end + -- single-file mode may also be enabled as a fallback when no repo is found. -- keep it local so the fallback doesn't stick to Config.value across starts local single_file = Config.value.single_file diff --git a/lua/github-preview/types.lua b/lua/github-preview/types.lua index c8eff0e..7efdebe 100644 --- a/lua/github-preview/types.lua +++ b/lua/github-preview/types.lua @@ -20,6 +20,7 @@ ---@class github_preview_config ---@field host string | nil ---@field port number | nil +---@field allow_multiple_instances boolean | nil ---@field theme theme | nil ---@field single_file boolean | nil ---@field details_tags_open boolean | nil diff --git a/tests/github-preview/config_spec.lua b/tests/github-preview/config_spec.lua index 80cfeb0..7bd79d3 100644 --- a/tests/github-preview/config_spec.lua +++ b/tests/github-preview/config_spec.lua @@ -51,4 +51,10 @@ describe("config", function() config.value.single_file = "yes" assert.has_error(config.validate) end) + + it("rejects non-boolean allow_multiple_instances", function() + local config = fresh_config() + config.value.allow_multiple_instances = 1 + assert.has_error(config.validate) + end) end)