Skip to content

Commit fca25ee

Browse files
committed
feat(config): reload default alias without restart
1 parent c3b9e21 commit fca25ee

4 files changed

Lines changed: 119 additions & 14 deletions

File tree

src/config.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
22
import { dirname, join, resolve } from 'node:path';
33
import { fileURLToPath } from 'node:url';
44
import yaml from 'js-yaml';
@@ -15,20 +15,29 @@ export const DEFAULT_CONFIG = {
1515
};
1616

1717
let configCache = null;
18+
let cachedMtimeMs = null;
1819
const TEMPLATE_CONFIG_PATH = resolve(
1920
dirname(fileURLToPath(import.meta.url)),
2021
'..',
2122
'config.yaml'
2223
);
2324

24-
export function loadConfig() {
25+
export function loadConfig({ forceReload = false } = {}) {
2526
const homeConfigPath = getHomeConfigPath();
2627
ensureHomeConfig(homeConfigPath);
2728
let configPath = getConfigPath();
2829
if (resolve(configPath) === TEMPLATE_CONFIG_PATH) {
2930
configPath = homeConfigPath;
3031
}
31-
if (configCache && cachedPath === configPath) return configCache;
32+
if (!forceReload && configCache && cachedPath === configPath) {
33+
const currentMtime = getConfigMtime(configPath);
34+
if (currentMtime !== null && cachedMtimeMs === currentMtime) {
35+
return configCache;
36+
}
37+
if (currentMtime === null && cachedMtimeMs === null) {
38+
return configCache;
39+
}
40+
}
3241

3342
try {
3443
const content = readFileSync(configPath, 'utf-8');
@@ -42,11 +51,13 @@ export function loadConfig() {
4251
aliases: { ...DEFAULT_CONFIG.aliases, ...parsedAliases },
4352
};
4453
cachedPath = configPath;
54+
cachedMtimeMs = getConfigMtime(configPath);
4555
} catch (error) {
4656
if (error.code === 'ENOENT') {
4757
configCache = { ...DEFAULT_CONFIG };
4858
cachedPath = configPath;
4959
writeDefaultConfig(configPath, configCache);
60+
cachedMtimeMs = getConfigMtime(configPath);
5061
} else {
5162
throw error;
5263
}
@@ -107,6 +118,17 @@ function applyEnv(envConfig) {
107118
}
108119
}
109120

121+
function getConfigMtime(configPath) {
122+
try {
123+
return statSync(configPath).mtimeMs;
124+
} catch (error) {
125+
if (error.code === 'ENOENT') {
126+
return null;
127+
}
128+
throw error;
129+
}
130+
}
131+
110132
function writeDefaultConfig(configPath, config) {
111133
mkdirSync(dirname(configPath), { recursive: true });
112134
const content = yaml.dump(config, {

src/server-config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export async function buildServerConfig() {
1313
const portNumber = await findAvailablePort(proxyHost, portSpec);
1414

1515
const targetUrl = process.env.TARGET_URL;
16+
const hasExplicitTarget = Boolean(targetUrl);
1617
const defaultAlias = fileConfig.default_alias;
1718
const hasAliases = fileConfig.aliases && Object.keys(fileConfig.aliases).length > 0;
1819

@@ -27,6 +28,7 @@ export async function buildServerConfig() {
2728
let resolvedTargetUrl = null;
2829
let providerLabel = 'aliases-only';
2930
let proxyHeaders = null;
31+
let targetAlias = null;
3032

3133
const applyTargetPortOverride = (parsedTarget) => {
3234
if (!process.env.TARGET_PORT) return;
@@ -45,6 +47,7 @@ export async function buildServerConfig() {
4547
resolvedTargetUrl = parsedTarget.toString();
4648
providerLabel = targetUrl;
4749
proxyHeaders = aliasConfig.headers;
50+
targetAlias = targetUrl;
4851
} else {
4952
let parsedTarget;
5053
try {
@@ -81,6 +84,8 @@ export async function buildServerConfig() {
8184
provider: providerLabel,
8285
aliases: fileConfig.aliases,
8386
proxyHeaders,
87+
targetAlias,
88+
hasExplicitTarget,
8489
};
8590

8691
return {

src/server.js

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import express from 'express';
22
import { createProxyHandler, createStreamingProxyHandler } from './proxy.js';
33
import { parseAliasPath, resolveAliasConfig } from './aliases.js';
4-
import { shouldIgnoreRoute } from './config.js';
4+
import { loadConfig, shouldIgnoreRoute } from './config.js';
55
import { createViewerRouter } from './routes/viewer.js';
66

77
export function createServer(config, { onListen } = {}) {
@@ -28,14 +28,9 @@ export function createServer(config, { onListen } = {}) {
2828
return;
2929
}
3030

31-
// If no target configured and not an alias request, return 404
32-
if (!config.targetUrl && !aliasInfo) {
33-
res.status(404).json({
34-
error: 'No target configured',
35-
message: 'Use /__proxy__/<alias> or configure --target',
36-
});
37-
return;
38-
}
31+
const runtimeConfig = loadConfig();
32+
const runtimeAliases = runtimeConfig.aliases || {};
33+
const runtimeDefaultAlias = runtimeConfig.default_alias;
3934

4035
let proxyPathname = proxyUrl.pathname;
4136
let targetBaseUrl = config.targetUrl;
@@ -44,7 +39,7 @@ export function createServer(config, { onListen } = {}) {
4439
let providerLabel = config.provider;
4540

4641
if (aliasInfo) {
47-
const aliasConfig = resolveAliasConfig(config.aliases, aliasInfo.alias);
42+
const aliasConfig = resolveAliasConfig(runtimeAliases, aliasInfo.alias);
4843
if (!aliasConfig) {
4944
res.status(404).json({ error: 'Unknown alias' });
5045
return;
@@ -54,6 +49,18 @@ export function createServer(config, { onListen } = {}) {
5449
targetPath = `${aliasInfo.path}${proxyUrl.search}${proxyUrl.hash}`;
5550
proxyHeaders = aliasConfig.headers;
5651
providerLabel = aliasInfo.alias;
52+
} else {
53+
const resolved = resolveRootTarget(config, runtimeAliases, runtimeDefaultAlias);
54+
targetBaseUrl = resolved.targetBaseUrl;
55+
providerLabel = resolved.providerLabel;
56+
proxyHeaders = resolved.proxyHeaders;
57+
if (!targetBaseUrl) {
58+
res.status(404).json({
59+
error: 'No target configured',
60+
message: 'Use /__proxy__/<alias> or configure --target',
61+
});
62+
return;
63+
}
5764
}
5865

5966
if (shouldIgnoreRoute(proxyPathname)) {
@@ -94,6 +101,41 @@ export function createServer(config, { onListen } = {}) {
94101
return server;
95102
}
96103

104+
function resolveRootTarget(config, aliases, defaultAlias) {
105+
if (config.targetAlias) {
106+
const aliasConfig = resolveAliasConfig(aliases, config.targetAlias);
107+
if (!aliasConfig) {
108+
return { targetBaseUrl: null, providerLabel: 'aliases-only', proxyHeaders: null };
109+
}
110+
return {
111+
targetBaseUrl: aliasConfig.url,
112+
providerLabel: config.targetAlias,
113+
proxyHeaders: aliasConfig.headers,
114+
};
115+
}
116+
117+
if (!config.hasExplicitTarget) {
118+
if (!defaultAlias) {
119+
return { targetBaseUrl: null, providerLabel: 'aliases-only', proxyHeaders: null };
120+
}
121+
const aliasConfig = resolveAliasConfig(aliases, defaultAlias);
122+
if (!aliasConfig) {
123+
return { targetBaseUrl: null, providerLabel: 'aliases-only', proxyHeaders: null };
124+
}
125+
return {
126+
targetBaseUrl: aliasConfig.url,
127+
providerLabel: defaultAlias,
128+
proxyHeaders: aliasConfig.headers,
129+
};
130+
}
131+
132+
return {
133+
targetBaseUrl: config.targetUrl,
134+
providerLabel: config.provider,
135+
proxyHeaders: config.proxyHeaders || null,
136+
};
137+
}
138+
97139
function isStreamingRequest(req) {
98140
if (!req.body || req.body.length === 0) return false;
99141

tests/config.test.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, beforeEach, afterEach } from 'node:test';
22
import assert from 'node:assert';
3-
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3+
import { mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs';
44
import { join } from 'node:path';
55
import { tmpdir } from 'node:os';
66

@@ -113,4 +113,40 @@ describe('config loading', () => {
113113
assert.ok(Array.isArray(config.hide_from_viewer));
114114
assert.ok(config.aliases && typeof config.aliases === 'object');
115115
});
116+
117+
it('reloads config when file changes', async () => {
118+
const configPath = join(testDir, 'config.yaml');
119+
writeFileSync(
120+
configPath,
121+
[
122+
'default_alias: openai',
123+
'aliases:',
124+
' openai:',
125+
' url: "https://api.openai.com"',
126+
'',
127+
].join('\n'),
128+
'utf-8'
129+
);
130+
131+
const { loadConfig } = await import(`../src/config.js?t=${Date.now()}`);
132+
let config = loadConfig();
133+
assert.strictEqual(config.default_alias, 'openai');
134+
135+
writeFileSync(
136+
configPath,
137+
[
138+
'default_alias: poe',
139+
'aliases:',
140+
' poe:',
141+
' url: "https://api.poe.com"',
142+
'',
143+
].join('\n'),
144+
'utf-8'
145+
);
146+
const future = new Date(Date.now() + 2000);
147+
utimesSync(configPath, future, future);
148+
149+
config = loadConfig();
150+
assert.strictEqual(config.default_alias, 'poe');
151+
});
116152
});

0 commit comments

Comments
 (0)