From 6f30e9a3b5dbbcf2c2120d9f0b19882dc8dd353d Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 08:11:23 -0500 Subject: [PATCH 01/19] fix(traefik): deep merge routers/services/middlewares in register() The shallow merge was overwriting nested config sections entirely instead of merging them. Now properly merges routers, services, and middlewares when updating an existing app's configuration. --- src/backends/traefik/traefikManager.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/backends/traefik/traefikManager.ts b/src/backends/traefik/traefikManager.ts index c62140f..df8eaf6 100644 --- a/src/backends/traefik/traefikManager.ts +++ b/src/backends/traefik/traefikManager.ts @@ -201,14 +201,25 @@ async function doFlushToDisk(): Promise { /** * Register or update an app's configuration. + * Performs deep merge of routers, services, and middlewares. */ export function register(appName: string, config: Partial): void { const existing = registry.get(appName) ?? {}; + // Deep merge each section to preserve existing routers/services/middlewares const merged: TraefikConfigYamlFormat = { - http: { ...existing.http, ...config.http }, - tcp: { ...existing.tcp, ...config.tcp }, - udp: { ...existing.udp, ...config.udp }, + http: (existing.http || config.http) ? { + routers: { ...existing.http?.routers, ...config.http?.routers }, + services: { ...existing.http?.services, ...config.http?.services }, + middlewares: { ...existing.http?.middlewares, ...config.http?.middlewares }, + } : undefined, + tcp: (existing.tcp || config.tcp) ? { + routers: { ...existing.tcp?.routers, ...config.tcp?.routers }, + services: { ...existing.tcp?.services, ...config.tcp?.services }, + } : undefined, + udp: (existing.udp || config.udp) ? { + services: { ...existing.udp?.services, ...config.udp?.services }, + } : undefined, }; registry.set(appName, merged); From 9299cf529b58d6e96f0f78c8ce263cc6690d0f6e Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 08:12:41 -0500 Subject: [PATCH 02/19] refactor(traefik): eliminate double YAML parse-dump cycle Split renderTemplate into two functions: - renderTemplate() returns raw rendered string - renderTemplateParsed() returns parsed YAML object This eliminates the redundant parse->dump->parse cycle where templates were parsed to YAML, dumped back to string, then parsed again by the caller. --- src/backends/traefik/templateParser.ts | 21 +++++++++++++++---- src/backends/traefik/traefik.ts | 5 ++--- .../traefik/template-error-handling.test.ts | 4 ++-- .../traefik/user-data-substitution.test.ts | 4 ++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index fbd28f1..f60f4c8 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -51,7 +51,7 @@ function buildContext(appName: string, data: XMagicProxyData): Record(template: string, appName: string, data: XMagicProxyData): T { + const rendered = renderTemplate(template, appName, data); + try { - const parsed = yaml.load(rendered); - return yaml.dump(parsed, { noRefs: true, skipInvalid: true }); + return yaml.load(rendered) as T; } catch (err) { const message = err instanceof Error ? err.message : String(err); log.error({ diff --git a/src/backends/traefik/traefik.ts b/src/backends/traefik/traefik.ts index 079a9fc..75b4b0c 100644 --- a/src/backends/traefik/traefik.ts +++ b/src/backends/traefik/traefik.ts @@ -1,8 +1,7 @@ import fs from 'fs/promises'; import path from 'path'; -import yaml from 'js-yaml'; import { OUTPUT_DIRECTORY, CONFIG_DIRECTORY } from '../../config'; -import { renderTemplate } from './templateParser'; +import { renderTemplate, renderTemplateParsed } from './templateParser'; import { TraefikConfigYamlFormat } from './types/traefik'; import * as manager from './traefikManager'; import { MagicProxyConfigFile } from '../../types/config'; @@ -61,7 +60,7 @@ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYam try { const rendered = renderTemplate(templateContent, appName, data); lastRendered = rendered; - return yaml.load(rendered) as TraefikConfigYamlFormat; + return renderTemplateParsed(templateContent, appName, data); } catch (err) { const message = err instanceof Error ? err.message : String(err); log.error({ diff --git a/test/unit/traefik/template-error-handling.test.ts b/test/unit/traefik/template-error-handling.test.ts index 188f73d..1ac5d1d 100644 --- a/test/unit/traefik/template-error-handling.test.ts +++ b/test/unit/traefik/template-error-handling.test.ts @@ -1,5 +1,5 @@ import { describe, it, beforeEach, expect } from 'vitest'; -import { renderTemplate } from '../../../src/backends/traefik/templateParser'; +import { renderTemplate, renderTemplateParsed } from '../../../src/backends/traefik/templateParser'; import * as traefik from '../../../src/backends/traefik/traefik'; import { XMagicProxyData } from '../../../src/types/xmagic'; import { HostEntry } from '../../../src/types/host'; @@ -58,7 +58,7 @@ key: {{ app_name }} hostname: 'h', }; - expect(() => renderTemplate(tmpl, 'app', data)).toThrow( + expect(() => renderTemplateParsed(tmpl, 'app', data)).toThrow( /Template produced invalid YAML/ ); }); diff --git a/test/unit/traefik/user-data-substitution.test.ts b/test/unit/traefik/user-data-substitution.test.ts index 68c424f..7635ac7 100644 --- a/test/unit/traefik/user-data-substitution.test.ts +++ b/test/unit/traefik/user-data-substitution.test.ts @@ -124,8 +124,8 @@ config: }; const result = renderTemplate(template, 'app', data); - // null should be converted to empty string, YAML output will have single quotes - expect(result).toContain("optional_setting: ''"); + // null should be converted to empty string in the raw template output + expect(result).toContain('optional_setting: ""'); }); it('core variables cannot be overwritten by userData', () => { From b708a200006f745f008e8d2e7f0a9ab10ed951ef Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 08:13:19 -0500 Subject: [PATCH 03/19] chore(traefik): remove unused lastUserData tracking The lastUserData variable and _getLastUserData() function were never used by any tests or production code. Removed to reduce dead code. --- src/backends/traefik/traefik.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/backends/traefik/traefik.ts b/src/backends/traefik/traefik.ts index 75b4b0c..8da9b45 100644 --- a/src/backends/traefik/traefik.ts +++ b/src/backends/traefik/traefik.ts @@ -16,7 +16,6 @@ const templates = new Map(); /** Tracking for debugging */ let lastRendered: string | null = null; -let lastUserData: string | null = null; /** * Load a template file from disk. @@ -43,8 +42,6 @@ async function loadTemplate(templatePath: string): Promise { * Returns null if template rendering fails (template not found or render error). */ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYamlFormat | null { - lastUserData = data.userData ? JSON.stringify(data.userData) : null; - const templateContent = templates.get(data.template); if (!templateContent) { const available = Array.from(templates.keys()).join(', ') || '(none)'; @@ -79,10 +76,6 @@ export function _getLastRendered(): string | null { return lastRendered; } -export function _getLastUserData(): string | null { - return lastUserData; -} - export function _setTemplateForTesting(name: string, content: string): void { templates.set(name, content); } @@ -91,7 +84,6 @@ export function _resetForTesting(): void { manager._resetForTesting?.(); templates.clear(); lastRendered = null; - lastUserData = null; } // ───────────────────────────────────────────────────────────────────────────── From dd62a74eeb218274a07bb36047bc32dbc2dfdbbf Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 08:14:08 -0500 Subject: [PATCH 04/19] feat(traefik): warn on router/service/middleware name collisions When building the combined config, log a warning if multiple apps define the same router, service, or middleware name. This helps catch misconfigurations where one app's config would silently overwrite another's. --- src/backends/traefik/traefikManager.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/backends/traefik/traefikManager.ts b/src/backends/traefik/traefikManager.ts index df8eaf6..edea548 100644 --- a/src/backends/traefik/traefikManager.ts +++ b/src/backends/traefik/traefikManager.ts @@ -31,8 +31,18 @@ let pendingFlush: { /** * Merge two records, with source values overwriting target values. + * Logs a warning if any keys would be overwritten. */ -function mergeRecord(target: Record = {}, source: Record = {}): Record { +function mergeRecord(target: Record = {}, source: Record = {}, section?: string): Record { + if (section) { + const collisions = Object.keys(source).filter(key => key in target); + if (collisions.length > 0) { + log.warn({ + message: 'Config name collision detected - values will be overwritten', + data: { section, collisions } + }); + } + } return { ...target, ...source }; } @@ -46,22 +56,22 @@ function buildCombinedConfig(): TraefikConfigYamlFormat { // HTTP section if (cfg.http?.routers || cfg.http?.services || cfg.http?.middlewares) { combined.http ??= {}; - combined.http.routers = mergeRecord(combined.http.routers, cfg.http.routers); - combined.http.services = mergeRecord(combined.http.services, cfg.http.services); - combined.http.middlewares = mergeRecord(combined.http.middlewares, cfg.http.middlewares); + combined.http.routers = mergeRecord(combined.http.routers, cfg.http.routers, 'http.routers'); + combined.http.services = mergeRecord(combined.http.services, cfg.http.services, 'http.services'); + combined.http.middlewares = mergeRecord(combined.http.middlewares, cfg.http.middlewares, 'http.middlewares'); } // TCP section if (cfg.tcp?.routers || cfg.tcp?.services) { combined.tcp ??= {}; - combined.tcp.routers = mergeRecord(combined.tcp.routers, cfg.tcp.routers); - combined.tcp.services = mergeRecord(combined.tcp.services, cfg.tcp.services); + combined.tcp.routers = mergeRecord(combined.tcp.routers, cfg.tcp.routers, 'tcp.routers'); + combined.tcp.services = mergeRecord(combined.tcp.services, cfg.tcp.services, 'tcp.services'); } // UDP section if (cfg.udp?.services) { combined.udp ??= {}; - combined.udp.services = mergeRecord(combined.udp.services, cfg.udp.services); + combined.udp.services = mergeRecord(combined.udp.services, cfg.udp.services, 'udp.services'); } } From 061dfb11b320bbef4c4d81a0384d3e00324b7640 Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 08:14:41 -0500 Subject: [PATCH 05/19] fix(backend): replace unsafe ! assertions with ensureBackend() if initialization failed, the next line would throw a confusing error. Added ensureBackend() helper that explicitly checks and throws a clear error message if the backend failed to initialize. --- src/backends/backendPlugin.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/backends/backendPlugin.ts b/src/backends/backendPlugin.ts index 857373f..354f299 100644 --- a/src/backends/backendPlugin.ts +++ b/src/backends/backendPlugin.ts @@ -45,17 +45,31 @@ export async function initialize(config?: MagicProxyConfigFile): Promise { await activeBackend.initialize(cfg); } +/** + * Ensures backend is initialized and returns it. + * Throws if initialization fails. + */ +async function ensureBackend(): Promise { + if (!activeBackend) { + await initialize(); + } + if (!activeBackend) { + throw new Error('Backend initialization failed - no active backend'); + } + return activeBackend; +} + export async function addProxiedApp(entry: HostEntry): Promise { - if (!activeBackend) await initialize(); - return activeBackend!.addProxiedApp(entry); + const backend = await ensureBackend(); + return backend.addProxiedApp(entry); } export async function removeProxiedApp(appName: string): Promise { - if (!activeBackend) await initialize(); - return activeBackend!.removeProxiedApp(appName); + const backend = await ensureBackend(); + return backend.removeProxiedApp(appName); } export async function getStatus(): Promise { - if (!activeBackend) await initialize(); - return activeBackend!.getStatus(); + const backend = await ensureBackend(); + return backend.getStatus(); } From 542afcc0feec1e8fb644eec462a9ca21980cef7b Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 08:29:37 -0500 Subject: [PATCH 06/19] perf(traefik): only cleanup temp files on first write Previously cleanupTempFiles() ran on every config flush, causing unnecessary filesystem operations. Now tracks cleanup state and only runs once per output file, resetting when the output file changes. --- src/backends/traefik/traefikManager.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/backends/traefik/traefikManager.ts b/src/backends/traefik/traefikManager.ts index edea548..b911403 100644 --- a/src/backends/traefik/traefikManager.ts +++ b/src/backends/traefik/traefikManager.ts @@ -11,6 +11,9 @@ const log = zone('backends.traefik.manager'); const registry = new Map(); let outputFile: string | null = null; +/** Track whether temp file cleanup has been performed for current output file */ +let tempFilesCleanedUp = false; + // ───────────────────────────────────────────────────────────────────────────── // Flush Debouncing // ───────────────────────────────────────────────────────────────────────────── @@ -116,8 +119,11 @@ async function writeAtomically(filePath: string, content: string): Promise try { await fs.mkdir(path.dirname(filePath), { recursive: true }); - // Clean up any stale temp files before writing - await cleanupTempFiles(filePath); + // Clean up any stale temp files on first write only + if (!tempFilesCleanedUp) { + await cleanupTempFiles(filePath); + tempFilesCleanedUp = true; + } await fs.writeFile(tmpFile, content, 'utf-8'); await fs.rename(tmpFile, filePath); log.debug({ message: 'Config written', data: { filePath } }); @@ -133,6 +139,9 @@ async function writeAtomically(filePath: string, content: string): Promise // ───────────────────────────────────────────────────────────────────────────── export function setOutputFile(file: string | null): void { + if (file !== outputFile) { + tempFilesCleanedUp = false; // Reset cleanup flag for new file + } outputFile = file; } @@ -267,4 +276,5 @@ export function _resetForTesting(): void { registry.clear(); outputFile = null; pendingFlush = null; + tempFilesCleanedUp = false; } From fcdc2420ccd1a7d560a6c0b3cd403aba88edb6ca Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 08:31:46 -0500 Subject: [PATCH 07/19] fix(lint): replace explicit 'any' types with specific Record types Changed buildContext and getContextValue to use specific Record types instead of 'any' to satisfy ESLint no-explicit-any rules. --- src/backends/traefik/templateParser.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index f60f4c8..c4d129e 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -17,10 +17,10 @@ const VALID_KEY_PATTERN = /^[a-zA-Z0-9_]+$/; * - Flat keys: {{ port }} (for backward compatibility) * - Nested access: {{ userData.port }} (explicit namespace) */ -function buildContext(appName: string, data: XMagicProxyData): Record { +function buildContext(appName: string, data: XMagicProxyData): Record> { const CORE_KEYS = new Set(['app_name', 'hostname', 'target_url', 'userData']); - const context: Record = { + const context: Record> = { app_name: appName, hostname: data.hostname, target_url: data.target, @@ -71,7 +71,7 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr */ function getContextValue(path: string): string | undefined { const parts = path.split('.'); - let value: any = context; + let value: string | Record | undefined = context; for (const part of parts) { if (value == null || typeof value !== 'object') { From 4e14e9f959ac18f0160491ba7d29a6ef3bb1e0db Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 09:24:19 -0500 Subject: [PATCH 08/19] refactor(traefik): fix double-render, extract error helper, remove dead code 1. Fix double rendering issue: - renderTemplateParsed now returns { raw, parsed } to avoid calling renderTemplate twice (was rendering once to store, then again to parse) 2. Extract reusable error helper: - Added getErrorMessage(err) to templateParser.ts - Used in traefik.ts, templateParser.ts, and validators.ts - Eliminates duplicate 'err instanceof Error ? err.message : String(err)' 3. Remove dead code: - Removed lastRendered variable (never used) - Removed _getLastRendered() function (never called from tests) - Removed duplicate debug log in makeAppConfig (templateParser already logs) --- src/backends/traefik/templateParser.ts | 25 ++++++++++++++++++++----- src/backends/traefik/traefik.ts | 25 +++++-------------------- src/backends/traefik/validators.ts | 3 ++- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index c4d129e..c7d964b 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -105,22 +105,37 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr return rendered; } +/** Result from rendering a template with both raw string and parsed object */ +export type RenderResult = { + raw: string; + parsed: T; +}; + +/** + * Extract error message from unknown error type. + */ +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + /** * Render a template and parse it as YAML. + * Returns both the raw rendered string and the parsed object. * * @param template - The template content with {{ variable }} placeholders * @param appName - The application name * @param data - The proxy configuration data - * @returns The parsed YAML object + * @returns Object containing both raw string and parsed YAML * @throws Error if unknown template variables are encountered or YAML is invalid */ -export function renderTemplateParsed(template: string, appName: string, data: XMagicProxyData): T { - const rendered = renderTemplate(template, appName, data); +export function renderTemplateParsed(template: string, appName: string, data: XMagicProxyData): RenderResult { + const raw = renderTemplate(template, appName, data); try { - return yaml.load(rendered) as T; + const parsed = yaml.load(raw) as T; + return { raw, parsed }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = getErrorMessage(err); log.error({ message: 'Template produced invalid YAML', data: { appName, error: message } diff --git a/src/backends/traefik/traefik.ts b/src/backends/traefik/traefik.ts index 8da9b45..64e334e 100644 --- a/src/backends/traefik/traefik.ts +++ b/src/backends/traefik/traefik.ts @@ -1,7 +1,7 @@ import fs from 'fs/promises'; import path from 'path'; import { OUTPUT_DIRECTORY, CONFIG_DIRECTORY } from '../../config'; -import { renderTemplate, renderTemplateParsed } from './templateParser'; +import { renderTemplateParsed, getErrorMessage } from './templateParser'; import { TraefikConfigYamlFormat } from './types/traefik'; import * as manager from './traefikManager'; import { MagicProxyConfigFile } from '../../types/config'; @@ -14,9 +14,6 @@ const log = zone('backends.traefik'); /** Template storage: maps template filename -> content */ const templates = new Map(); -/** Tracking for debugging */ -let lastRendered: string | null = null; - /** * Load a template file from disk. * Relative paths are resolved against CONFIG_DIRECTORY. @@ -31,7 +28,7 @@ async function loadTemplate(templatePath: string): Promise { try { return await fs.readFile(resolved, 'utf-8'); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = getErrorMessage(err); log.error({ message: 'Failed to load template', data: { templatePath, resolved, error: message } }); throw new Error(`Failed to load template '${templatePath}': ${message}`); } @@ -49,20 +46,13 @@ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYam return null; } - log.debug({ - message: 'Rendering template', - data: { appName, template: data.template, target: data.target, hostname: data.hostname } - }); - try { - const rendered = renderTemplate(templateContent, appName, data); - lastRendered = rendered; - return renderTemplateParsed(templateContent, appName, data); + const { parsed } = renderTemplateParsed(templateContent, appName, data); + return parsed; } catch (err) { - const message = err instanceof Error ? err.message : String(err); log.error({ message: 'Failed to render template', - data: { appName, error: message } + data: { appName, error: getErrorMessage(err) } }); return null; } @@ -72,10 +62,6 @@ function makeAppConfig(appName: string, data: XMagicProxyData): TraefikConfigYam // Test utilities // ───────────────────────────────────────────────────────────────────────────── -export function _getLastRendered(): string | null { - return lastRendered; -} - export function _setTemplateForTesting(name: string, content: string): void { templates.set(name, content); } @@ -83,7 +69,6 @@ export function _setTemplateForTesting(name: string, content: string): void { export function _resetForTesting(): void { manager._resetForTesting?.(); templates.clear(); - lastRendered = null; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/backends/traefik/validators.ts b/src/backends/traefik/validators.ts index 45d3238..3e1b2b2 100644 --- a/src/backends/traefik/validators.ts +++ b/src/backends/traefik/validators.ts @@ -1,4 +1,5 @@ import yaml from 'js-yaml'; +import { getErrorMessage } from './templateParser'; /** Allowed top-level keys in Traefik dynamic config */ const ALLOWED_TOP_KEYS = new Set(['http', 'tcp', 'udp']); @@ -60,7 +61,7 @@ export function validateGeneratedConfig(yamlText: string): ValidationResult { try { parsed = yaml.load(yamlText); } catch (err) { - return { valid: false, error: `Invalid YAML: ${err instanceof Error ? err.message : String(err)}` }; + return { valid: false, error: `Invalid YAML: ${getErrorMessage(err)}` }; } // Allow empty config From 85fbe58ebc35250675eb2a48237f55e3272e565f Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 09:28:37 -0500 Subject: [PATCH 09/19] fix(traefik): tighten Context typing and safe lookup in template parser Define a dedicated Context type and use safe unknown-based lookup in getContextValue to satisfy TypeScript without resorting to 'any'. --- src/backends/traefik/templateParser.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index c7d964b..6862a35 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -17,10 +17,18 @@ const VALID_KEY_PATTERN = /^[a-zA-Z0-9_]+$/; * - Flat keys: {{ port }} (for backward compatibility) * - Nested access: {{ userData.port }} (explicit namespace) */ -function buildContext(appName: string, data: XMagicProxyData): Record> { +type Context = { + app_name: string; + hostname: string; + target_url: string; + userData: Record; + [key: string]: string | Record; +}; + +function buildContext(appName: string, data: XMagicProxyData): Context { const CORE_KEYS = new Set(['app_name', 'hostname', 'target_url', 'userData']); - const context: Record> = { + const context: Context = { app_name: appName, hostname: data.hostname, target_url: data.target, @@ -71,16 +79,16 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr */ function getContextValue(path: string): string | undefined { const parts = path.split('.'); - let value: string | Record | undefined = context; - + let value: unknown = context; + for (const part of parts) { if (value == null || typeof value !== 'object') { return undefined; } - value = value[part]; + value = (value as Record)[part]; } - - return value == null ? undefined : String(value); + + return typeof value === 'string' ? value : undefined; } // Replace all {{ key }} occurrences From eeadb9e20b08c6f8191757f917583982ee4a5ef6 Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 09:42:07 -0500 Subject: [PATCH 10/19] refactor(traefik): extract shared helpers and improve readability - Add with and - Use helpers across parser, validators, and manager - Minor readability improvements --- src/backends/traefik/helpers.ts | 11 +++++++++++ src/backends/traefik/templateParser.ts | 5 ++--- src/backends/traefik/traefik.ts | 3 ++- src/backends/traefik/traefikManager.ts | 4 +++- src/backends/traefik/validators.ts | 2 +- 5 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 src/backends/traefik/helpers.ts diff --git a/src/backends/traefik/helpers.ts b/src/backends/traefik/helpers.ts new file mode 100644 index 0000000..11f219e --- /dev/null +++ b/src/backends/traefik/helpers.ts @@ -0,0 +1,11 @@ +/** + * Small shared helpers for the Traefik backend. + */ + +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function detectCollisions(target: Record = {}, source: Record = {}): string[] { + return Object.keys(source).filter(k => k in target); +} diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index 6862a35..f5d3261 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -122,9 +122,8 @@ export type RenderResult = { /** * Extract error message from unknown error type. */ -export function getErrorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} +import { getErrorMessage } from './helpers'; +export { getErrorMessage }; /** * Render a template and parse it as YAML. diff --git a/src/backends/traefik/traefik.ts b/src/backends/traefik/traefik.ts index 64e334e..ddc5a4f 100644 --- a/src/backends/traefik/traefik.ts +++ b/src/backends/traefik/traefik.ts @@ -1,7 +1,8 @@ import fs from 'fs/promises'; import path from 'path'; import { OUTPUT_DIRECTORY, CONFIG_DIRECTORY } from '../../config'; -import { renderTemplateParsed, getErrorMessage } from './templateParser'; +import { renderTemplateParsed } from './templateParser'; +import { getErrorMessage } from './helpers'; import { TraefikConfigYamlFormat } from './types/traefik'; import * as manager from './traefikManager'; import { MagicProxyConfigFile } from '../../types/config'; diff --git a/src/backends/traefik/traefikManager.ts b/src/backends/traefik/traefikManager.ts index b911403..3dac671 100644 --- a/src/backends/traefik/traefikManager.ts +++ b/src/backends/traefik/traefikManager.ts @@ -36,9 +36,11 @@ let pendingFlush: { * Merge two records, with source values overwriting target values. * Logs a warning if any keys would be overwritten. */ +import { detectCollisions } from './helpers'; + function mergeRecord(target: Record = {}, source: Record = {}, section?: string): Record { if (section) { - const collisions = Object.keys(source).filter(key => key in target); + const collisions = detectCollisions(target, source); if (collisions.length > 0) { log.warn({ message: 'Config name collision detected - values will be overwritten', diff --git a/src/backends/traefik/validators.ts b/src/backends/traefik/validators.ts index 3e1b2b2..7de7914 100644 --- a/src/backends/traefik/validators.ts +++ b/src/backends/traefik/validators.ts @@ -1,5 +1,5 @@ import yaml from 'js-yaml'; -import { getErrorMessage } from './templateParser'; +import { getErrorMessage } from './helpers'; /** Allowed top-level keys in Traefik dynamic config */ const ALLOWED_TOP_KEYS = new Set(['http', 'tcp', 'udp']); From 50b0ae3ab6cdd205f0767cb383a9c9a62a798559 Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 10:20:41 -0500 Subject: [PATCH 11/19] refactor: consolidate types and remove duplicates - Remove duplicate ComposeFileReference from docker.d.ts (use provider's version) - Remove TraefikConfigYamlFormat re-export from types/index.ts (keep in backend) - Remove duplicate FieldData type from api/types.ts - Extract shared getClientIP utility from auth.ts and logging.ts - Clean up config.ts comments (fix typos, improve clarity) - Remove unnecessary re-export in templateParser.ts --- src/api/middleware/auth.ts | 12 +----------- src/api/middleware/logging.ts | 13 +------------ src/api/middleware/utils.ts | 13 +++++++++++++ src/api/types.ts | 4 ---- src/backends/traefik/templateParser.ts | 7 +------ src/config.ts | 16 ++++++---------- src/types/docker.d.ts | 18 ++++-------------- src/types/index.ts | 3 +-- 8 files changed, 27 insertions(+), 59 deletions(-) create mode 100644 src/api/middleware/utils.ts diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts index 5fb7c50..21603e2 100644 --- a/src/api/middleware/auth.ts +++ b/src/api/middleware/auth.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { zone } from '../../logging/zone'; +import { getClientIP } from './utils'; const log = zone('api:auth'); @@ -48,14 +49,3 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): // Key is valid, proceed next(); } - -/** - * Extract client IP from request - */ -function getClientIP(req: Request): string { - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded === 'string') { - return forwarded.split(',')[0].trim(); - } - return req.socket.remoteAddress || 'unknown'; -} diff --git a/src/api/middleware/logging.ts b/src/api/middleware/logging.ts index f18bbb5..97a95f8 100644 --- a/src/api/middleware/logging.ts +++ b/src/api/middleware/logging.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { zone } from '../../logging/zone'; +import { getClientIP } from './utils'; const log = zone('api:request'); @@ -41,15 +42,3 @@ export function requestLogging(req: Request, res: Response, next: NextFunction): next(); } - -/** - * Extract client IP from request - * Handles X-Forwarded-For header if behind a proxy - */ -function getClientIP(req: Request): string { - const forwarded = req.headers['x-forwarded-for']; - if (typeof forwarded === 'string') { - return forwarded.split(',')[0].trim(); - } - return req.socket.remoteAddress || 'unknown'; -} diff --git a/src/api/middleware/utils.ts b/src/api/middleware/utils.ts new file mode 100644 index 0000000..7ddc159 --- /dev/null +++ b/src/api/middleware/utils.ts @@ -0,0 +1,13 @@ +import { Request } from 'express'; + +/** + * Extract client IP from request. + * Handles X-Forwarded-For header when behind a proxy. + */ +export function getClientIP(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return forwarded.split(',')[0].trim(); + } + return req.socket.remoteAddress || 'unknown'; +} diff --git a/src/api/types.ts b/src/api/types.ts index a76c282..ebcd31d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,5 +1 @@ export type { APIConfig } from '../types/config'; - -export interface FieldData { - [key: string]: unknown; -} diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index f5d3261..cd120fb 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -1,6 +1,7 @@ import yaml from 'js-yaml'; import { XMagicProxyData } from '../../types/xmagic'; import { zone } from '../../logging/zone'; +import { getErrorMessage } from './helpers'; const log = zone('backends.traefik.template'); @@ -119,12 +120,6 @@ export type RenderResult = { parsed: T; }; -/** - * Extract error message from unknown error type. - */ -import { getErrorMessage } from './helpers'; -export { getErrorMessage }; - /** * Render a template and parse it as YAML. * Returns both the raw rendered string and the parsed object. diff --git a/src/config.ts b/src/config.ts index a2a28f0..4defadf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,16 +1,13 @@ import isDocker from "is-docker"; - -// set config direcotry to CONFIG_DIRECOTRY env or default to ./config: - +// Configuration directories - use environment variables or sensible defaults export const CONFIG_DIRECTORY = process.env.CONFIG_DIRECTORY || (isDocker() ? '/var/config/' : './config/'); - -// set output directory to CONFIG_DIRECOTRY env or default to ./generated: - export const OUTPUT_DIRECTORY = process.env.OUTPUT_DIRECTORY || (isDocker() ? '/var/generated/' : './generated/'); -// Lazy evaluation of DEFAULT_CONFIG_FILE to support FS mocking in tests -// (tests set up mocks before loading config module) +/** + * Get the default config file path. + * Uses lazy evaluation to support FS mocking in tests. + */ let _defaultConfigFile: string | null = null; export function getDefaultConfigFile(): string { if (_defaultConfigFile === null) { @@ -19,8 +16,7 @@ export function getDefaultConfigFile(): string { return _defaultConfigFile; } -// Export DEFAULT_CONFIG_FILE for backwards compatibility - it will be computed on first access -// This is important for FS mocking in tests +// For backwards compatibility - computed on first access export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); // Read the config file and load the YAML: diff --git a/src/types/docker.d.ts b/src/types/docker.d.ts index 784f6b5..0e31fdc 100644 --- a/src/types/docker.d.ts +++ b/src/types/docker.d.ts @@ -1,8 +1,9 @@ -import Docker from 'dockerode'; import { XMagicProxyData } from './xmagic'; -// Definition of Docker Compose file structure, with support for -// our custom x-magic-proxy object under services..x-magic-proxy +/** + * Docker Compose file structure with support for custom x-magic-proxy extension. + * @see https://docs.docker.com/compose/compose-file/ + */ export type ComposeFileData = { version?: string | number; @@ -58,14 +59,3 @@ export type ComposeFileData = { secrets?: Record; }; - - - -export interface ComposeFileReference { - path: string | null; - error?: string; - composeFile?: string | null; - // composeData contains x-magic-proxy info if successfully loaded - composeData?: ComposeFileData; - containers: Docker.ContainerInfo[]; -} diff --git a/src/types/index.ts b/src/types/index.ts index b62b061..f9f63ed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,5 @@ -// Re-export all types from their respective modules for convenient importing +// Re-export all types from their respective modules export * from './config'; export * from './docker'; export * from './host'; -export * from '../backends/traefik/types/traefik'; export * from './xmagic'; From dcd91ba966bb4a47d80126933c7646a4291edefb Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 10:24:42 -0500 Subject: [PATCH 12/19] refactor: clean up logging module and API middleware - Reorganize logger.ts imports (group and move type import to top) - Add JSDoc comments to logging functions - Clean up verbose comments and simplify code - Export getClientIP from middleware index - Simplify consoleFormat data source selection --- src/api/middleware/index.ts | 1 + src/logging/logger.ts | 80 +++++++++++++++++++------------------ 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/api/middleware/index.ts b/src/api/middleware/index.ts index 3b84cd2..6d363a6 100644 --- a/src/api/middleware/index.ts +++ b/src/api/middleware/index.ts @@ -3,3 +3,4 @@ export { apiLimiter } from './ratelimit'; export { authMiddleware, setAPIKey } from './auth'; export { errorHandler, notFoundHandler } from './errorHandler'; export { validateQuery, validateBodySize } from './validation'; +export { getClientIP } from './utils'; diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 1e8ff1c..4bcc05d 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,24 +1,32 @@ import * as winston from "winston"; +import type { Logform } from 'winston'; import fs from 'fs'; import path from 'path'; import util from 'util'; +// Configuration constants export const OVERSIZE_THRESHOLD = 100_000; // bytes export const CONSOLE_TRUNCATE_LENGTH = 1_000; // characters const LOG_FILE = process.env.LOG_FILE || path.join(process.cwd(), 'logs', 'app.log'); -// Define colors for levels and add them to winston +// Log info type with custom fields +type LogInfo = Logform.TransformableInfo & { + __serializedData?: string; + zone?: string; + data?: unknown; + timestamp?: string; +}; + +// Define colors for levels const levelColors: Record = { - info: 'cyan', // light blue - debug: 'gray', // gray + info: 'cyan', + debug: 'gray', error: 'red', warn: 'yellow' }; winston.addColors(levelColors); - - // Ensure log directory exists for file transport try { const logDir = path.dirname(LOG_FILE); @@ -26,18 +34,16 @@ try { fs.mkdirSync(logDir, { recursive: true }); } } catch (err) { - // If we can't make the dir, continue; file transport will error when used - // We don't want to crash the app during logger initialization console.error('[Logger] Failed to ensure log directory exists:', err); } -// Type-aware serialization for log data. Exported for testing and extension. +/** + * Type-aware serialization for log data. + * Safely converts any value to a string for logging. + */ export function serializeLogData(data: unknown): string { - // Strings are returned as-is if (typeof data === 'string') return data; - if (data === null) return 'null'; - if (data === undefined) return ''; if (typeof data === 'number') { @@ -47,42 +53,35 @@ export function serializeLogData(data: unknown): string { } if (typeof data === 'boolean') return data ? 'true' : 'false'; - if (typeof data === 'symbol') return data.toString(); + if (typeof data === 'function') { - const fn = data as (...args: unknown[]) => unknown; - return ``; + return ``; } if (Buffer.isBuffer(data)) { - // Use base64 to safely serialize binary data without huge expansion - return ``; + return ``; } - // Objects: try JSON.stringify, fallback to util.inspect for circular references + // Objects: try JSON.stringify, fallback to util.inspect for circular refs try { return JSON.stringify(data); } catch { - // Fallback to util.inspect for objects that can't be JSON-stringified return util.inspect(data, { depth: 2, breakLength: Infinity }); } } -import type { Logform } from 'winston'; - -type LogInfo = Logform.TransformableInfo & { __serializedData?: string; zone?: string; data?: unknown; timestamp?: string }; - -// Guard that checks incoming data size and replaces oversized payloads +/** + * Winston format that checks data size and replaces oversized payloads with an error. + */ export const overflowGuard = winston.format((info: LogInfo) => { - // If data is not present at all, or explicitly undefined, treat it as missing and do nothing if (!Object.prototype.hasOwnProperty.call(info, 'data') || info.data === undefined) { return info; } - // Serialize using type-aware serializer to avoid undefined/non-serializable issues let serialized: string; try { - serialized = serializeLogData(info.data as unknown); + serialized = serializeLogData(info.data); } catch (err) { const stack = err instanceof Error && err.stack ? err.stack : String(err); info.zone = 'logger'; @@ -92,13 +91,10 @@ export const overflowGuard = winston.format((info: LogInfo) => { return info; } - // Attach serialized copy for formatters and debugging without mutating original data info.__serializedData = serialized; - const bytes = Buffer.byteLength(serialized, 'utf8'); if (bytes > OVERSIZE_THRESHOLD) { - // Throw and capture stack for context, then replace message/meta try { throw new Error('Oversized log message'); } catch (err) { @@ -113,7 +109,10 @@ export const overflowGuard = winston.format((info: LogInfo) => { return info; }); -export function formatDataForConsole(data: unknown) { +/** + * Format data for console output with truncation. + */ +export function formatDataForConsole(data: unknown): string { if (data === undefined) return ''; let s: string; @@ -123,19 +122,23 @@ export function formatDataForConsole(data: unknown) { s = String(data); } - const bytes = Buffer.byteLength(s, 'utf8'); if (s.length > CONSOLE_TRUNCATE_LENGTH) { + const bytes = Buffer.byteLength(s, 'utf8'); return s.slice(0, CONSOLE_TRUNCATE_LENGTH) + ` ... `; } return s; } +/** + * Winston format to normalize log levels to lowercase. + */ export const lowerCaseLevel = winston.format((info) => { if (info.level) info.level = String(info.level).toLowerCase(); return info; }); +// Console format with colored output export const consoleFormat = winston.format.combine( overflowGuard(), lowerCaseLevel(), @@ -143,13 +146,13 @@ export const consoleFormat = winston.format.combine( winston.format.printf((info: LogInfo) => { const levelLabel = (info.level as string) ?? ''; const zone = info.zone ?? 'core'; - // Prefer serialized data for consistent, safe output - const dataSource = typeof info.__serializedData !== 'undefined' ? info.__serializedData : (Object.prototype.hasOwnProperty.call(info, 'data') ? info.data : undefined); - const dataPart = typeof dataSource !== 'undefined' ? ' ' + formatDataForConsole(dataSource) : ''; + const dataSource = info.__serializedData ?? (Object.prototype.hasOwnProperty.call(info, 'data') ? info.data : undefined); + const dataPart = dataSource !== undefined ? ' ' + formatDataForConsole(dataSource) : ''; return `[${levelLabel}][${zone}] ${info.message}${dataPart}`; }) ); +// File format with timestamps in syslog style const fileFormat = winston.format.combine( overflowGuard(), winston.format.timestamp(), @@ -157,10 +160,10 @@ const fileFormat = winston.format.combine( const ts = info.timestamp || new Date().toISOString(); const zone = info.zone ?? 'core'; const level = ((info.level as string) ?? '').toUpperCase(); + let dataPart = ''; - const serialized = info.__serializedData; - if (typeof serialized !== 'undefined') { - dataPart = ' ' + serialized; + if (info.__serializedData !== undefined) { + dataPart = ' ' + info.__serializedData; } else if (Object.prototype.hasOwnProperty.call(info, 'data')) { try { dataPart = ' ' + (typeof info.data === 'string' ? info.data : JSON.stringify(info.data)); @@ -169,12 +172,11 @@ const fileFormat = winston.format.combine( } } - // Simple syslog-like line: TIMESTAMP ZONE LEVEL: MESSAGE DATA return `${ts} ${zone} ${level}: ${info.message}${dataPart}`; }) ); -// When running unit tests we should avoid printing logs to console +// Suppress console output during tests const isTestEnv = process.env.NODE_ENV === 'test'; export const baseLogger = winston.createLogger({ From dd997b105a65f83eb8b08358c624fb64748e6633 Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 10:26:49 -0500 Subject: [PATCH 13/19] refactor: improve config and backend plugin modules - Reorganize config.ts imports at top - Extract VALID_BACKENDS as const array - Add JSDoc comments to exported functions - Convert BackendModule and BackendStatus to exported interfaces - Add JSDoc comments to backend plugin functions --- src/backends/backendPlugin.ts | 37 +++++++++++++++++++++++++++-------- src/config.ts | 27 +++++++++++++------------ 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/backends/backendPlugin.ts b/src/backends/backendPlugin.ts index 354f299..70e6c1f 100644 --- a/src/backends/backendPlugin.ts +++ b/src/backends/backendPlugin.ts @@ -2,22 +2,30 @@ import { loadConfigFile } from '../config'; import { MagicProxyConfigFile } from '../types/config'; import { HostEntry } from '../types/host'; -type BackendStatus = { registered?: string[]; outputFile?: string | null;[key: string]: unknown }; +/** Status returned by backend getStatus() */ +export interface BackendStatus { + registered?: string[]; + outputFile?: string | null; + [key: string]: unknown; +} -type BackendModule = { +/** Interface that all backend modules must implement */ +export interface BackendModule { initialize: (config?: MagicProxyConfigFile) => Promise; addProxiedApp: (entry: HostEntry) => Promise; removeProxiedApp: (appName: string) => Promise; getStatus: () => Promise; -}; +} let activeBackend: BackendModule | null = null; let activeName: string | null = null; +/** + * Load a backend module by name. + */ async function loadBackend(name: string): Promise { switch (name) { case 'traefik': { - // dynamic import to avoid load-time side effects const mod = await import('./traefik/traefik'); return { initialize: mod.initialize, @@ -31,11 +39,16 @@ async function loadBackend(name: string): Promise { } } +/** + * Initialize the backend from configuration. + */ export async function initialize(config?: MagicProxyConfigFile): Promise { const cfg = config || await loadConfigFile(); - const backendName: string = cfg.proxyBackend; + const backendName = cfg.proxyBackend; - if (!backendName) throw new Error('No proxyBackend configured'); + if (!backendName) { + throw new Error('No proxyBackend configured'); + } if (!activeBackend || activeName !== backendName) { activeBackend = await loadBackend(backendName); @@ -46,8 +59,7 @@ export async function initialize(config?: MagicProxyConfigFile): Promise { } /** - * Ensures backend is initialized and returns it. - * Throws if initialization fails. + * Get the active backend, initializing if needed. */ async function ensureBackend(): Promise { if (!activeBackend) { @@ -59,16 +71,25 @@ async function ensureBackend(): Promise { return activeBackend; } +/** + * Add or update a proxied application. + */ export async function addProxiedApp(entry: HostEntry): Promise { const backend = await ensureBackend(); return backend.addProxiedApp(entry); } +/** + * Remove a proxied application. + */ export async function removeProxiedApp(appName: string): Promise { const backend = await ensureBackend(); return backend.removeProxiedApp(appName); } +/** + * Get the current backend status. + */ export async function getStatus(): Promise { const backend = await ensureBackend(); return backend.getStatus(); diff --git a/src/config.ts b/src/config.ts index 4defadf..d727bd6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,7 @@ +import fs from 'fs'; +import yaml from 'js-yaml'; import isDocker from "is-docker"; +import { MagicProxyConfigFile } from './types/config'; // Configuration directories - use environment variables or sensible defaults export const CONFIG_DIRECTORY = process.env.CONFIG_DIRECTORY || (isDocker() ? '/var/config/' : './config/'); @@ -16,34 +19,34 @@ export function getDefaultConfigFile(): string { return _defaultConfigFile; } -// For backwards compatibility - computed on first access +// For backwards compatibility export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); -// Read the config file and load the YAML: -import fs from 'fs'; -import yaml from 'js-yaml'; -import { MagicProxyConfigFile } from './types/config'; +/** Valid proxy backend names */ +const VALID_BACKENDS = ['traefik'] as const; +/** + * Load and validate a configuration file. + */ export async function loadConfigFile(path?: string): Promise { const configPath = path || getDefaultConfigFile(); try { const fileContent = await fs.promises.readFile(configPath, 'utf-8'); const config = yaml.load(fileContent) as MagicProxyConfigFile; - - // validateConfig throws if invalid: validateConfig(config); - return config; } catch (error) { throw new Error(`Error loading config file at ${configPath}: ${error instanceof Error ? error.message : String(error)}`); } } -// config validator: +/** + * Validate configuration object. + * Throws if invalid. + */ export function validateConfig(config: MagicProxyConfigFile): boolean { - const validBackends = ['traefik']; - if (!config.proxyBackend || !validBackends.includes(config.proxyBackend)) { - throw new Error(`Invalid proxyBackend in config file. Must be one of: ${validBackends.join(', ')}`); + if (!config.proxyBackend || !VALID_BACKENDS.includes(config.proxyBackend)) { + throw new Error(`Invalid proxyBackend in config file. Must be one of: ${VALID_BACKENDS.join(', ')}`); } return true; } \ No newline at end of file From 29631954a56c413be6495e00233573ac4cc64482 Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 10:28:42 -0500 Subject: [PATCH 14/19] refactor: clean up traefik manager imports - Move detectCollisions import to top of file with other imports - Remove redundant section headers (Flush Debouncing) - Simplify comments on module state variables --- src/backends/traefik/traefikManager.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/backends/traefik/traefikManager.ts b/src/backends/traefik/traefikManager.ts index 3dac671..112312c 100644 --- a/src/backends/traefik/traefikManager.ts +++ b/src/backends/traefik/traefikManager.ts @@ -3,26 +3,19 @@ import path from 'path'; import yaml from 'js-yaml'; import { TraefikConfigYamlFormat } from './types/traefik'; import { validateGeneratedConfig } from './validators'; +import { detectCollisions } from './helpers'; import { zone } from '../../logging/zone'; const log = zone('backends.traefik.manager'); -/** Registry of app configs keyed by app name */ +// Registry of app configs keyed by app name const registry = new Map(); let outputFile: string | null = null; -/** Track whether temp file cleanup has been performed for current output file */ +// Track whether temp file cleanup has been performed for current output file let tempFilesCleanedUp = false; -// ───────────────────────────────────────────────────────────────────────────── -// Flush Debouncing -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Pending flush state for debouncing multiple rapid flushToDisk() calls. - * This prevents race conditions where multiple writes with different registry - * states could complete out of order. - */ +// Pending flush state for debouncing multiple rapid flushToDisk() calls let pendingFlush: { resolvers: Array<{ resolve: () => void; reject: (err: Error) => void }>; scheduled: boolean; @@ -36,8 +29,6 @@ let pendingFlush: { * Merge two records, with source values overwriting target values. * Logs a warning if any keys would be overwritten. */ -import { detectCollisions } from './helpers'; - function mergeRecord(target: Record = {}, source: Record = {}, section?: string): Record { if (section) { const collisions = detectCollisions(target, source); From ba0a5786de6976247257c1814e27974a291b4bb8 Mon Sep 17 00:00:00 2001 From: Stone Gray Date: Wed, 14 Jan 2026 10:31:19 -0500 Subject: [PATCH 15/19] refactor: remove dead code and update documentation - Remove unused xMagicProxySchema alias export - Rewrite backends/readme.md with cleaner formatting and updated references --- src/backends/readme.md | 109 +++++++++++++++++++++-------------------- src/types/xmagic.ts | 2 - 2 files changed, 55 insertions(+), 56 deletions(-) diff --git a/src/backends/readme.md b/src/backends/readme.md index 1372856..fbff2c0 100644 --- a/src/backends/readme.md +++ b/src/backends/readme.md @@ -1,56 +1,57 @@ # Adding a new proxy backend -magic-proxy is more or less setup for being fully proxy-agnostic. - -Summary -- Backends are loaded dynamically by [`loadBackend`](src/backends/backendPlugin.ts) and must match the backend shape defined by [`BackendModule`](src/backends/backendPlugin.ts). -- The platform initializes the selected backend via [`initialize`](src/backends/backendPlugin.ts) (which is called from [`startApp`](src/index.ts) after config is loaded with [`loadConfigFile`](src/config.ts) and validated by [`validateConfig`](src/config.ts)). - -Backend API (required) -- Export an object that implements the [`BackendModule`](src/backends/backendPlugin.ts) shape: - - initialize(config?: [`MagicProxyConfigFile`](src/types/config.d.ts)): Promise - - addProxiedApp(entry: [`HostEntry`](src/types/host.d.ts)): Promise - - removeProxiedApp(appName: string): Promise - - getStatus(): Promise<{ registered?: string[]; outputFile?: string | null; [key: string]: unknown }> - -Practical guidance / checklist -1. Module location & loading - - Add a module under `src/backends//` and export the required functions. - - Add a case in [`loadBackend`](src/backends/backendPlugin.ts) to dynamically import your module by `proxyBackend` name. - -2. Initialization - - Load any backend-specific configuration from the provided [`MagicProxyConfigFile`](src/types/config.d.ts). - - Validate required config and fail clearly (throw or log + exit). See the [`traefik`](src/backends/traefik/traefik.ts) behavior for examples. - - Set up any on-disk output paths or runtime state the backend needs. - -3. Registry / state & atomic writes - - Provide deterministic IDs for registered apps so getStatus and subsequent calls are stable. - - If writing files (e.g., dynamic proxy config): write atomically (tmp file + rename) and validate output where possible. See [`register`](src/backends/traefik/traefikManager.ts) and [`flushToDisk`](src/backends/traefik/traefikManager.ts) for patterns. - -4. addProxiedApp / removeProxiedApp behavior - - Accept a [`HostEntry`](src/types/host.d.ts) and perform idempotent registration. - - Ensure remove cleans up state so `getStatus()` reflects current registrations. - -5. getStatus - - Return an object containing `registered` (array of app names) and optionally `outputFile` (or other runtime metadata). - -6. Tests - - Add unit tests covering: - - Template loading and rendering. - - Registration/unregistration behavior. - - Output format validation (if generating files). - - Reuse helpers in [test/helpers/mockHelpers.ts](test/helpers/mockHelpers.ts) (FS mocks like `setupFSMocks` and `mockFileWrite`) and follow existing test patterns (see [test/legacy/backend.test.ts](test/legacy/backend.test.ts) and [test/legacy/traefik-file.test.ts](test/legacy/traefik-file.test.ts)). - -7. Config schema - - If new config fields are required, update [`src/types/config.d.ts`](src/types/config.d.ts) and ensure [`validateConfig`](src/config.ts) accepts the backend name (add to valid backends if needed). - -8. Error handling & startup - - Be explicit on fatal vs recoverable errors. The application startup (`startApp` in [`src/index.ts`](src/index.ts)) will exit on uncaught initialization errors; handle accordingly. - -Examples & references -- Reference backend implementation: [`src/backends/traefik/traefik.ts`](src/backends/traefik/traefik.ts) -- Manager utilities: [`src/backends/traefik/traefikManager.ts`](src/backends/traefik/traefikManager.ts) -- Backend plugin loader and API: [`src/backends/backendPlugin.ts`](src/backends/backendPlugin.ts) -- Types: [`MagicProxyConfigFile`](src/types/config.d.ts), [`HostEntry`](src/types/host.d.ts) - -If you want, I can scaffold a minimal backend module (with tests) using these patterns. \ No newline at end of file +magic-proxy supports pluggable proxy backends. + +## Summary +- Backends are loaded dynamically by `loadBackend` in `backendPlugin.ts` +- Backends must implement the `BackendModule` interface exported from `backendPlugin.ts` +- The platform initializes the selected backend via `initialize()` during startup + +## Backend API (required) + +Export a module that implements the `BackendModule` interface: + +```typescript +interface BackendModule { + initialize(config?: MagicProxyConfigFile): Promise; + addProxiedApp(entry: HostEntry): Promise; + removeProxiedApp(appName: string): Promise; + getStatus(): Promise; +} +``` + +## Implementation Checklist + +1. **Module location & loading** + - Add a module under `src/backends//` + - Add a case in `loadBackend()` to dynamically import your module + +2. **Initialization** + - Load backend-specific configuration from `MagicProxyConfigFile` + - Validate required config and fail clearly (throw or log + exit) + +3. **Registry & atomic writes** + - Provide deterministic IDs for registered apps + - Write files atomically (tmp file + rename) and validate output + +4. **addProxiedApp / removeProxiedApp** + - Accept a `HostEntry` and perform idempotent registration + - Ensure remove cleans up state so `getStatus()` reflects current registrations + +5. **getStatus** + - Return an object with `registered` (array of app names) and optionally `outputFile` + +6. **Tests** + - Add unit tests for template loading/rendering, registration, and output validation + - Reuse helpers in `test/helpers/mockHelpers.ts` + +7. **Config schema** + - Update `src/types/config.d.ts` if new config fields are required + - Update `validateConfig()` to accept the backend name + +## Reference Implementation + +See the Traefik backend: +- `src/backends/traefik/traefik.ts` - Main backend module +- `src/backends/traefik/traefikManager.ts` - Registry and file management +- `src/backends/backendPlugin.ts` - Plugin loader and interface \ No newline at end of file diff --git a/src/types/xmagic.ts b/src/types/xmagic.ts index 8c07193..c088e75 100644 --- a/src/types/xmagic.ts +++ b/src/types/xmagic.ts @@ -48,5 +48,3 @@ export function validateXMagicProxyData(data: unknown): XMagicProxyValidationRes return { valid: false, reason }; } - -export { XMagicProxySchema as xMagicProxySchema }; From e96659d89030df20b3a43e42316c5a93179802a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:07:05 +0000 Subject: [PATCH 16/19] Initial plan From b2816e018e4184eca0e3661340557bb7590cb235 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:09:45 +0000 Subject: [PATCH 17/19] Initial plan for addressing review comments Co-authored-by: stonegray <7140974+stonegray@users.noreply.github.com> --- package-lock.json | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8db7c2f..0b042b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1917,7 +1916,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.6.tgz", "integrity": "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2035,7 +2033,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -2374,7 +2371,6 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -2430,7 +2426,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2710,7 +2705,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2901,7 +2895,6 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3389,7 +3382,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3632,7 +3624,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5071,7 +5062,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6094,7 +6084,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6148,7 +6137,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6280,7 +6268,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6356,7 +6343,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", From d8ba03af0164c64229e799f2f18b6783f6b35e12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:13:02 +0000 Subject: [PATCH 18/19] Address review comments: improve templateParser, add helpers tests, fix config types Co-authored-by: stonegray <7140974+stonegray@users.noreply.github.com> --- src/backends/traefik/templateParser.ts | 7 +- src/config.ts | 2 +- test/unit/traefik/helpers.test.ts | 128 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 test/unit/traefik/helpers.test.ts diff --git a/src/backends/traefik/templateParser.ts b/src/backends/traefik/templateParser.ts index cd120fb..7e05ae4 100644 --- a/src/backends/traefik/templateParser.ts +++ b/src/backends/traefik/templateParser.ts @@ -77,6 +77,7 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr /** * Get a value from context, supporting nested property access with dot notation. * e.g., "userData.foo" returns context.userData.foo + * Converts primitives (string, number, boolean) to strings for template substitution. */ function getContextValue(path: string): string | undefined { const parts = path.split('.'); @@ -89,7 +90,11 @@ export function renderTemplate(template: string, appName: string, data: XMagicPr value = (value as Record)[part]; } - return typeof value === 'string' ? value : undefined; + // Convert primitives to strings, reject objects/arrays/functions + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return undefined; } // Replace all {{ key }} occurrences diff --git a/src/config.ts b/src/config.ts index d727bd6..c175ee1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ export function getDefaultConfigFile(): string { export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); /** Valid proxy backend names */ -const VALID_BACKENDS = ['traefik'] as const; +const VALID_BACKENDS: readonly string[] = ['traefik'] as const; /** * Load and validate a configuration file. diff --git a/test/unit/traefik/helpers.test.ts b/test/unit/traefik/helpers.test.ts new file mode 100644 index 0000000..74f7574 --- /dev/null +++ b/test/unit/traefik/helpers.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { getErrorMessage, detectCollisions } from '../../../src/backends/traefik/helpers'; + +describe('Traefik Helpers', () => { + describe('getErrorMessage', () => { + it('extracts message from Error object', () => { + const error = new Error('Something went wrong'); + expect(getErrorMessage(error)).toBe('Something went wrong'); + }); + + it('converts string to string', () => { + expect(getErrorMessage('plain string error')).toBe('plain string error'); + }); + + it('converts number to string', () => { + expect(getErrorMessage(42)).toBe('42'); + }); + + it('converts null to string', () => { + expect(getErrorMessage(null)).toBe('null'); + }); + + it('converts undefined to string', () => { + expect(getErrorMessage(undefined)).toBe('undefined'); + }); + + it('converts object to string', () => { + const obj = { code: 'ERR_UNKNOWN' }; + expect(getErrorMessage(obj)).toBe('[object Object]'); + }); + + it('handles TypeError', () => { + const error = new TypeError('Invalid type'); + expect(getErrorMessage(error)).toBe('Invalid type'); + }); + + it('handles custom Error subclass', () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + const error = new CustomError('Custom error message'); + expect(getErrorMessage(error)).toBe('Custom error message'); + }); + }); + + describe('detectCollisions', () => { + it('detects single collision', () => { + const target = { foo: 1, bar: 2 }; + const source = { foo: 3, baz: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['foo']); + }); + + it('detects multiple collisions', () => { + const target = { foo: 1, bar: 2, baz: 3 }; + const source = { foo: 10, bar: 20, qux: 40 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('foo'); + expect(collisions).toContain('bar'); + expect(collisions).toHaveLength(2); + }); + + it('returns empty array when no collisions', () => { + const target = { foo: 1, bar: 2 }; + const source = { baz: 3, qux: 4 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles empty target', () => { + const target = {}; + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles empty source', () => { + const target = { foo: 1, bar: 2 }; + const source = {}; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual([]); + }); + + it('handles both empty objects', () => { + const collisions = detectCollisions({}, {}); + expect(collisions).toEqual([]); + }); + + it('handles undefined target (default parameter)', () => { + const source = { foo: 1, bar: 2 }; + const collisions = detectCollisions(undefined, source); + expect(collisions).toEqual([]); + }); + + it('handles undefined source (default parameter)', () => { + const target = { foo: 1, bar: 2 }; + const collisions = detectCollisions(target, undefined); + expect(collisions).toEqual([]); + }); + + it('works with different value types', () => { + const target = { str: 'hello', num: 42, bool: true }; + const source = { str: 'world', obj: { nested: 'value' } }; + const collisions = detectCollisions(target, source); + expect(collisions).toEqual(['str']); + }); + + it('detects all keys when source is subset of target', () => { + const target = { a: 1, b: 2, c: 3, d: 4 }; + const source = { a: 10, c: 30 }; + const collisions = detectCollisions(target, source); + expect(collisions).toContain('a'); + expect(collisions).toContain('c'); + expect(collisions).toHaveLength(2); + }); + + it('preserves order of keys from source', () => { + const target = { a: 1, b: 2, c: 3 }; + const source = { c: 30, a: 10, b: 20 }; + const collisions = detectCollisions(target, source); + // Object.keys() order in modern JS follows insertion order + expect(collisions).toEqual(['c', 'a', 'b']); + }); + }); +}); From 66a5d193c7b7e3b4422bf0b2cb8d2259d9629242 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:14:14 +0000 Subject: [PATCH 19/19] Improve VALID_BACKENDS type to maintain literal type safety Co-authored-by: stonegray <7140974+stonegray@users.noreply.github.com> --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index c175ee1..41ba613 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ export function getDefaultConfigFile(): string { export const DEFAULT_CONFIG_FILE = getDefaultConfigFile(); /** Valid proxy backend names */ -const VALID_BACKENDS: readonly string[] = ['traefik'] as const; +const VALID_BACKENDS: readonly ['traefik'] = ['traefik']; /** * Load and validate a configuration file.