Add esbuild production bundle and multi-stage slim Docker image.

Bundle server (and mcp entry) into a single JS file with deps inlined, minify UI into one HTML, and ship only dist/ in the runtime image.
This commit is contained in:
2026-08-07 11:09:02 +02:00
parent 7b739607ff
commit 0dd548339c
7 changed files with 663 additions and 24 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/ node_modules/
coverage/ coverage/
dist/
data/ data/
.env .env
.env.* .env.*

View File

@@ -1,8 +1,20 @@
FROM node:20-alpine # Multi-stage: esbuild produces a single Node file (deps inlined) + minified UI.
# Final image has no node_modules — only dist/ + runtime Node.
FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci
COPY src/ ./src/ COPY src/ ./src/
COPY ui/ ./ui/ COPY ui/ ./ui/
COPY scripts/ ./scripts/
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
# Runtime is only the bundled server + UI (no node_modules, no source, no mcp-only entry)
COPY --from=build /app/dist/server.js ./dist/server.js
COPY --from=build /app/dist/ui ./dist/ui
EXPOSE 3001 EXPOSE 3001
CMD ["node", "src/index.js"] CMD ["node", "dist/server.js"]

View File

@@ -19,22 +19,34 @@ Webshare download links expire after roughly ten minutes, so the torrent never e
## Layout ## Layout
``` ```
src/ src/ # source (CommonJS modules)
index.js # Express app + optional Sonarr missing-episode search ui/ # Web UI source (index.html, styles.css, scripts.js)
webshare.js # salt/login, search, file_link scripts/build.mjs # esbuild: single server.js + minified single-file UI
md5crypt.js # Unix $1$ MD5-crypt (Webshare password digest) dist/ # build output (gitignored; produced by npm run build / Docker)
torznab.js # caps + search feed XML server.js # one file: app + all npm dependencies
torrent.js # bencode + fake torrent + info-hash mcp.js # optional MCP-only entry
qbt.js # qBittorrent Web API façade + download queue ui/index.html # CSS+JS inlined
mcp-server.js # MCP tool registration (shared)
mcp-http.js # Streamable HTTP transport mount for Express
mcp.js # Optional MCP-only HTTP process
ui/ # Web UI: search & queue management
__tests__/ __tests__/
compose.yaml compose.yaml
Dockerfile Dockerfile # multi-stage: build with esbuild, runtime = Node + dist only
``` ```
## Build (smaller image / single JS)
```bash
npm install
npm run build # → dist/server.js (~2 MB, all deps bundled) + dist/ui/index.html
npm start # node dist/server.js
npm run start:dev # unbundled src/ for local hacking
```
**esbuild** bundles the server (and optional `mcp` entry) into a **single `.js` file** with
dependencies inlined — no `node_modules` needed at runtime. The Web UI is minified into
**one** `dist/ui/index.html` (CSS and JS inlined).
Docker uses a multi-stage build: final image copies only `dist/`, not source or
`node_modules`, which keeps the image much smaller than `npm ci --omit=dev`.
## Configuration ## Configuration
Environment variables (required unless noted): Environment variables (required unless noted):
@@ -71,9 +83,11 @@ See `compose.yaml`. Override ports, `BASE_URL`, Sonarr settings, and volume moun
```bash ```bash
npm install npm install
npm run build
export WEBSHARE_USERNAME=... export WEBSHARE_USERNAME=...
export WEBSHARE_PASSWORD=... export WEBSHARE_PASSWORD=...
npm start npm start
# or: npm run start:dev # no build step, uses src/
``` ```
## Sonarr / Radarr ## Sonarr / Radarr

488
package-lock.json generated
View File

@@ -15,7 +15,11 @@
"xml2js": "^0.6.0", "xml2js": "^0.6.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"bin": {
"webshare-mcp": "dist/mcp.js"
},
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"supertest": "^7.2.2" "supertest": "^7.2.2"
}, },
@@ -603,6 +607,448 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
@@ -2946,6 +3392,48 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",

View File

@@ -1,15 +1,19 @@
{ {
"name": "webshare-api", "name": "webshare-api",
"version": "1.0.0", "version": "1.0.0",
"description": "Webshare.cz HTTP API: Torznab indexer, fake qBittorrent client, and MCP server for AI agents", "description": "Webshare.cz HTTP API: Torznab indexer, fake qBittorrent client, Web UI, and MCP server for AI agents",
"main": "src/index.js", "main": "dist/server.js",
"bin": { "bin": {
"webshare-mcp": "src/mcp.js" "webshare-mcp": "dist/mcp.js"
}, },
"scripts": { "scripts": {
"start": "node src/index.js", "build": "node scripts/build.mjs",
"mcp": "node src/mcp.js", "start": "node dist/server.js",
"test": "jest --coverage --runInBand" "start:dev": "node src/index.js",
"mcp": "node dist/mcp.js",
"mcp:dev": "node src/mcp.js",
"test": "jest --coverage --runInBand",
"prepublishOnly": "npm run build"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -23,7 +27,15 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"supertest": "^7.2.2" "supertest": "^7.2.2"
} },
"files": [
"dist",
"src",
"ui",
"LICENSE",
"README.md"
]
} }

105
scripts/build.mjs Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* Production build: one Node bundle (all deps inlined) + minified single-file Web UI.
*
* Outputs:
* dist/server.js — main app (Torznab, qBt, MCP, UI API)
* dist/mcp.js — optional MCP-only process
* dist/ui/index.html — Search/Queue UI (CSS+JS inlined)
*/
import * as esbuild from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dist = path.join(root, 'dist');
const distUi = path.join(dist, 'ui');
fs.rmSync(dist, { recursive: true, force: true });
fs.mkdirSync(distUi, { recursive: true });
const nodeBundle = {
bundle: true,
platform: 'node',
target: 'node20',
format: 'cjs',
minify: true,
legalComments: 'none',
sourcemap: false,
// Keep Node builtins external; pull npm deps into the file.
packages: 'bundle',
};
async function bundleServer() {
const result = await esbuild.build({
...nodeBundle,
entryPoints: [path.join(root, 'src/index.js')],
outfile: path.join(dist, 'server.js'),
metafile: true,
logLevel: 'info',
});
const out = result.metafile.outputs[Object.keys(result.metafile.outputs)[0]];
console.log(`server.js ${(out.bytes / 1024 / 1024).toFixed(2)} MB`);
}
async function bundleMcp() {
const result = await esbuild.build({
...nodeBundle,
entryPoints: [path.join(root, 'src/mcp.js')],
outfile: path.join(dist, 'mcp.js'),
banner: { js: '#!/usr/bin/env node\n' },
metafile: true,
logLevel: 'info',
});
const out = result.metafile.outputs[Object.keys(result.metafile.outputs)[0]];
console.log(`mcp.js ${(out.bytes / 1024 / 1024).toFixed(2)} MB`);
try {
fs.chmodSync(path.join(dist, 'mcp.js'), 0o755);
} catch {
/* windows */
}
}
async function bundleUi() {
const cssSrc = fs.readFileSync(path.join(root, 'ui/styles.css'), 'utf8');
const jsSrc = fs.readFileSync(path.join(root, 'ui/scripts.js'), 'utf8');
const htmlSrc = fs.readFileSync(path.join(root, 'ui/index.html'), 'utf8');
const css = await esbuild.transform(cssSrc, { loader: 'css', minify: true });
const js = await esbuild.transform(jsSrc, {
loader: 'js',
minify: true,
target: ['es2020'],
});
// Prefer a single HTML payload (no extra static requests) for the tiny UI.
let html = htmlSrc
.replace(/<link rel="stylesheet" href="styles\.css">\s*/i, '')
.replace(/<script src="scripts\.js"><\/script>\s*/i, '');
// Inject minified assets before </head> and </body>
if (!html.includes('</head>')) {
throw new Error('ui/index.html missing </head>');
}
html = html.replace(
'</head>',
`<style>${css.code}</style>\n</head>`,
);
if (!html.includes('</body>')) {
throw new Error('ui/index.html missing </body>');
}
html = html.replace(
'</body>',
`<script>${js.code}</script>\n</body>`,
);
const outHtml = path.join(distUi, 'index.html');
fs.writeFileSync(outHtml, html);
console.log(`ui/index.html ${(Buffer.byteLength(html) / 1024).toFixed(1)} KB (inlined css+js)`);
}
await bundleServer();
await bundleMcp();
await bundleUi();
console.log('build ok → dist/');

View File

@@ -1,3 +1,5 @@
const fs = require('fs');
const path = require('path');
const express = require('express'); const express = require('express');
const { WebshareClient } = require('./webshare'); const { WebshareClient } = require('./webshare');
const { caps, feed } = require('./torznab'); const { caps, feed } = require('./torznab');
@@ -113,8 +115,13 @@ app.get('/resolve/:ident', async (req, res) => {
} }
}); });
// ── UI static files ──────────────────────────────────────────────────────── // ── UI static files (dev: ../ui from src/; production: dist/ui next to server.js)
app.use(express.static('ui')); const uiRoot =
process.env.UI_DIR ||
(fs.existsSync(path.join(__dirname, 'ui'))
? path.join(__dirname, 'ui')
: path.join(__dirname, '..', 'ui'));
app.use(express.static(uiRoot));
// ── UI API: JSON search ─────────────────────────────────────────────────── // ── UI API: JSON search ───────────────────────────────────────────────────
app.get('/api/search', async (req, res) => { app.get('/api/search', async (req, res) => {