Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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`

Expand Down
16 changes: 9 additions & 7 deletions app/github-preview.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down
127 changes: 80 additions & 47 deletions app/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(app: GithubPreview, isDev: boolean): Server<T> {
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<undefined>) => {
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<undefined>) => {
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> | 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<T>;
}
1 change: 1 addition & 0 deletions app/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions lua/github-preview/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions lua/github-preview/functions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lua/github-preview/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tests/github-preview/config_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)