refactor!: move every config default into compose.yml

compose.yml now holds each default as ${VAR:-default}, and the code keeps
no fallback values. The bot stops at startup on a missing or invalid
PORT or LOG_LEVEL, /addsticker refuses without STICKER_PACK_NAME, and the
renderer refuses to start until every RENDERER_* setting is a valid
value, listing each problem. Settings whose empty value means none or
all (MODULES, OWNER_ID, ADMIN_IDS, the API tokens) use ${VAR:-}.

The renderer's npm start and dev load .env when present, so a local run
works from a copy of .env.example.

BREAKING CHANGE: running outside compose now requires LOG_LEVEL and PORT
for the bot, STICKER_PACK_NAME for /addsticker, and every RENDERER_*
variable for the renderer.
This commit is contained in:
tiennm99 committed 2026-10-03 12:38:39 +07:00
1 parent 4f8c01dae7
commit 153fc211cf
17 files changed
+208 -94

No files matched your search

+10 -3
View File
@@ -25,7 +25,8 @@ ADMIN_IDS=
# MUST end in _by_<this bot username>: Telegram requires that suffix on sets a
# bot creates and refuses to edit sets it did not create, so the suffix is what
# proves the pack is manageable. Created automatically, owned by OWNER_ID, on
# the first /addsticker if it does not exist yet. Unset = the default below.
# the first /addsticker if it does not exist yet. Required by /addsticker:
# the code has no fallback (compose.yml supplies this value as its default).
STICKER_PACK_NAME=miti99_by_miti99bot
# SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM.
@@ -44,10 +45,16 @@ LOL_PANDASCORE_TOKEN=
# only when running the bot outside compose. Leave blank to fall back to text.
RENDERER_URL=
# ============================ Runtime =============================
# Required: the code has no fallback. compose.yml supplies these values as its
# defaults; set them yourself when running outside compose.
# Log level: debug, info, warn, or error.
LOG_LEVEL=info
# Internal health server port.
PORT=8080
# ====================== Leave UNSET on self-host ==================
# Defaults are correct for self-host:
# KV_PROVIDER — auto-selects mongodb because MONGO_URL is set
# PORT — defaults to 8080 (internal health server)
# TELEGRAM_WEBHOOK_SECRET — long polling has no webhook
# GOLD_VNAPP_API_KEY — gold module auto-fetches + caches the key to Mongo
# Stock/coin/gold URL env overrides are not supported; modules use coded
+7
View File
@@ -262,6 +262,8 @@ shell, then run the server with Go:
```powershell
# PowerShell
$env:TELEGRAM_BOT_TOKEN = "…"
$env:LOG_LEVEL = "info"
$env:PORT = "8080"
$env:MODULES = ""
go run ./cmd/server
```
@@ -269,10 +271,15 @@ go run ./cmd/server
```sh
# POSIX shells (Linux/macOS)
export TELEGRAM_BOT_TOKEN="…"
export LOG_LEVEL=info
export PORT=8080
export MODULES=""
go run ./cmd/server
```
The code has no default values: `compose.yml` supplies them, so a local run
sets `LOG_LEVEL` and `PORT` itself (and `STICKER_PACK_NAME` for `/addsticker`).
The bot uses long polling, so a local run talks to Telegram directly — no
`ngrok` or public URL. The server clears any existing webhook on startup. The
dev bot is created manually; its token is injected through the environment.
+9 -7
View File
@@ -368,9 +368,9 @@ type config struct {
MongoDatabase string // required when KVProvider=mongodb
}
// loadConfig reads config from the environment. PORT defaults to 8080 and an
// invalid PORT is fatal; malformed OWNER_ID / ADMIN_IDS entries are logged and
// ignored.
// loadConfig reads config from the environment. compose.yml supplies every
// default, so the code keeps none: a missing or invalid PORT or LOG_LEVEL is
// fatal. Malformed OWNER_ID / ADMIN_IDS entries are logged and ignored.
func loadConfig() config {
envMap := make(map[string]string, len(os.Environ()))
for _, kv := range os.Environ() {
@@ -378,15 +378,17 @@ func loadConfig() config {
envMap[kv[:eq]] = kv[eq+1:]
}
}
port := envMap["PORT"]
if port == "" {
port = "8080"
level, err := log.ParseLevel(envMap["LOG_LEVEL"])
if err != nil {
log.Fatal("invalid LOG_LEVEL", "err", err)
}
log.SetLevel(level)
port := strings.TrimSpace(envMap["PORT"])
// PORT must be a number in 0..65535. http.Server uses ":<port>" verbatim,
// so a junk value would otherwise surface only at ListenAndServe time;
// fail fast here instead.
if n, err := strconv.Atoi(port); err != nil || n < 0 || n > 65535 {
log.Fatal("invalid PORT", "value", port)
log.Fatal("missing or invalid PORT", "value", port)
}
return config{
Port: port,
+16 -16
View File
@@ -11,18 +11,18 @@ services:
MONGO_URL: ${MONGO_URL} # Atlas SRV string incl. credentials — SECRET
MONGO_DATABASE: ${MONGO_DATABASE} # e.g. miti99bot
# --- Access control (optional; empty uses the default) ---
MODULES: ${MODULES} # CSV of modules; default: all modules
OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands; default: none
ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids; default: none
# --- Access control (optional; empty means none / all) ---
MODULES: ${MODULES:-} # CSV of modules; empty = all modules
OWNER_ID: ${OWNER_ID:-} # Telegram user id for owner-only commands; empty = none
ADMIN_IDS: ${ADMIN_IDS:-} # CSV of admin Telegram user ids; empty = none
# --- Module settings (optional; empty uses the default) ---
LOL_PANDASCORE_TOKEN: ${LOL_PANDASCORE_TOKEN} # PandaScore token — SECRET; default: none (/lol* fetches fail)
GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY} # VNAppMob key — SECRET; default: fetched and cached in Mongo
STICKER_PACK_NAME: ${STICKER_PACK_NAME} # /addsticker set; default: miti99_by_miti99bot
# --- Module settings (optional) ---
LOL_PANDASCORE_TOKEN: ${LOL_PANDASCORE_TOKEN:-} # PandaScore token — SECRET; empty = /lol* fetches fail
GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY:-} # VNAppMob key — SECRET; empty = fetched and cached in Mongo
STICKER_PACK_NAME: ${STICKER_PACK_NAME:-miti99_by_miti99bot} # /addsticker set; must end in _by_<bot username>
# --- Runtime (optional; empty uses the default) ---
LOG_LEVEL: ${LOG_LEVEL} # debug|info|warn|error; default: info
# --- Runtime (defaults below; the code has no fallback) ---
LOG_LEVEL: ${LOG_LEVEL:-info} # debug|info|warn|error
# --- Fixed by this stack (not Coolify settings) ---
# The bundled renderer service below draws /wheelofnames, /gacha and
@@ -30,12 +30,12 @@ services:
# platform-level value cannot point the bot elsewhere; the bot appends
# each /api/<name> route itself.
RENDERER_URL: http://renderer:3000
PORT: "8080" # health server; keep in sync with the healthcheck below
# --- Deliberately not declared ---
# SOURCE_COMMIT: Coolify provides it at runtime via its generated env
# file; declaring it here with Compose interpolation can override the
# runtime value with an empty string.
# PORT: the health server defaults to 8080, which the healthcheck uses.
# KV_PROVIDER: storage auto-selects mongodb because MONGO_URL is set.
# Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no public domain.
# Cron is in-process only — no CRON_MODE, no /cron route, no secret.
@@ -70,11 +70,11 @@ services:
# Every renderer setting carries the RENDERER_ prefix so it reads as a
# renderer setting and cannot clash with the bot's variables.
# --- Tuning (optional; empty uses the default) ---
RENDERER_MAX_CONCURRENT_RENDERS: ${RENDERER_MAX_CONCURRENT_RENDERS} # default: 1
RENDERER_RENDER_TIMEOUT_MS: ${RENDERER_RENDER_TIMEOUT_MS} # default: 15000 (floor 7000)
RENDERER_MAX_OPTIONS: ${RENDERER_MAX_OPTIONS} # default: 32 wheel options
RENDERER_MAX_OPTION_CHARS: ${RENDERER_MAX_OPTION_CHARS} # default: 40 chars per option/label
# --- Tuning (defaults below; the renderer has no fallback) ---
RENDERER_MAX_CONCURRENT_RENDERS: ${RENDERER_MAX_CONCURRENT_RENDERS:-1}
RENDERER_RENDER_TIMEOUT_MS: ${RENDERER_RENDER_TIMEOUT_MS:-15000} # floor 7000
RENDERER_MAX_OPTIONS: ${RENDERER_MAX_OPTIONS:-32} # wheel options
RENDERER_MAX_OPTION_CHARS: ${RENDERER_MAX_OPTION_CHARS:-40} # chars per option/label
# --- Fixed by this stack (not Coolify settings) ---
NODE_ENV: production
+8 -3
View File
@@ -34,15 +34,20 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
| `MODULES` | optional | CSV; empty = all modules, including any added later |
| `OWNER_ID` | optional | Telegram user id for owner-only commands, the deploy DM, and the `/addsticker` pack owner. Unset = owner-only commands are denied and `/addsticker` refuses |
| `ADMIN_IDS` | optional | CSV of Telegram user ids for admin-only commands |
| `STICKER_PACK_NAME` | optional | set `/addsticker` writes to; default `miti99_by_miti99bot`. See [sticker packs](sticker-packs.md) |
| `STICKER_PACK_NAME` | optional | set `/addsticker` writes to; `compose.yml` default `miti99_by_miti99bot`. See [sticker packs](sticker-packs.md) |
| `LOL_PANDASCORE_TOKEN` | optional | PandaScore API token for the lol module (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) |
| `RENDERER_URL` | leave unset | base URL of the animation renderer; fixed by `compose.yml` to the bundled renderer (`http://renderer:3000`), so a Coolify value is ignored |
| `LOG_LEVEL` | optional | `debug`, `info` (default), `warn`, or `error`; logs are JSON on stdout |
| `LOG_LEVEL` | optional | `debug`, `info`, `warn`, or `error`; `compose.yml` default `info`; logs are JSON on stdout |
| `GOLD_VNAPP_API_KEY` | optional | VNAppMob key — secret; empty = the gold module fetches one and caches it in MongoDB |
| `KV_PROVIDER` | leave unset | `memory` or `mongodb`; unset = `mongodb` when `MONGO_URL` is set, otherwise `memory` |
| `PORT` | leave unset | health server port; default `8080` |
| `PORT` | leave unset | health server port; fixed to `8080` by `compose.yml`, which the health check uses |
| `SOURCE_COMMIT` | never set | provided by Coolify at runtime for the deploy DM (see step 5 below) |
Defaults live in `compose.yml` only (`${VAR:-default}`); the code has no
fallback values. `PORT`, `LOG_LEVEL`, and (for `/addsticker`)
`STICKER_PACK_NAME` must therefore be set when running outside compose — a
missing or invalid `PORT` or `LOG_LEVEL` stops the bot at startup.
Stock, coin, and gold provider URL overrides are not supported in runtime env;
modules use coded defaults. There is no `TELEGRAM_WEBHOOK_SECRET`: long polling
has no webhook.
+3 -3
View File
@@ -22,14 +22,14 @@ Single-shot: one message replying to the media to add. No conversation state.
## Configuration
| Env | Default | Meaning |
| Env | `compose.yml` default | Meaning |
|---|---|---|
| `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to |
| `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to; required — unset, `/addsticker` refuses |
| `OWNER_ID` | — | Must be the account that **owns** that set |
`OWNER_ID` is reused rather than given a sticker-specific twin because
`addStickerToSet` takes the **set owner's** user ID, not the caller's, and the
default pack belongs to the bot owner. Point `OWNER_ID` at the owning account if
standard pack belongs to the bot owner. Point `OWNER_ID` at the owning account if
the configured pack belongs to someone else.
The caller's identity is used nowhere. That is what makes the command stateless:
+22 -15
View File
@@ -19,36 +19,43 @@ package log
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
)
// defaultLogger is constructed at init from LOG_LEVEL. Tests can swap it via
// SetDefault — but the public Info/Warn/Error/Fatal helpers always read the
// current default so test substitutions take effect immediately.
var defaultLogger *slog.Logger
// level is the default logger's minimum level. It starts at Info so logging
// works before configuration is read (and in tests); SetLevel applies the
// configured LOG_LEVEL at startup.
var level = new(slog.LevelVar)
func init() {
defaultLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: parseLevel(os.Getenv("LOG_LEVEL")),
}))
}
// defaultLogger writes JSON to stdout. Tests can swap it via SetDefault — but
// the public Info/Warn/Error/Fatal helpers always read the current default so
// test substitutions take effect immediately.
var defaultLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
// parseLevel maps LOG_LEVEL env to a slog.Level. Unknown / empty → Info.
func parseLevel(s string) slog.Level {
// ParseLevel maps a LOG_LEVEL value to a slog.Level. There is no fallback: an
// empty or unknown value is an error, so a typo cannot silently change what
// gets logged.
func ParseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn
return slog.LevelWarn, nil
case "error":
return slog.LevelError
return slog.LevelError, nil
default:
return slog.LevelInfo
return 0, fmt.Errorf("invalid log level %q: want debug, info, warn, or error", s)
}
}
// SetLevel sets the default logger's minimum level.
func SetLevel(l slog.Level) { level.Set(l) }
// SetDefault swaps the package-level logger. Used by tests to capture output;
// production code never calls this.
func SetDefault(l *slog.Logger) { defaultLogger = l }
+11 -6
View File
@@ -32,8 +32,7 @@ func decodeOne(t *testing.T, buf *bytes.Buffer) map[string]any {
}
func TestParseLevel(t *testing.T) {
tests := map[string]slog.Level{
"": slog.LevelInfo,
valid := map[string]slog.Level{
"info": slog.LevelInfo,
"INFO": slog.LevelInfo,
"debug": slog.LevelDebug,
@@ -41,11 +40,17 @@ func TestParseLevel(t *testing.T) {
"warning": slog.LevelWarn,
"error": slog.LevelError,
" Error ": slog.LevelError,
"bogus": slog.LevelInfo,
}
for in, want := range tests {
if got := parseLevel(in); got != want {
t.Errorf("parseLevel(%q) = %v, want %v", in, got, want)
for in, want := range valid {
got, err := ParseLevel(in)
if err != nil || got != want {
t.Errorf("ParseLevel(%q) = %v, %v; want %v", in, got, err, want)
}
}
// No fallback: empty and unknown values are configuration errors.
for _, in := range []string{"", " ", "bogus"} {
if _, err := ParseLevel(in); err == nil {
t.Errorf("ParseLevel(%q) = nil error, want an error", in)
}
}
}
@@ -94,17 +94,18 @@ func TestAddSticker_NonOwnerWritesToConfiguredPack(t *testing.T) {
}
}
func TestAddSticker_DefaultsToMiti99Pack(t *testing.T) {
// There is no fallback pack: an unset STICKER_PACK_NAME must not write
// anywhere.
func TestAddSticker_RequiresPackName(t *testing.T) {
rb := installAddSticker(t, "", "miti99bot")
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
call, ok := callTo(rb, "addStickerToSet")
if !ok {
t.Fatalf("no addStickerToSet call; got %+v", rb.Sent())
if _, ok := callTo(rb, "addStickerToSet"); ok {
t.Fatal("addStickerToSet called without a configured pack name")
}
if got := call.Form["name"]; got != "miti99_by_miti99bot" {
t.Errorf("name = %q, want the default pack", got)
if _, ok := callTo(rb, "createNewStickerSet"); ok {
t.Fatal("createNewStickerSet called without a configured pack name")
}
}
+8 -7
View File
@@ -17,18 +17,15 @@ import (
)
const (
// stickerPackNameEnv overrides which set /addsticker writes to. The name
// must end in "_by_<bot_username>", the only thing that makes a set
// stickerPackNameEnv names the set /addsticker writes to; compose.yml
// supplies its default. The name must end in "_by_<bot_username>", the only thing that makes a set
// bot-manageable; packTitle checks it before any upload. A set that does
// not exist yet is created by the first successful /addsticker.
stickerPackNameEnv = "STICKER_PACK_NAME"
// defaultStickerPackName is the shared pack used when the env is unset.
defaultStickerPackName = "miti99_by_miti99bot"
// stickerPackOwnerEnv reuses the bot-wide owner setting rather than
// introducing a second variable: AddStickerToSet needs the *set owner's*
// user ID, and the default pack above belongs to the bot owner. A pack
// user ID, and the standard pack belongs to the bot owner. A pack
// owned by any other account needs this env pointed at that account.
stickerPackOwnerEnv = "OWNER_ID"
@@ -78,6 +75,10 @@ type stickerPack struct {
OwnerID int64 // the account the set belongs to; AddStickerToSet demands it
}
// errNoPackName means STICKER_PACK_NAME is unset, so no sticker can be added.
// Internal, not user-facing: nothing the caller does fixes a misconfiguration.
var errNoPackName = errors.New("util: sticker pack name unset")
// errNoPackOwner means the owner ID is unset, so no sticker can be added.
// Internal, not user-facing: nothing the caller does fixes a misconfiguration.
var errNoPackOwner = errors.New("util: sticker pack owner ID unset")
@@ -90,7 +91,7 @@ var errNoPackOwner = errors.New("util: sticker pack owner ID unset")
func loadStickerPack() (stickerPack, error) {
name := strings.TrimSpace(os.Getenv(stickerPackNameEnv))
if name == "" {
name = defaultStickerPackName
return stickerPack{}, errNoPackName
}
ownerID, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(stickerPackOwnerEnv)), 10, 64)
if err != nil || ownerID == 0 {
+14 -4
View File
@@ -106,26 +106,36 @@ func TestPackTitle_AtNameLengthCap(t *testing.T) {
}
func TestLoadStickerPack(t *testing.T) {
t.Run("defaults the name and requires an owner", func(t *testing.T) {
t.Run("reads the name and owner", func(t *testing.T) {
t.Setenv("OWNER_ID", "42")
t.Setenv("STICKER_PACK_NAME", "")
t.Setenv("STICKER_PACK_NAME", "miti99_by_miti99bot")
pack, err := loadStickerPack()
if err != nil {
t.Fatalf("loadStickerPack: %v", err)
}
if pack.Name != defaultStickerPackName {
t.Errorf("name = %q, want %q", pack.Name, defaultStickerPackName)
if pack.Name != "miti99_by_miti99bot" {
t.Errorf("name = %q, want miti99_by_miti99bot", pack.Name)
}
if pack.OwnerID != 42 {
t.Errorf("ownerID = %d, want 42", pack.OwnerID)
}
})
// No fallback name: an unset STICKER_PACK_NAME is a misconfiguration.
t.Run("requires a name", func(t *testing.T) {
t.Setenv("OWNER_ID", "42")
t.Setenv("STICKER_PACK_NAME", "")
if _, err := loadStickerPack(); !errors.Is(err, errNoPackName) {
t.Errorf("loadStickerPack() err = %v, want errNoPackName", err)
}
})
// A zero owner is the unset case, not a valid user: AddStickerToSet needs a
// real account, so it must fail here rather than at the API.
for _, owner := range []string{"", "0", "not-a-number"} {
t.Run("rejects owner "+owner, func(t *testing.T) {
t.Setenv("OWNER_ID", owner)
t.Setenv("STICKER_PACK_NAME", "miti99_by_miti99bot")
if _, err := loadStickerPack(); !errors.Is(err, errNoPackOwner) {
t.Errorf("loadStickerPack() err = %v, want errNoPackOwner", err)
}
+2
View File
@@ -1,3 +1,5 @@
# Every RENDERER_* setting is required; the renderer has no fallback values.
# The root compose.yml supplies these same values as its defaults.
RENDERER_PORT=3000
RENDERER_HOST=0.0.0.0
NODE_ENV=production
+4 -2
View File
@@ -108,9 +108,11 @@ npm install
npm run browser:ensure
```
Start the local API:
Start the local API. Every `RENDERER_*` setting is required, so copy the
template first; `npm run dev` loads `.env`:
```sh
cp .env.example .env
npm run dev
```
@@ -178,7 +180,7 @@ dependencies, and FFmpeg/compositor support.
```sh
docker build -t miti99bot-renderer .
docker run --rm -p 3000:3000 miti99bot-renderer
docker run --rm -p 3000:3000 --env-file .env.example miti99bot-renderer
```
Recommended starting resources: 1-2 vCPU and 1-2 GB RAM, with
+6 -4
View File
@@ -28,7 +28,7 @@ Avoid for v1:
## Runtime
Env vars, all optional (defaults shown):
Env vars, all required (standard values shown):
```sh
RENDERER_PORT=3000
@@ -39,9 +39,11 @@ RENDERER_MAX_OPTIONS=32
RENDERER_MAX_OPTION_CHARS=40
```
An unset or empty variable uses the default above. The root `compose.yml`
fixes `RENDERER_HOST` and `RENDERER_PORT` and passes the tuning values through
without defaults, so leaving them empty in Coolify uses these defaults.
The renderer has no fallback values: it refuses to start and lists every
missing or invalid variable. The root `compose.yml` owns the defaults — it
fixes `RENDERER_HOST` and `RENDERER_PORT` and gives each tuning value a
`${VAR:-default}`, so leaving them empty in Coolify uses these values. For a
local run, copy `.env.example` to `.env`; `npm run dev` and `npm start` load it.
Start with 1-2 vCPU and 1-2 GB RAM. Increase only after render benchmarks show
the service is CPU-bound or concurrency-limited.
+2 -2
View File
@@ -7,8 +7,8 @@
"node": ">=24 <25"
},
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"dev": "node --env-file-if-exists=.env --watch src/server.js",
"start": "node --env-file-if-exists=.env src/server.js",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
+40 -15
View File
@@ -11,33 +11,58 @@ export const minRenderTimeoutMs = 7000;
*/
/**
* @param {string | undefined} value
* @param {number} fallback
* Reads a required positive integer setting, recording a problem instead of
* falling back: compose.yml owns every default, so a missing value is a
* deployment mistake to report, not to paper over.
*
* @param {NodeJS.ProcessEnv} env
* @param {string} name
* @param {string[]} problems
* @returns {number}
*/
const parsePositiveInt = (value, fallback) => {
if (!value) {
return fallback;
const requirePositiveInt = (env, name, problems) => {
const raw = env[name]?.trim();
if (!raw) {
problems.push(`${name} is required`);
return 0;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
problems.push(`${name} must be a positive integer, got "${raw}"`);
return 0;
}
return parsed;
};
/**
* Loads the renderer settings. Every RENDERER_* variable is required; the
* deployment (compose.yml, or .env for local runs) supplies the values.
*
* @param {NodeJS.ProcessEnv} [env]
* @returns {AppConfig}
*/
export const loadConfig = (env = process.env) => {
return {
host: env.RENDERER_HOST || '0.0.0.0',
port: parsePositiveInt(env.RENDERER_PORT, 3000),
maxConcurrentRenders: parsePositiveInt(env.RENDERER_MAX_CONCURRENT_RENDERS, 1),
/** @type {string[]} */
const problems = [];
const host = env.RENDERER_HOST?.trim() ?? '';
if (!host) {
problems.push('RENDERER_HOST is required');
}
const config = {
host,
port: requirePositiveInt(env, 'RENDERER_PORT', problems),
maxConcurrentRenders: requirePositiveInt(env, 'RENDERER_MAX_CONCURRENT_RENDERS', problems),
// Remotion's browser timeout cannot go below 7000ms, so lower values are
// raised to that floor rather than rejected.
renderTimeoutMs: Math.max(
minRenderTimeoutMs,
parsePositiveInt(env.RENDERER_RENDER_TIMEOUT_MS, 15000),
requirePositiveInt(env, 'RENDERER_RENDER_TIMEOUT_MS', problems),
),
maxOptions: parsePositiveInt(env.RENDERER_MAX_OPTIONS, 32),
maxOptionChars: parsePositiveInt(env.RENDERER_MAX_OPTION_CHARS, 40),
maxOptions: requirePositiveInt(env, 'RENDERER_MAX_OPTIONS', problems),
maxOptionChars: requirePositiveInt(env, 'RENDERER_MAX_OPTION_CHARS', problems),
};
if (problems.length > 0) {
throw new Error(`invalid renderer configuration: ${problems.join('; ')}`);
}
return config;
};
+39 -1
View File
@@ -1,10 +1,48 @@
import {describe, expect, test} from 'vitest';
import {loadConfig, minRenderTimeoutMs} from '../src/config.js';
const validEnv = {
RENDERER_HOST: '0.0.0.0',
RENDERER_PORT: '3000',
RENDERER_MAX_CONCURRENT_RENDERS: '1',
RENDERER_RENDER_TIMEOUT_MS: '15000',
RENDERER_MAX_OPTIONS: '32',
RENDERER_MAX_OPTION_CHARS: '40',
};
describe('loadConfig', () => {
test('reads every RENDERER_* setting', () => {
expect(loadConfig(validEnv)).toEqual({
host: '0.0.0.0',
port: 3000,
maxConcurrentRenders: 1,
renderTimeoutMs: 15000,
maxOptions: 32,
maxOptionChars: 40,
});
});
test('keeps render timeout compatible with Remotion browser timeout limits', () => {
const config = loadConfig({RENDERER_RENDER_TIMEOUT_MS: '500'});
const config = loadConfig({...validEnv, RENDERER_RENDER_TIMEOUT_MS: '500'});
expect(config.renderTimeoutMs).toBe(minRenderTimeoutMs);
});
test('has no fallback: missing settings are all reported', () => {
expect(() => loadConfig({})).toThrow(
/RENDERER_HOST is required.*RENDERER_PORT is required.*RENDERER_MAX_OPTION_CHARS is required/,
);
});
test('treats an empty value as missing', () => {
expect(() => loadConfig({...validEnv, RENDERER_MAX_OPTIONS: ''})).toThrow(
/RENDERER_MAX_OPTIONS is required/,
);
});
test.each(['0', '-1', '1.5', 'abc'])('rejects non-positive-integer %s', (value) => {
expect(() => loadConfig({...validEnv, RENDERER_MAX_OPTIONS: value})).toThrow(
/RENDERER_MAX_OPTIONS must be a positive integer/,
);
});
});