Files
asepharyana c8473b0610 build(nix): fix binding link path — LDC_LIB is full .so path
binding.gyp appended '/libdatachannel.so.0.24.0' to LDC_LIB; nixpkgs output
layout is <out>/lib/libdatachannel.so.0.24.1. Make LDC_LIB the complete
library path (env or default) and drop the append.
2026-08-11 18:54:12 +07:00

388 lines
16 KiB
Nix

{
description = "GMW — Bete Discord Moderation Bot (Nix build)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
let
pkgs = import nixpkgs { inherit system; };
# libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built
# against this host's glibc and ships both lib + dev headers, so the
# binding links cleanly inside the Nix sandbox (no manual cmake build).
libdatachannel = pkgs.libdatachannel;
# Source filter: `path:` literals do NOT respect .gitignore by default,
# so a dirty local out/ (stale chunks from previous builds) leaks into
# the sandbox. Filter out build artifacts explicitly.
filterSource = { dir, ignore }: builtins.path {
path = dir;
name = "source";
filter = (path: type: let base = baseNameOf path; in !(builtins.elem base ignore));
};
frontendSrc = filterSource {
dir = ./services/frontend;
ignore = [ "out" ".next" "node_modules" "pnpm-lock.yaml" ];
};
# OpenSSL headers (.dev output) + STATIC libs (pkgsStatic.openssl.out —
# node-datachannel's CMakeLists sets OPENSSL_USE_STATIC_LIBS=TRUE, and
# the default `pkgs.openssl` resolves to `bin` which has no lib/) merged
# into one tree so FindOpenSSL resolves both via OPENSSL_ROOT_DIR.
opensslDevEnv = pkgs.symlinkJoin {
name = "openssl-dev-env";
paths = [ pkgs.pkgsStatic.openssl.out pkgs.openssl.dev ];
};
# ---- Shared build tools ----
nodejs = pkgs.nodejs_22;
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
pnpmInstall = ''
export HOME=$TMPDIR/home
export npm_config_cache=$TMPDIR/npm-cache
mkdir -p $npm_config_cache
# SSL/TLS certs (Nix sandbox doesn't have system CA bundle)
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
# pnpm uses node-gyp for native addons — provide build tools
export npm_config_build_from_source=true
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
pnpm install --no-frozen-lockfile --ignore-scripts 2>&1
# Build native addons that need compilation
pnpm rebuild 2>&1 || true
'';
# Shrink the shipped node_modules to production deps only. The full
# install's .pnpm virtual store carries dev-only packages (biome,
# typescript, esbuild, drizzle-kit, vitest, ... ~150MB+) that are never
# needed at runtime, so we delete every .pnpm dir that is not part of
# the resolved production graph (`pnpm list --prod`).
#
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
# are only reachable through it), silently breaking voice/screenshare.
# Instead we keep the full install's symlink layout and only prune
# orphaned package dirs + broken symlinks.
# Must run AFTER tsc (typescript is a devDep) and after native builds.
pruneProd = ''
echo "=== Pruning devDependencies (production-only node_modules) ==="
pnpm list --prod --depth 999 --parseable 2>/dev/null \
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
( cd node_modules/.pnpm \
&& for d in */; do \
d="''${d%/}"; \
[ "$d" = "node_modules" ] && continue; \
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
done ) || true
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
# which would otherwise fail the fixupPhase.
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
du -sh node_modules
'';
# ---- Backend ----
backend = pkgs.stdenv.mkDerivation {
pname = "gmw-backend";
version = "1.0.0";
src = ./services/backend;
nativeBuildInputs = [ nodejs pnpm pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + ''
echo "=== Compiling TypeScript ==="
npx tsc 2>&1
echo "=== Fixing @/ path aliases to relative paths ==="
node -e "
const fs = require('fs');
const path = require('path');
let count = 0;
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.js')) {
const c = fs.readFileSync(p, 'utf8');
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
const n = c.replace(pat, (m, p1) => {
const target = path.join('dist', p1) + '.js';
const rel = path.relative(path.dirname(p), target);
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
});
if (n !== c) { fs.writeFileSync(p, n); count++; }
}
}
}
walk('dist');
console.log('Fixed ' + count + ' files');
"
echo "=== Build complete ==="
'' + pruneProd;
installPhase = ''
mkdir -p $out/lib/gmw-backend
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-backend/
mkdir -p $out/bin
cat > $out/bin/gmw-backend << WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-backend
exec ${nodejs}/bin/node dist/index.js
WRAPPER
chmod +x $out/bin/gmw-backend
'';
meta = {
description = "GMW Backend — Express HTTP/WS server";
platforms = pkgs.lib.platforms.linux;
};
};
# ---- Discord Gateway ----
discord-gateway = pkgs.stdenv.mkDerivation {
pname = "gmw-discord-gateway";
version = "1.0.0";
src = ./services/discord-gateway;
nativeBuildInputs = [
nodejs pnpm
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cmake
pkgs.rustc pkgs.cargo
pkgs.pkg-config
pkgs.openssl
pkgs.openssl.dev
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
pkgs.git # libdatachannel FetchContent clones from GitHub
pkgs.cacert
];
# Runtime tools for the voice pipeline: ffmpeg (mic transmit encode,
# music stream decode, segment muxing) and yt-dlp (YouTube/Spotify/
# search media resolution). Must be on PATH inside the wrapper below.
buildInputs = [ pkgs.ffmpeg-headless pkgs.yt-dlp ];
# cmake is only needed for node-datachannel's postinstall build —
# do NOT let stdenv run its own cmake configure phase on the source.
dontUseCmakeConfigure = true;
buildPhase = pnpmInstall + ''
echo "=== Building native voice deps ==="
# pnpm rebuild aborts on the first failing package and runs scripts
# from the wrong cwd — build each native dep explicitly with its own
# install script. Each failure is tolerated (|| true); the packages
# that matter (opus) are verified at runtime.
for pkg in \
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus
do
if [ -d "$pkg" ]; then
echo "--- native build: $pkg ---"
(cd "$pkg" && npm run install 2>&1 || true)
fi
done
echo "=== Building libdatachannel-min N-API binding ==="
# The GoLive screen-share stack uses a minimal N-API binding
# (native/libdatachannel-min) over nixpkgs libdatachannel.
(
cd native/libdatachannel-min
# binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev
# include root, LDC_LIB = lib output dir, NAPI_INCLUDE =
# node-addon-api include root).
NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \
-type d -path "*node_modules/node-addon-api" | head -1)
echo "NAPI_INCLUDE=$NAPI_INCLUDE"
LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \
NAPI_INCLUDE=$NAPI_INCLUDE \
npx node-gyp rebuild 2>&1 || true
ls -la build/Release/datachannel_min.node 2>/dev/null \
&& echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \
|| echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)"
)
echo "=== Compiling TypeScript ===="
npx tsc 2>&1
echo "=== Fixing @/ path aliases to relative paths ==="
node -e "
const fs = require('fs');
const path = require('path');
let count = 0;
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.js')) {
const c = fs.readFileSync(p, 'utf8');
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
const n = c.replace(pat, (m, p1) => {
const target = path.join('dist', p1) + '.js';
const rel = path.relative(path.dirname(p), target);
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
});
if (n !== c) { fs.writeFileSync(p, n); count++; }
}
}
}
walk('dist');
console.log('Fixed ' + count + ' files');
"
echo "=== Build complete ==="
'' + pruneProd;
installPhase = ''
mkdir -p $out/lib/gmw-discord-gateway
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
# GoLive native binding — loadNative resolves it relative to
# dist/goLive/native.js, i.e. <root>/native/libdatachannel-min/
# build/Release/datachannel_min.node; libdatachannel .so must sit
# next to it and be on LD_LIBRARY_PATH at runtime.
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release
cp native/libdatachannel-min/build/Release/datachannel_min.node \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc
cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true
# If the binding failed to build, screen share is simply disabled —
# the gateway itself must still start.
if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then
echo "WARN: datachannel_min.node missing — GoLive screen share disabled in this build"
fi
# Also include drizzle migrations if they exist
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
mkdir -p $out/bin
cat > $out/bin/gmw-discord-gateway << WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-discord-gateway
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH
exec ${nodejs}/bin/node dist/index.js
WRAPPER
chmod +x $out/bin/gmw-discord-gateway
'';
meta = {
description = "GMW Discord Gateway — message capture, voice, AI moderation";
platforms = pkgs.lib.platforms.linux;
};
};
# ---- Frontend (Next.js SSR standalone) ----
frontend = pkgs.stdenv.mkDerivation {
pname = "gmw-frontend";
version = "1.0.0";
src = frontendSrc;
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + ''
echo "=== Building Next.js SSR (standalone) ==="
export NEXT_TELEMETRY_DISABLED=1
export GMW_BACKEND_URL=http://127.0.0.1:4001
npx next build 2>&1
'';
installPhase = ''
echo "=== Packaging standalone server ==="
mkdir -p $out/lib/gmw-frontend/standalone
# The standalone server bundles its own minimal node_modules but
# needs the build assets + public copied INSIDE its tree.
cp -r .next/standalone/. $out/lib/gmw-frontend/standalone/
mkdir -p $out/lib/gmw-frontend/standalone/.next
cp -r .next/static $out/lib/gmw-frontend/standalone/.next/static
cp -r public $out/lib/gmw-frontend/standalone/public 2>/dev/null || true
# Remove dangling symlinks left by pnpm's hoisted .pnpm layout
# (e.g. node_modules/.pnpm/node_modules/...). The standalone server
# never resolves those at runtime — it bundles its own node_modules
# — and they trip stdenv's noBrokenSymlinks check.
find $out/lib/gmw-frontend/standalone -type l \
! -exec test -e {} \; -delete 2>/dev/null || true
mkdir -p $out/bin
cat > $out/bin/gmw-frontend << WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-frontend/standalone
export PORT=''${GMW_FRONTEND_PORT:-4017}
export HOSTNAME=127.0.0.1
exec ${nodejs}/bin/node server.js
WRAPPER
chmod +x $out/bin/gmw-frontend
'';
meta = {
description = "GMW Frontend — Next.js SSR dashboard";
platforms = pkgs.lib.platforms.linux;
};
};
# ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
proxy = pkgs.stdenv.mkDerivation {
pname = "gmw-proxy";
version = "1.0.0";
src = ./infra/docker;
buildInputs = [ pkgs.nginx ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out/bin $out/etc $out/share
# Substitute placeholders in nginx template
sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
-e "s|@NEXT_PORT@|4017|g" \
${./infra/nix/nginx.conf.template} \
> $out/etc/nginx.conf
cat > $out/bin/gmw-proxy << WRAPPER
#!${pkgs.runtimeShell}
exec ${pkgs.nginx}/bin/nginx -c $out/etc/nginx.conf -p /var/lib/gmw-proxy -g "error_log /var/lib/gmw-proxy/nginx-error.log; daemon off;"
WRAPPER
chmod +x $out/bin/gmw-proxy
'';
meta = {
description = "GMW Proxy — nginx -> Next.js + backend";
platforms = pkgs.lib.platforms.linux;
};
};
in {
packages = {
inherit backend discord-gateway frontend proxy;
default = proxy;
};
devShells.default = pkgs.mkShell {
buildInputs = [
nodejs pnpm
pkgs.python3 pkgs.gnumake pkgs.gcc
pkgs.rustc pkgs.cargo
pkgs.ffmpeg-headless
];
shellHook = ''
echo "GMW dev shell ready — node $(node --version), pnpm $(pnpm --version)"
'';
};
});
}