📦 Add Linux packaging (AppImage/Flatpak/DEB)

🧹 Minor Codebase cleanup
 Implement automatic starting of the relay
This commit is contained in:
Danny Morabito 2025-02-24 21:48:11 +01:00
parent 89fcaa9aa6
commit f402ff04ab
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
19 changed files with 519 additions and 119 deletions

2
.gitignore vendored
View file

@ -4,6 +4,8 @@ logs
node_modules
dist
out
extras
# Editor directories and files
.vscode/*

29
electron-builder.yaml Normal file
View file

@ -0,0 +1,29 @@
appId: com.arx-ccn.eve
productName: Eve
executableName: Eve
icon: public/icon512x512.png
linux:
category: Network
target:
- AppImage
- flatpak
- deb
desktop:
desktopActions: {}
extraFiles:
- from: extras/linux/relay
to: usr/bin/eve-relay
flatpak:
runtimeVersion: "24.08"
license: "LICENSE"
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**

51
electron.vite.config.ts Normal file
View file

@ -0,0 +1,51 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { resolve } from "path";
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
lib: {
entry: resolve(__dirname, "src/electron/main.ts"),
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
lib: {
entry: resolve(__dirname, "src/electron/preload.ts"),
},
},
},
renderer: {
root: resolve(__dirname, "src"),
build: {
target: "es2024",
rollupOptions: {
input: {
index: resolve(__dirname, "src/index.html"),
},
},
},
resolve: {
alias: {
"@utils": fileURLToPath(new URL("./src/utils", import.meta.url)),
"@routes": fileURLToPath(new URL("./src/routes", import.meta.url)),
"@styles": fileURLToPath(new URL("./src/styles", import.meta.url)),
"@widgets": fileURLToPath(
new URL("./src/components/Widgets", import.meta.url)
),
"@components": fileURLToPath(
new URL("./src/components", import.meta.url)
),
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
// server: {
// port: 5173,
// open: true,
// },
},
});

View file

@ -1,16 +1,31 @@
{
"name": "eve",
"private": true,
"version": "0.0.0",
"description": "Closed Community Networks",
"version": "0.0.1",
"type": "module",
"browserslist": ["not dead"],
"license": "AGPL-3.0-only",
"browserslist": [
"electron >= 22.0.0"
],
"main": "./out/main/main.js",
"homepage": "https://arx-ccn.com/eve",
"author": {
"name": "arx-ccn",
"email": "developers@arx-ccn.com"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"build": "tsc && electron-vite build",
"build:linux": "bun run build && electron-builder --linux",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"prebuild": "electron-vite build"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"electron-builder": "^25.1.8",
"electron-vite": "^3.0.0",
"electron": "^34.2.0",
"@tsconfig/node22": "^22.0.0",
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.10.2",
@ -21,11 +36,13 @@
},
"dependencies": {
"@lit-labs/motion": "^1.0.8",
"@noble/ciphers": "^1.2.1",
"@nostr-dev-kit/ndk": "^2.10.7",
"@nostr/tools": "npm:@jsr/nostr__tools",
"@open-wc/lit-helpers": "^0.7.0",
"@std/encoding": "npm:@jsr/std__encoding",
"iconify-icon": "^2.2.0",
"lit": "^3.2.1",
"markdown-it": "^14.1.0",
"nostr-tools": "^2.10.4"
"markdown-it": "^14.1.0"
}
}

BIN
public/icon512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View file

@ -1,12 +1,14 @@
import { LitElement, html, css } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import { animate } from "@lit-labs/motion";
import * as nostrTools from "nostr-tools/pure";
import * as nip06 from "nostr-tools/nip06";
import * as nip19 from "nostr-tools/nip19";
import * as nip49 from "nostr-tools/nip49";
import * as nostrTools from "@nostr/tools/pure";
import * as nip06 from "@nostr/tools/nip06";
import * as nip19 from "@nostr/tools/nip19";
import * as nip49 from "@nostr/tools/nip49";
import { ndk, setSigner } from "@/ndk";
import { NDKEvent, NDKKind, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import { encodeBase64 } from "@std/encoding/base64";
import { randomBytes } from "@noble/ciphers/webcrypto";
@customElement("arx-initial-setup")
export class InitialSetup extends LitElement {
@ -17,6 +19,15 @@ export class InitialSetup extends LitElement {
@state() private profileImage = "";
@state() private lightningAddress = "";
get encryptionPassphrase() {
let encryptionPassphrase = localStorage.getItem("encryption_key");
if (!encryptionPassphrase) {
encryptionPassphrase = encodeBase64(randomBytes(32));
localStorage.setItem("encryption_key", encryptionPassphrase);
}
return encryptionPassphrase;
}
static override styles = css`
:host {
display: block;
@ -409,32 +420,9 @@ export class InitialSetup extends LitElement {
`;
}
private getSetupCode() {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes("mac")) {
return `
mkdir -p ~/.config/arx/eve && cd ~/.config/arx/eve
echo "${this.seedPhrase}" > ccn.seed
launchctl load ~/Library/LaunchAgents/com.user.eve-relay.plist
launchctl start com.user.eve-relay
`
.split("\n")
.map((x) => x.trim())
.join("\n");
}
if (userAgent.includes("linux")) {
return `
mkdir -p ~/.config/arx/eve && cd ~/.config/arx/eve
echo "${this.seedPhrase}" > ccn.seed
systemctl --user enable eve-relay.service
systemctl --user start eve-relay.service
`
.split("\n")
.map((x) => x.trim())
.join("\n");
}
return "Unsupported OS";
private async startRelay() {
await window.relay.writeSeed(this.seedPhrase);
await window.relay.start(this.encryptionPassphrase);
}
private renderPageThree() {
@ -446,11 +434,10 @@ export class InitialSetup extends LitElement {
During this alpha phase, manual relay configuration is required.
This process will be automated in future releases.
</p>
<p>Open your terminal and run following commands:</p>
<pre>
<code>${this.getSetupCode()}</code>
</pre
>
<p>Please press the button below to start the relay.</p>
<button @click=${() => this.startRelay()} class="button primary">
Start Relay
</button>
<p>
Having trouble? Our team is here to help if you encounter any
issues.
@ -586,15 +573,11 @@ export class InitialSetup extends LitElement {
}
private async goToFinalStep() {
let encryptionPassphrase = localStorage.getItem("encryption_key");
if (!encryptionPassphrase) {
encryptionPassphrase =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
localStorage.setItem("encryption_key", encryptionPassphrase);
}
const randomPrivateKey = nostrTools.generateSecretKey();
const encryptedNsec = nip49.encrypt(randomPrivateKey, encryptionPassphrase);
const encryptedNsec = nip49.encrypt(
randomPrivateKey,
this.encryptionPassphrase
);
const npub = nip19.npubEncode(nostrTools.getPublicKey(randomPrivateKey));
if (!this.lightningAddress) this.lightningAddress = `${npub}@npub.cash`;
@ -602,6 +585,7 @@ export class InitialSetup extends LitElement {
localStorage.setItem("ncryptsec", encryptedNsec);
setSigner(new NDKPrivateKeySigner(randomPrivateKey));
await ndk.connect(5000);
const event = new NDKEvent(ndk);
event.kind = NDKKind.Metadata;

View file

@ -1,18 +1,18 @@
import { css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import MarkdownIt, { type StateCore, type Token } from 'markdown-it';
import { css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import MarkdownIt, { type StateCore, type Token } from "markdown-it";
function nostrPlugin(md: MarkdownIt): void {
const npubRegex = /(npub[0-9a-zA-Z]{59})/g;
md.core.ruler.after('inline', 'nostr_npub', (state: StateCore): boolean => {
md.core.ruler.after("inline", "nostr_npub", (state: StateCore): boolean => {
for (const token of state.tokens) {
if (token.type === 'inline' && token.children) {
if (token.type === "inline" && token.children) {
for (let i = 0; i < token.children.length; i++) {
const child = token.children[i];
if (child.type === 'text') {
if (child.type === "text") {
const matches = child.content.match(npubRegex);
if (!matches) continue;
@ -21,28 +21,29 @@ function nostrPlugin(md: MarkdownIt): void {
child.content.replace(
npubRegex,
// @ts-ignore this is an issue with the types
(match: string, npub: string, offset: number) => {
if (offset > lastIndex) {
const textToken = new state.Token('text', '', 0);
const textToken = new state.Token("text", "", 0);
textToken.content = child.content.slice(lastIndex, offset);
newTokens.push(textToken);
}
const linkOpen = new state.Token('link_open', 'a', 1);
linkOpen.attrs = [['href', `nostr:${npub}`]];
const linkOpen = new state.Token("link_open", "a", 1);
linkOpen.attrs = [["href", `nostr:${npub}`]];
const text = new state.Token('text', '', 0);
const text = new state.Token("text", "", 0);
text.content = npub;
const linkClose = new state.Token('link_close', 'a', -1);
const linkClose = new state.Token("link_close", "a", -1);
newTokens.push(linkOpen, text, linkClose);
lastIndex = offset + match.length;
},
}
);
if (lastIndex < child.content.length) {
const textToken = new state.Token('text', '', 0);
const textToken = new state.Token("text", "", 0);
textToken.content = child.content.slice(lastIndex);
newTokens.push(textToken);
}
@ -58,7 +59,7 @@ function nostrPlugin(md: MarkdownIt): void {
});
}
@customElement('arx-markdown-content')
@customElement("arx-markdown-content")
export class MarkdownContent extends LitElement {
private md = new MarkdownIt({
html: false,
@ -68,7 +69,7 @@ export class MarkdownContent extends LitElement {
});
@property({ type: String })
content = '';
content = "";
static override styles = [
css`
@ -94,7 +95,16 @@ export class MarkdownContent extends LitElement {
display: contents;
}
h1, h2, h3, h4, h5, h6, code, ul, ol, blockquote {
h1,
h2,
h3,
h4,
h5,
h6,
code,
ul,
ol,
blockquote {
width: 100%;
margin: 0;
display: block;

77
src/electron/main.ts Normal file
View file

@ -0,0 +1,77 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { optimizer, is } from "@electron-toolkit/utils";
import { RelayManager } from "./relayManager";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
const relay = new RelayManager();
ipcMain.handle("relay:writeSeed", (_, ...args: any) => {
if (!args[0]) throw new Error("No seed provided");
const seed = args[0] as string;
const configPath = path.join(os.homedir(), ".config", "arx", "Eve");
const seedPath = path.join(configPath, "ccn.seed");
fs.mkdirSync(configPath, { recursive: true });
fs.writeFileSync(seedPath, seed);
});
ipcMain.handle("relay:start", (_, ...args: any) => {
if (!args[0]) throw new Error("No encryption key provided");
const encryptionKey = args[0] as string;
return relay.start(encryptionKey);
});
ipcMain.handle("relay:stop", () => {
return relay.stop();
});
ipcMain.handle("relay:status", () => {
return {
running: relay.isRunning,
pid: relay.pid,
logs: relay.getLogs(),
};
});
ipcMain.handle("relay:getLogs", () => {
return relay.getLogs();
});
function createWindow(): void {
const mainWindow = new BrowserWindow({
width: 1024,
height: 768,
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, "../preload/preload.mjs"),
sandbox: false,
},
});
mainWindow.on("ready-to-show", () => {
mainWindow.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
if (is.dev && process.env["ELECTRON_RENDERER_URL"])
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
else mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
}
app.whenReady().then(() => {
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
createWindow();
});
app.on("window-all-closed", () => {
app.quit();
});

21
src/electron/preload.ts Normal file
View file

@ -0,0 +1,21 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("relay", {
writeSeed: (seed: string) => ipcRenderer.invoke("relay:writeSeed", seed),
start: (encryptionKey: string) =>
ipcRenderer.invoke("relay:start", encryptionKey),
stop: () => ipcRenderer.invoke("relay:stop"),
getStatus: () => ipcRenderer.invoke("relay:status"),
getLogs: () => ipcRenderer.invoke("relay:logs"),
});
} catch (error) {
console.error(error);
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI;
}

View file

@ -0,0 +1,207 @@
import { spawn, ChildProcess } from "child_process";
import { join } from "path";
import { is } from "@electron-toolkit/utils";
type PackageEnvironment = "flatpak" | "appimage" | "system" | "dev";
export class RelayManager {
private process: ChildProcess | null;
private readonly relayPath: string;
private isShuttingDown: boolean;
private restartAttempts: number;
private readonly maxRestartAttempts: number;
private readonly restartDelay: number;
private restartTimeout: NodeJS.Timeout | null;
private relayLogs: string[];
private readonly maxLogs: number;
private encryptionKey: string | null;
/**
* Checks if the relay is currently running.
*
* @returns {boolean} True if the relay is running, otherwise false.
*/
get isRunning(): boolean {
return !!this.process;
}
/**
* Retrieves the process identifier (PID) of the running relay process.
*
* @returns {number | undefined} The PID of the relay process if it is running, otherwise undefined.
*/
get pid(): number | undefined {
return this.process?.pid;
}
constructor(maxRestartAttempts = 5, restartDelay = 1000, maxLogs = 100) {
this.process = null;
this.relayPath = this.getRelayPath();
this.isShuttingDown = false;
this.restartAttempts = 0;
this.maxRestartAttempts = maxRestartAttempts;
this.restartDelay = restartDelay;
this.restartTimeout = null;
this.relayLogs = [];
this.maxLogs = maxLogs;
this.encryptionKey = null;
}
private detectEnvironment(): PackageEnvironment {
if (is.dev) return "dev";
if (process.env.FLATPAK_ID) return "flatpak";
if (process.env.APPIMAGE) return "appimage";
return "system";
}
private getRelayPath(): string {
const environment = this.detectEnvironment();
switch (environment) {
case "dev":
return join(__dirname, "../../extras/linux/relay");
case "flatpak":
return "/app/lib/com.arx_ccn.eve/usr/bin/eve-relay";
case "appimage":
return join(process.env.APPDIR || "", "usr/bin/eve-relay");
case "system":
return "/usr/bin/eve-relay";
}
}
private handleProcessExit(code: number | null): void {
this.process = null;
console.log(`Relay exited with code ${code}`);
if (!this.isShuttingDown) this.restartProcess();
}
private restartProcess(): void {
if (this.restartAttempts >= this.maxRestartAttempts) {
console.error(
`Failed to restart relay after ${this.maxRestartAttempts} attempts`
);
return;
}
this.restartAttempts++;
console.log(
`Attempting restart #${this.restartAttempts} in ${this.restartDelay}ms...`
);
if (this.restartTimeout) clearTimeout(this.restartTimeout);
this.restartTimeout = setTimeout(() => {
this.start(this.encryptionKey!);
}, this.restartDelay);
}
private addLog(data: string): void {
this.relayLogs.push(data);
if (this.relayLogs.length > this.maxLogs) {
this.relayLogs = this.relayLogs.slice(-this.maxLogs);
}
}
/**
* Start the Eve Relay.
*
* If the process is already running, do nothing.
*
* Logs from the Relay process are captured and can be retrieved with the
* `getLogs()` method.
*
* If the process exits unexpectedly, it will be restarted according to the
* configured restart policy.
*
* @param {string} encryptionKey - The key to use for encrypting data saved in
* the Relay.
*/
public start(encryptionKey: string): void {
if (this.process) return;
this.encryptionKey = encryptionKey;
try {
this.process = spawn(this.relayPath, [], {
env: {
...process.env,
LD_LIBRARY_PATH: process.env.LD_LIBRARY_PATH,
PATH: process.env.PATH,
ENCRYPTION_KEY: encryptionKey,
},
});
if (this.process.stdout) {
this.process.stdout.on("data", (data: Buffer) => {
const logLine = data.toString().trim();
this.addLog(logLine);
console.log(logLine);
});
}
if (this.process.stderr) {
this.process.stderr.on("data", (data: Buffer) => {
const logLine = data.toString().trim();
this.addLog(logLine);
console.error(logLine);
});
}
this.process.on("error", (err: Error) => {
console.error(`Failed to start Relay: ${err.message}`);
this.process = null;
this.restartProcess();
});
this.process.on("exit", this.handleProcessExit.bind(this));
if (this.process.pid) {
this.restartAttempts = 0;
}
} catch (error) {
console.error(
`Error starting Relay: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
this.restartProcess();
}
}
/**
* Stop the Eve Relay.
*
* If the Relay process is currently running, it will be terminated.
*
* If the process is not running, do nothing.
*
* All pending restarts will be cancelled.
*/
public stop(): void {
this.isShuttingDown = true;
if (this.restartTimeout) {
clearTimeout(this.restartTimeout);
this.restartTimeout = null;
}
if (this.process) {
this.process.kill();
this.process = null;
}
}
/**
* Returns a copy of all the log lines the Relay has written to date.
*
* The returned array is a copy of the internal log state, and will not be
* modified by future log activity.
*
* @returns A copy of all log lines from the Relay since it was last started.
*/
public getLogs(): string[] {
return [...this.relayLogs];
}
}

View file

@ -7,6 +7,6 @@
</head>
<body>
<script src="/src/main.ts" type="module"></script>
<script src="main.ts" type="module"></script>
</body>
</html>

View file

@ -7,8 +7,22 @@ import "@components/NostrProfile";
import "@components/Breadcrumbs";
import "@components/Header";
import "@routes/router";
import "@components/LoadingView";
import type EveRouter from "@routes/router";
import { sleep } from "./utils/sleep";
async function startRelay() {
if (localStorage.getItem("ncryptsec")) {
const loadingIndicator = document.createElement("arx-loading-view");
document.body.appendChild(loadingIndicator);
await window.relay.start(localStorage.getItem("encryption_key")!);
await sleep(5000);
loadingIndicator.remove();
}
}
startRelay().then(() => {
const router = document.createElement("arx-eve-router") as EveRouter;
router.ccnSetup = localStorage.getItem("ncryptsec");
router.ccnSetup = !!localStorage.getItem("ncryptsec");
document.body.appendChild(router);
});

View file

@ -1,5 +1,5 @@
import NDK, { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import * as nip49 from "nostr-tools/nip49";
import * as nip49 from "@nostr/tools/nip49";
export const ndk = new NDK({
explicitRelayUrls: ["ws://localhost:6942"],
@ -13,8 +13,9 @@ export async function getSigner() {
await ndk.connect();
if (ndk.signer) return;
const encryptionPassphrase = localStorage.getItem("encryption_key");
if (!encryptionPassphrase) throw new Error("Encryption passphrase not found");
const signer = new NDKPrivateKeySigner(
nip49.decrypt(localStorage.getItem("ncryptsec"), encryptionPassphrase)
nip49.decrypt(localStorage.getItem("ncryptsec")!, encryptionPassphrase)
);
setSigner(signer);
}

21
src/relayManager.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
interface RelayStatus {
running: boolean;
pid: number | null;
logs: string[];
}
interface RelayBridge {
writeSeed: (seed: string) => Promise<void>;
start: (encryptionKey: string) => Promise<void>;
stop: () => Promise<void>;
getStatus: () => Promise<RelayStatus>;
getLogs: () => Promise<string[]>;
}
declare global {
interface Window {
relay: RelayBridge;
}
}
export {};

View file

@ -1,7 +1,6 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import { styleMap } from "lit/directives/style-map.js";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { getUserProfile } from "../ndk";

View file

@ -78,7 +78,7 @@ export default class EveRouter extends LitElement {
private currentIndex = -1;
@property()
private ccnSetup = false;
public ccnSetup = false;
private beforeEachGuards: ((to: Route, from: Route | null) => boolean)[] = [];
private afterEachHooks: ((to: Route, from: Route | null) => void)[] = [];
@ -116,6 +116,8 @@ export default class EveRouter extends LitElement {
constructor() {
super();
this.initializeRouter();
if (this.ccnSetup)
window.relay.start(localStorage.getItem("encryption_key")!);
}
override connectedCallback(): void {
@ -276,7 +278,7 @@ export default class EveRouter extends LitElement {
?canGoBack=${this.currentIndex > 0}
?canGoForward=${this.currentIndex < this.history.length - 1}
url="eve://${this.currentPath}"
@navigate=${(e) => this.navigate(e.detail)}
@navigate=${(e: CustomEvent) => this.navigate(e.detail)}
@go-back=${this.goBack}
@go-forward=${this.goForward}
title="Eve"

3
src/utils/sleep.ts Normal file
View file

@ -0,0 +1,3 @@
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -31,5 +31,6 @@
"@components/*": ["./src/components/*"],
"@widgets/*": ["./src/components/Widgets/*"]
}
}
},
"include": ["./src/**/*"]
}

View file

@ -1,39 +0,0 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import { resolve } from "path";
export default defineConfig({
plugins: [],
build: {
target: "es2024",
outDir: "dist",
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
resolve: {
alias: {
"@utils": fileURLToPath(new URL("./src/utils", import.meta.url)),
"@routes": fileURLToPath(new URL("./src/routes", import.meta.url)),
"@styles": fileURLToPath(new URL("./src/styles", import.meta.url)),
"@widgets": fileURLToPath(
new URL("./src/components/Widgets", import.meta.url)
),
"@components": fileURLToPath(
new URL("./src/components", import.meta.url)
),
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
port: 5173,
open: true,
},
optimizeDeps: {
esbuildOptions: {
target: "es2024",
},
},
});