chore: add Nix flake and NixOS module support (#20597)

* feat(nix): add flake module and offline-friendly build

* chore(nix): upgrade to Node.js 24 and pnpm 10

Update devenv.nix to match project dependencies:
- Upgrade Node.js from 22 to 24 (aligns with Dockerfile node:24-bookworm)
- Upgrade pnpm from 9 to 10 (aligns with package.json pnpm@10.22.0)

* test: fix buffer-get test timeout

Replace external URL with mock server endpoint to prevent test timeout.
The test was trying to fetch from http://example.com which is not mocked,
causing it to timeout. Now uses http://rsshub.test/headers which is
properly mocked in the test setup.
This commit is contained in:
Dzming Li
2025-12-09 03:43:06 -08:00
committed by GitHub
parent e96c64090b
commit dbb692462d
8 changed files with 721 additions and 22 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake .

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
.DS_Store
.cursorrules
.env*
.env
.eslintcache
.idea
.log

108
devenv.nix Normal file
View File

@@ -0,0 +1,108 @@
{ pkgs, lib, config, ... }:
{
# https://devenv.sh/basics/
env = {
NODE_ENV = "dev";
NODE_OPTIONS = "--max-http-header-size=32768";
};
# https://devenv.sh/packages/
packages = with pkgs; [
git
# Optional: Uncomment if you need browser automation
# chromium
];
# https://devenv.sh/languages/
languages.javascript = {
enable = true;
package = pkgs.nodejs_24;
pnpm = {
enable = true;
package = pkgs.pnpm_10;
};
};
# https://devenv.sh/services/
services.redis = {
enable = lib.mkDefault false; # Disabled by default, users can enable in devenv.local.nix
port = 6379;
};
# https://devenv.sh/scripts/
scripts.rsshub-dev.exec = ''
pnpm run dev
'';
scripts.rsshub-build.exec = ''
pnpm run build
'';
scripts.rsshub-start.exec = ''
pnpm start
'';
scripts.rsshub-test.exec = ''
pnpm test
'';
# https://devenv.sh/processes/
processes = {
# Uncomment to auto-start RSSHub in dev mode when entering the shell
# rsshub.exec = "pnpm run dev";
# Example: Auto-start with Redis
# rsshub.exec = "pnpm run dev";
};
# https://devenv.sh/pre-commit-hooks/
pre-commit.hooks = {
# Lint staged files
eslint = {
enable = true;
entry = lib.mkForce "pnpm run format:staged";
};
};
enterShell = ''
echo ""
echo "🚀 RSSHub Development Environment"
echo ""
echo ""
echo "Node.js: $(node --version)"
echo "pnpm: $(pnpm --version)"
${lib.optionalString config.services.redis.enable ''
echo "Redis: Running on port ${toString config.services.redis.port}"
''}
echo ""
echo "Available commands:"
echo " rsshub-dev - Start development server (pnpm run dev)"
echo " rsshub-build - Build the project (pnpm run build)"
echo " rsshub-start - Start production server (pnpm start)"
echo " rsshub-test - Run tests (pnpm test)"
${lib.optionalString (!config.services.redis.enable) ''
echo ""
echo "💡 Tip: Enable Redis by creating devenv.local.nix:"
echo " { services.redis.enable = true; }"
''}
echo ""
echo "Documentation: https://docs.rsshub.app"
echo ""
echo ""
# Install dependencies if node_modules doesn't exist
if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..."
pnpm install
fi
'';
# https://devenv.sh/integrations/dotenv/
dotenv.enable = true; # Automatically load .env file
# Load local overrides if they exist
# Users can create devenv.local.nix for personal customizations
imports = lib.optional (builtins.pathExists ./devenv.local.nix) ./devenv.local.nix;
}

259
flake.lock generated Normal file
View File

@@ -0,0 +1,259 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv",
"flake-compat"
],
"git-hooks": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760971495,
"narHash": "sha256-IwnNtbNVrlZIHh7h4Wz6VP0Furxg9Hh0ycighvL5cZc=",
"owner": "cachix",
"repo": "cachix",
"rev": "c5bfd933d1033672f51a863c47303fc0e093c2d2",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nix": "nix",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1764368166,
"narHash": "sha256-FktN7dtYlC/sgLGBCGFXzNOvwgB7MSujp6cooJE48Ac=",
"owner": "cachix",
"repo": "devenv",
"rev": "47a243b97499bfe5d5783d1fc86d9fe776b2497f",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1761588595,
"narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760948891,
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1760663237,
"narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv",
"flake-compat"
],
"flake-parts": [
"devenv",
"flake-parts"
],
"git-hooks-nix": [
"devenv",
"git-hooks"
],
"nixpkgs": [
"devenv",
"nixpkgs"
],
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
]
},
"locked": {
"lastModified": 1761648602,
"narHash": "sha256-H97KSB/luq/aGobKRuHahOvT1r7C03BgB6D5HBZsbN8=",
"owner": "cachix",
"repo": "nix",
"rev": "3e5644da6830ef65f0a2f7ec22830c46285bfff6",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "devenv-2.30.6",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1761313199,
"narHash": "sha256-wCIACXbNtXAlwvQUo1Ed++loFALPjYUA3dpcUJiXO44=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "d1c30452ebecfc55185ae6d1c983c09da0c274ff",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1764242076,
"narHash": "sha256-sKoIWfnijJ0+9e4wRvIgm/HgE27bzwQxcEmo2J/gNpI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2fad6eac6077f03fe109c4d4eb171cf96791faa4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

308
flake.nix Normal file
View File

@@ -0,0 +1,308 @@
{
description = "RSSHub - Make RSS Great Again!";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
devenv.url = "github:cachix/devenv";
};
outputs = inputs@{ self, nixpkgs, flake-utils, devenv }:
let
# Helper to define the RSSHub package
makeRSSHub = pkgs:
let
pnpm = pkgs.pnpm_9;
deps = pnpm.fetchDeps {
pname = "rsshub";
src = ./.;
hash = "sha256-ErMPvlOIDqn03s2P+tzbQbYPZFEax5P61O1DJputvo4=";
fetcherVersion = 2;
};
in
pkgs.stdenv.mkDerivation rec {
pname = "rsshub";
version = "1.0.0";
src = ./.;
nativeBuildInputs = with pkgs; [
nodejs_22
pnpm.configHook
git
];
buildInputs = with pkgs; [
# Optional: Add chromium for routes that need browser automation
# chromium
];
pnpmDeps = deps;
# 修补构建脚本以支持离线构建Nix 构建环境无网络访问)
postPatch = ''
# registry.ts BUILD_ROUTES 使 directoryImport
substituteInPlace lib/registry.ts \
--replace-fail 'if (config.isPackage)' \
'if (process.env.BUILD_ROUTES_MODE) {
modules = directoryImport({
targetDirectoryPath: path.join(__dirname, "./routes"),
importPattern: /\.ts$/,
}) as typeof modules;
} else if (config.isPackage)'
'';
# The build phase
buildPhase = ''
runHook preBuild
# 使 directoryImport
export BUILD_ROUTES_MODE=1
pnpm run build:routes
unset BUILD_ROUTES_MODE
#
export NODE_ENV=production
${pnpm}/bin/pnpm run build
runHook postBuild
'';
# The install phase
installPhase = ''
runHook preInstall
mkdir -p $out/lib/rsshub
cp -r dist $out/lib/rsshub/
cp -r node_modules $out/lib/rsshub/
cp package.json $out/lib/rsshub/
mkdir -p $out/bin
cat > $out/bin/rsshub <<EOF
#!${pkgs.bash}/bin/bash
export NODE_ENV=production
export NODE_OPTIONS='--max-http-header-size=32768'
exec ${pkgs.nodejs_22}/bin/node $out/lib/rsshub/dist/index.mjs "\$@"
EOF
chmod +x $out/bin/rsshub
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Everything is RSSible";
homepage = "https://github.com/DIYgod/RSSHub";
license = licenses.mit;
maintainers = [ ];
platforms = platforms.all;
};
};
# NixOS module definition
makeNixOSModule = { lib, pkgs, config, ... }:
with lib;
let
cfg = config.services.rsshub;
in
{
options.services.rsshub = {
enable = mkEnableOption "RSSHub service";
package = mkOption {
type = types.package;
default = makeRSSHub pkgs;
defaultText = literalExpression "pkgs.rsshub";
description = "The RSSHub package to use.";
};
port = mkOption {
type = types.port;
default = 1200;
description = "Port on which RSSHub will listen.";
};
listenAddress = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Address on which RSSHub will listen.";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Whether to open the firewall for the specified port.";
};
user = mkOption {
type = types.str;
default = "rsshub";
description = "User account under which RSSHub runs.";
};
group = mkOption {
type = types.str;
default = "rsshub";
description = "Group under which RSSHub runs.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/rsshub";
description = "Directory for RSSHub data.";
};
environment = mkOption {
type = types.attrsOf types.str;
default = { };
example = literalExpression ''
{
PORT = "1200";
CACHE_TYPE = "redis";
REDIS_URL = "redis://localhost:6379/";
ALLOW_LOCALHOST = "true";
}
'';
description = ''
Environment variables for RSSHub.
See https://docs.rsshub.app/deploy/config for available options.
'';
};
redis = {
enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable and configure Redis for caching.";
};
createLocally = mkOption {
type = types.bool;
default = true;
description = "Whether to create a local Redis instance.";
};
url = mkOption {
type = types.str;
default = "redis://localhost:6379/";
description = "Redis connection URL.";
};
};
};
config = mkIf cfg.enable (
let
baseEnv = cfg.environment;
redisEnv = optionalAttrs cfg.redis.enable {
CACHE_TYPE = "redis";
REDIS_URL = cfg.redis.url;
};
derivedEnv =
{
PORT = toString cfg.port;
}
// optionalAttrs (cfg.listenAddress == "0.0.0.0") {
LISTEN_INADDR_ANY = "1";
};
finalEnv = baseEnv // redisEnv // derivedEnv;
environmentFile = pkgs.writeText "rsshub.env" (
concatStringsSep "\n" (
mapAttrsToList (name: value: "${name}=${toString value}") finalEnv
)
);
in
{
# Set up Redis if enabled
services.redis.servers.rsshub = mkIf (cfg.redis.enable && cfg.redis.createLocally) {
enable = true;
port = 6379;
};
# Create user and group
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
description = "RSSHub service user";
};
users.groups.${cfg.group} = { };
# SystemD service
systemd.services.rsshub = {
description = "RSSHub - Everything is RSSible";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ] ++ optional (cfg.redis.enable && cfg.redis.createLocally) "redis-rsshub.service";
requires = optional (cfg.redis.enable && cfg.redis.createLocally) "redis-rsshub.service";
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.dataDir;
EnvironmentFile = environmentFile;
ExecStart = "${cfg.package}/bin/rsshub";
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
};
};
# Open firewall if requested
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
}
);
};
in
{
# NixOS module
nixosModules.default = makeNixOSModule;
nixosModules.rsshub = makeNixOSModule;
# Overlay
overlays.default = final: prev: {
rsshub = makeRSSHub final;
};
}
//
# Per-system outputs
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
# Package
packages = {
default = makeRSSHub pkgs;
rsshub = makeRSSHub pkgs;
};
# Development shell using devenv
devShells.default = devenv.lib.mkShell {
inherit inputs pkgs;
modules = [
{
# devenv requires knowing the project root
# https://devenv.sh/guides/using-with-flakes/
packages = [ ];
}
./devenv.nix
];
};
# Apps
apps.default = {
type = "app";
program = "${self.packages.${system}.default}/bin/rsshub";
};
}
);
}

View File

@@ -7,20 +7,28 @@ import { parseRelativeDate } from '@/utils/parse-date';
import utils, { getVideoUrl } from '../utils';
import { getSrtAttachmentBatch } from './subtitles';
const innertubePromise = Innertube.create({
fetch: (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
let innertubePromise: Promise<Innertube> | undefined;
return fetch(url, {
method: input?.method,
...init,
const getInnertube = () => {
if (!innertubePromise) {
// Lazy init to avoid network calls during import time (e.g. when building)
innertubePromise = Innertube.create({
fetch: (input, init) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
return fetch(url, {
method: input?.method,
...init,
});
},
});
},
});
}
return innertubePromise;
};
export const getChannelIdByUsername = (username: string) =>
cache.tryGet(`youtube:getChannelIdByUsername:${username}`, async () => {
const innertube = await innertubePromise;
const innertube = await getInnertube();
const navigationEndpoint = await innertube.resolveURL(`https://www.youtube.com/${username}`);
return navigationEndpoint.payload.browseId;
});
@@ -31,7 +39,7 @@ export const getDataByUsername = async ({ username, embed, filterShorts, isJsonF
};
export const getDataByChannelId = async ({ channelId, embed, isJsonFeed }: { channelId: string; embed: boolean; filterShorts: boolean; isJsonFeed: boolean }): Promise<Data> => {
const innertube = await innertubePromise;
const innertube = await getInnertube();
const channel = await innertube.getChannel(channelId);
const videos = await channel.getVideos();
const videoSubtitles = isJsonFeed ? await getSrtAttachmentBatch(videos.videos.filter((video) => 'video_id' in video).map((video) => video.video_id)) : {};
@@ -71,7 +79,7 @@ export const getDataByChannelId = async ({ channelId, embed, isJsonFeed }: { cha
};
export const getDataByPlaylistId = async ({ playlistId, embed }: { playlistId: string; embed: boolean; isJsonFeed: boolean }): Promise<Data> => {
const innertube = await innertubePromise;
const innertube = await getInnertube();
const playlist = await innertube.getPlaylist(playlistId);
const videos = await playlist.videos;

View File

@@ -53,7 +53,7 @@ describe('got', () => {
});
it('buffer-get', async () => {
const response = await got.get('http://example.com', {
const response = await got.get('http://rsshub.test/headers', {
responseType: 'buffer',
});
expect(response.body instanceof Buffer).toBe(true);

View File

@@ -11,16 +11,31 @@ import { getCurrentPath } from '../../lib/utils/helpers';
const __dirname = getCurrentPath(import.meta.url);
const foloAnalysis = await (
await fetch('https://raw.githubusercontent.com/RSSNext/rsshub-docs/refs/heads/main/rsshub-analytics.json', {
headers: {
'user-agent': config.trueUA,
},
})
).json();
const foloAnalysisResult = foloAnalysis.data as Record<string, { subscriptionCount: number; topFeeds: any[] }>;
type FoloAnalysis = Record<string, { subscriptionCount: number; topFeeds: any[] }>;
const loadFoloAnalysis = async (): Promise<FoloAnalysis> => {
try {
const response = await fetch('https://raw.githubusercontent.com/RSSNext/rsshub-docs/refs/heads/main/rsshub-analytics.json', {
headers: {
'user-agent': config.trueUA,
},
});
if (!response.ok) {
throw new Error(`Unexpected status ${response.status}`);
}
const data = await response.json();
return (data?.data as FoloAnalysis) || {};
} catch (error) {
process.emitWarning(`Failed to fetch rsshub-analytics.json, continuing without popularity data. ${String(error)}`);
return {};
}
};
const foloAnalysisResult = await loadFoloAnalysis();
const foloAnalysisTop100 = Object.entries(foloAnalysisResult)
.sort((a, b) => b[1].subscriptionCount - a[1].subscriptionCount)
.toSorted((a, b) => b[1].subscriptionCount - a[1].subscriptionCount)
.slice(0, 150);
const maintainers: Record<string, string[]> = {};