revert: restore code fallbacks for config defaults

This reverts commit 153fc21. With no fallback in code, the deploy
crash-looped: Coolify keeps an entry for every variable compose.yml has
referenced, and empty entries appear to reach the containers as empty
values instead of the compose defaults.
This commit is contained in:
tiennm99 committed 2026-10-03 12:42:16 +07:00
1 parent 153fc211cf
commit e418bf3390
17 files changed
+94 -208

No files matched your search

+3 -10
View File
@@ -25,8 +25,7 @@ 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. Required by /addsticker:
# the code has no fallback (compose.yml supplies this value as its default).
# the first /addsticker if it does not exist yet. Unset = the default below.
STICKER_PACK_NAME=miti99_by_miti99bot
# SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM.
@@ -45,16 +44,10 @@ 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,8 +262,6 @@ 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
```
@@ -271,15 +269,10 @@ 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.
+7 -9
View File
@@ -368,9 +368,9 @@ type config struct {
MongoDatabase string // required when KVProvider=mongodb
}
// 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.
// 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.
func loadConfig() config {
envMap := make(map[string]string, len(os.Environ()))
for _, kv := range os.Environ() {
@@ -378,17 +378,15 @@ func loadConfig() config {
envMap[kv[:eq]] = kv[eq+1:]
}
}
level, err := log.ParseLevel(envMap["LOG_LEVEL"])
if err != nil {
log.Fatal("invalid LOG_LEVEL", "err", err)
port := envMap["PORT"]
if port == "" {
port = "8080"
}
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("missing or invalid PORT", "value", port)
log.Fatal("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 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
# --- 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
# --- 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>
# --- 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
# --- Runtime (defaults below; the code has no fallback) ---
LOG_LEVEL: ${LOG_LEVEL:-info} # debug|info|warn|error
# --- Runtime (optional; empty uses the default) ---
LOG_LEVEL: ${LOG_LEVEL} # debug|info|warn|error; default: info
# --- 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 (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
# --- 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
# --- Fixed by this stack (not Coolify settings) ---
NODE_ENV: production
+3 -8
View File
@@ -34,20 +34,15 @@ 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; `compose.yml` default `miti99_by_miti99bot`. See [sticker packs](sticker-packs.md) |
| `STICKER_PACK_NAME` | optional | set `/addsticker` writes to; 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`, `warn`, or `error`; `compose.yml` default `info`; logs are JSON on stdout |
| `LOG_LEVEL` | optional | `debug`, `info` (default), `warn`, or `error`; 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; fixed to `8080` by `compose.yml`, which the health check uses |
| `PORT` | leave unset | health server port; default `8080` |
| `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 | `compose.yml` default | Meaning |
| Env | Default | Meaning |
|---|---|---|
| `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to; required — unset, `/addsticker` refuses |
| `STICKER_PACK_NAME` | `miti99_by_miti99bot` | The Telegram set to write to |
| `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
standard pack belongs to the bot owner. Point `OWNER_ID` at the owning account if
default 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:
+15 -22
View File
@@ -19,43 +19,36 @@ package log
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
)
// 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)
// 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
// 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}))
func init() {
defaultLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: parseLevel(os.Getenv("LOG_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) {
// parseLevel maps LOG_LEVEL env to a slog.Level. Unknown / empty → Info.
func parseLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn, nil
return slog.LevelWarn
case "error":
return slog.LevelError, nil
return slog.LevelError
default:
return 0, fmt.Errorf("invalid log level %q: want debug, info, warn, or error", s)
return slog.LevelInfo
}
}
// 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 }
+6 -11
View File
@@ -32,7 +32,8 @@ func decodeOne(t *testing.T, buf *bytes.Buffer) map[string]any {
}
func TestParseLevel(t *testing.T) {
valid := map[string]slog.Level{
tests := map[string]slog.Level{
"": slog.LevelInfo,
"info": slog.LevelInfo,
"INFO": slog.LevelInfo,
"debug": slog.LevelDebug,
@@ -40,17 +41,11 @@ func TestParseLevel(t *testing.T) {
"warning": slog.LevelWarn,
"error": slog.LevelError,
" Error ": slog.LevelError,
"bogus": slog.LevelInfo,
}
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)
for in, want := range tests {
if got := parseLevel(in); got != want {
t.Errorf("parseLevel(%q) = %v, want %v", in, got, want)
}
}
}
@@ -94,18 +94,17 @@ func TestAddSticker_NonOwnerWritesToConfiguredPack(t *testing.T) {
}
}
// There is no fallback pack: an unset STICKER_PACK_NAME must not write
// anywhere.
func TestAddSticker_RequiresPackName(t *testing.T) {
func TestAddSticker_DefaultsToMiti99Pack(t *testing.T) {
rb := installAddSticker(t, "", "miti99bot")
rb.Bot.ProcessUpdate(context.Background(), stickerReply(999, "", "src", ""))
if _, ok := callTo(rb, "addStickerToSet"); ok {
t.Fatal("addStickerToSet called without a configured pack name")
call, ok := callTo(rb, "addStickerToSet")
if !ok {
t.Fatalf("no addStickerToSet call; got %+v", rb.Sent())
}
if _, ok := callTo(rb, "createNewStickerSet"); ok {
t.Fatal("createNewStickerSet called without a configured pack name")
if got := call.Form["name"]; got != "miti99_by_miti99bot" {
t.Errorf("name = %q, want the default pack", got)
}
}
+7 -8
View File
@@ -17,15 +17,18 @@ import (
)
const (
// 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
// stickerPackNameEnv overrides which set /addsticker writes to. 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 standard pack belongs to the bot owner. A pack
// user ID, and the default pack above belongs to the bot owner. A pack
// owned by any other account needs this env pointed at that account.
stickerPackOwnerEnv = "OWNER_ID"
@@ -75,10 +78,6 @@ 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")
@@ -91,7 +90,7 @@ var errNoPackOwner = errors.New("util: sticker pack owner ID unset")
func loadStickerPack() (stickerPack, error) {
name := strings.TrimSpace(os.Getenv(stickerPackNameEnv))
if name == "" {
return stickerPack{}, errNoPackName
name = defaultStickerPackName
}
ownerID, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(stickerPackOwnerEnv)), 10, 64)
if err != nil || ownerID == 0 {
+4 -14
View File
@@ -106,36 +106,26 @@ func TestPackTitle_AtNameLengthCap(t *testing.T) {
}
func TestLoadStickerPack(t *testing.T) {
t.Run("reads the name and owner", func(t *testing.T) {
t.Run("defaults the name and requires an owner", func(t *testing.T) {
t.Setenv("OWNER_ID", "42")
t.Setenv("STICKER_PACK_NAME", "miti99_by_miti99bot")
t.Setenv("STICKER_PACK_NAME", "")
pack, err := loadStickerPack()
if err != nil {
t.Fatalf("loadStickerPack: %v", err)
}
if pack.Name != "miti99_by_miti99bot" {
t.Errorf("name = %q, want miti99_by_miti99bot", pack.Name)
if pack.Name != defaultStickerPackName {
t.Errorf("name = %q, want %q", pack.Name, defaultStickerPackName)
}
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,5 +1,3 @@
# 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
+2 -4
View File
@@ -108,11 +108,9 @@ npm install
npm run browser:ensure
```
Start the local API. Every `RENDERER_*` setting is required, so copy the
template first; `npm run dev` loads `.env`:
Start the local API:
```sh
cp .env.example .env
npm run dev
```
@@ -180,7 +178,7 @@ dependencies, and FFmpeg/compositor support.
```sh
docker build -t miti99bot-renderer .
docker run --rm -p 3000:3000 --env-file .env.example miti99bot-renderer
docker run --rm -p 3000:3000 miti99bot-renderer
```
Recommended starting resources: 1-2 vCPU and 1-2 GB RAM, with
+4 -6
View File
@@ -28,7 +28,7 @@ Avoid for v1:
## Runtime
Env vars, all required (standard values shown):
Env vars, all optional (defaults shown):
```sh
RENDERER_PORT=3000
@@ -39,11 +39,9 @@ RENDERER_MAX_OPTIONS=32
RENDERER_MAX_OPTION_CHARS=40
```
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.
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.
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 --env-file-if-exists=.env --watch src/server.js",
"start": "node --env-file-if-exists=.env src/server.js",
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
+15 -40
View File
@@ -11,58 +11,33 @@ export const minRenderTimeoutMs = 7000;
*/
/**
* 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
* @param {string | undefined} value
* @param {number} fallback
* @returns {number}
*/
const requirePositiveInt = (env, name, problems) => {
const raw = env[name]?.trim();
if (!raw) {
problems.push(`${name} is required`);
return 0;
const parsePositiveInt = (value, fallback) => {
if (!value) {
return 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;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
/**
* 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) => {
/** @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.
return {
host: env.RENDERER_HOST || '0.0.0.0',
port: parsePositiveInt(env.RENDERER_PORT, 3000),
maxConcurrentRenders: parsePositiveInt(env.RENDERER_MAX_CONCURRENT_RENDERS, 1),
renderTimeoutMs: Math.max(
minRenderTimeoutMs,
requirePositiveInt(env, 'RENDERER_RENDER_TIMEOUT_MS', problems),
parsePositiveInt(env.RENDERER_RENDER_TIMEOUT_MS, 15000),
),
maxOptions: requirePositiveInt(env, 'RENDERER_MAX_OPTIONS', problems),
maxOptionChars: requirePositiveInt(env, 'RENDERER_MAX_OPTION_CHARS', problems),
maxOptions: parsePositiveInt(env.RENDERER_MAX_OPTIONS, 32),
maxOptionChars: parsePositiveInt(env.RENDERER_MAX_OPTION_CHARS, 40),
};
if (problems.length > 0) {
throw new Error(`invalid renderer configuration: ${problems.join('; ')}`);
}
return config;
};
+1 -39
View File
@@ -1,48 +1,10 @@
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({...validEnv, RENDERER_RENDER_TIMEOUT_MS: '500'});
const config = loadConfig({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/,
);
});
});