Files
openidea/scripts/dev-session.ts
T
l0ng-ai a4b62ad27f fix(dev): apply the worktree's Vite URL when dev:app is given a project path (#3212)
`pnpm dev:app <path>` appended `--config {devUrl}` after the path, and `tauri dev`
hands the first positional argument and everything after it to the app. The config was
dropped: tauri ran `pnpm dev` on port 1420 as well, and a full reload loaded that server
instead of the worktree's, failing with "Could not load startup script". The config now goes
before the first positional argument.
2026-11-14 01:10:20 +08:00

103 lines
3.4 KiB
TypeScript

import { realpathSync } from "node:fs";
import { createConnection, createServer, type Server } from "node:net";
import { fileURLToPath } from "node:url";
export const worktreeRoot = realpathSync(fileURLToPath(new URL("..", import.meta.url)));
export function worktreeId(root: string): string {
let hash = 0xcbf29ce484222325n;
for (const byte of Buffer.from(root)) {
hash = BigInt.asUintN(64, (hash ^ BigInt(byte)) * 0x100000001b3n);
}
return `ai.l0ng.openidea.dev.w${hash.toString(16).padStart(16, "0")}`;
}
export interface DevSession {
worktree: string;
identifier: string;
pid: number;
devUrl?: string;
}
export function preferredVitePort(identifier: string): number {
return 15000 + (Number.parseInt(identifier.slice(-8), 16) % 20000);
}
function readSession(port: number): Promise<DevSession | undefined> {
return new Promise((resolve) => {
const socket = createConnection({ host: "127.0.0.1", port });
let data = "";
const finish = (session?: DevSession) => {
socket.destroy();
resolve(session);
};
socket.setTimeout(1000, () => finish());
socket.on("error", () => finish());
socket.on("data", (chunk) => {
data += chunk.toString();
if (data.length > 16384) finish();
});
socket.on("end", () => {
try {
finish(JSON.parse(data));
} catch {
finish();
}
});
});
}
// A live OS listener is the lock: no stale PID files after crashes or PID reuse.
export async function claimSession(
session: DevSession,
firstPort = 43000 + (Number.parseInt(session.identifier.slice(-8), 16) % 10000),
): Promise<{ server: Server } | { existing: DevSession }> {
for (let offset = 0; offset < 100; offset++) {
const port = firstPort + offset;
const server = createServer((socket) => {
socket.on("error", () => {});
socket.end(JSON.stringify(session));
});
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.removeListener("error", reject);
resolve();
});
});
return { server };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EADDRINUSE" && code !== "EACCES") throw error;
const existing = await readSession(port);
if (existing?.worktree === session.worktree && existing.identifier === session.identifier) {
return { existing };
}
}
}
throw new Error("No available development session port");
}
/** `tauri dev` options that take a value as the next argument. */
const VALUE_OPTIONS = new Set(["-c", "--config", "-t", "--target", "-f", "--features", "-r", "--runner", "--port"]);
/**
* `args` with the session's Vite URL as the last `--config`, placed before the first
* positional argument: `tauri dev` passes everything from there on to the app, so a config
* after `pnpm dev:app <project path>` would be ignored.
*/
export function withDevConfig(args: string[], devUrl: string): string[] {
let index = 1;
while (index < args.length && args[index] !== "--" && args[index]!.startsWith("-")) {
index += VALUE_OPTIONS.has(args[index]!) ? 2 : 1;
}
index = Math.min(index, args.length);
return [
...args.slice(0, index),
"--config",
JSON.stringify({ build: { beforeDevCommand: "", devUrl } }),
...args.slice(index),
];
}