Skip to content

Commit 815272e

Browse files
committed
feat: route applications by host/urlPath declared in the root config
Where an application is served is a deployment concern, not an application concern, but `host`/`urlPath` were only readable from the config file that declared a plugin. For an application that is its own `config.yaml`, so the hostname and mount point had to be checked into the app — unoverridable from outside it (the env-config overlay is root-config-only). Worse, `host`/`urlPath` on a root-config *application* entry were silently inert. An application's plugin scopes read the application's own config.yaml and nothing carried the root entry's routing down to them, so `deploy_component urlPath=/api` (#1113) persisted a value that changed nothing. The flow described in #1113 only ever held for a root-declared *plugin*, whose scope does read the root config. The root config is now authoritative for where an application is served: my-app: host: api.example.com urlPath: /v1 - `scopeMount.ts` — pure mount model. `host` is replaced outright (an operator remapping a hostname must win over a value the app shipped). `urlPath` is composed rather than replaced, because a plugin's `urlPath` doubles as its app-internal base path (static's asset root, fastify's route prefix); replacing it would silently relocate app-internal URLs and collapse distinct plugins onto one path. Mount `/v1` + `static: { urlPath: assets }` → `/v1/assets/`. The composed value is a fixed point of `resolveBaseURLPath`, so downstream consumers keep resolving it without compounding the prefix. - Overlaid in `OptionsWatcher`, not at each call site, so `scope.options.getAll()` is the one effective view of a plugin's config. static's redirects and external paths, the EntryHandler's entry URLs, and fastify's route prefix are all correct with no changes of their own. Composed from the freshly-parsed file on every read, so live reload cannot compound the prefix. - Applied on both load paths: the root-config `package` recursion and the components-root directory scan. The scan is the path that matters most — it loads apps with no root entry at all, so a mount works for a payload-deployed app, not just an installed one. - `deploy_component` accepts and persists `host` alongside `urlPath`, rejecting a host that carries a port or path (it would never match the router's host compare). Also fixes the Scope `server` proxy passing a raw config `urlPath` straight to the router: a plugin that spreads its whole config section into these options (REST does) handed the router the literal './', which normalized to the unmatchable route '/.'. The proxy now resolves whichever source supplied the value. Documented in HarperFast/documentation#595.
1 parent 6b0162c commit 815272e

15 files changed

Lines changed: 672 additions & 14 deletions

File tree

components/OptionsWatcher.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DEFAULT_CONFIG } from './DEFAULT_CONFIG.ts';
99
import { cloneDeep } from 'lodash';
1010
import { POLLING_FALLBACK_OPTIONS, isWatcherExhaustionError, warnWatcherFallback } from '../utility/watcherFallback.ts';
1111
import { overlayRootEnvConfig, isRootConfigFilename } from '../config/harperConfigEnvVars.ts';
12+
import { applyScopeMount, type ScopeMount } from './scopeMount.ts';
1213

1314
export interface Config {
1415
[key: string]: ConfigValue;
@@ -94,12 +95,18 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
9495
#closed: boolean;
9596
#openCount: number = 0;
9697
#pendingReads: Set<Promise<void>> = new Set();
98+
#mount?: ScopeMount;
9799
ready: Promise<any[]>;
98100

99-
constructor(name: string, filePath: string, logger?: Logger, isRootConfig?: boolean) {
101+
constructor(name: string, filePath: string, logger?: Logger, isRootConfig?: boolean, mount?: ScopeMount) {
100102
super();
101103
this.#name = name;
102104
this.#filePath = filePath;
105+
// The operator-declared mount for the application this scope belongs to (root config).
106+
// Overlaid on every read so `getAll()` is the single effective view of this plugin's
107+
// config — consumers that resolve routing from it (the `server` proxy, EntryHandler,
108+
// static, fastifyRoutes) are correct without each having to know about mounts.
109+
this.#mount = mount;
103110
// Root-config watchers must see runtime env config (HARPER_SET_CONFIG et al.)
104111
// even when it hasn't been flushed to disk yet — see #handleChange (#1618).
105112
// Application scopes watch their own config.yaml and are never overlaid.
@@ -143,12 +150,12 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
143150
// If a config object does not exist
144151
if (!this.#scopedConfig) {
145152
// set it
146-
this.#scopedConfig = this.#rootConfig[this.#name];
153+
this.#scopedConfig = this.#mountedSection(this.#rootConfig[this.#name]);
147154
// and emit a ready event
148155
this.emit('ready', this.#scopedConfig);
149156
} else {
150157
// Otherwise, merge the new config with the old config
151-
this.#merge(this.#rootConfig[this.#name], this.#scopedConfig);
158+
this.#merge(this.#mountedSection(this.#rootConfig[this.#name]), this.#scopedConfig);
152159
}
153160
} else {
154161
// Otherwise, if the extension is not in the config file
@@ -255,17 +262,26 @@ export class OptionsWatcher extends EventEmitter<OptionsWatcherEventMap> {
255262
if (!composed || !(this.#name in composed)) return false;
256263
this.#rootConfig = composed;
257264
if (!this.#scopedConfig) {
258-
this.#scopedConfig = composed[this.#name];
265+
this.#scopedConfig = this.#mountedSection(composed[this.#name]);
259266
this.emit('ready', this.#scopedConfig);
260267
} else {
261-
this.#merge(composed[this.#name], this.#scopedConfig);
268+
this.#merge(this.#mountedSection(composed[this.#name]), this.#scopedConfig);
262269
}
263270
return true;
264271
}
265272

266273
#resetConfig() {
267274
this.#rootConfig = DEFAULT_CONFIG;
268-
this.#scopedConfig = this.#rootConfig[this.#name];
275+
this.#scopedConfig = this.#mountedSection(this.#rootConfig[this.#name]);
276+
}
277+
278+
/**
279+
* Overlays this scope's application mount onto a raw config section. Composed from the
280+
* freshly-parsed value on every read, so the prefix never compounds. Returns the section
281+
* unchanged (same reference) when there is no mount.
282+
*/
283+
#mountedSection(section: ConfigValue): ConfigValue {
284+
return applyScopeMount(section, this.#name, this.#mount);
269285
}
270286

271287
/**

components/Scope.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { FileAndURLPathConfig } from './Component.ts';
1111
import { FilesOption } from './deriveGlobOptions.ts';
1212
import { requestRestart } from './requestRestart.ts';
1313
import { resolveBaseURLPath } from './resolveBaseURLPath.ts';
14+
import { type ScopeMount } from './scopeMount.ts';
1415
import { ApplicationScope } from './ApplicationScope.ts';
1516
import {
1617
getSecretsForComponent,
@@ -88,7 +89,8 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
8889
configFilePath: string,
8990
applicationScope: ApplicationScope,
9091
origin: string = appName,
91-
isRootConfig?: boolean
92+
isRootConfig?: boolean,
93+
mount?: ScopeMount
9294
) {
9395
super();
9496

@@ -118,13 +120,18 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
118120
if (typeof method === 'function') {
119121
return (listener: any, options?: any) => {
120122
const scopeConfig = (scopeRef.options?.getAll() as any) ?? {};
123+
// An explicit call option wins over config, but either way the value is
124+
// resolved here rather than passed through: plugins that spread their whole
125+
// config section into these options (e.g. REST) would otherwise hand the
126+
// router a raw, unresolved `urlPath` — './' became the literal route '/.'.
127+
const rawUrlPath = options?.urlPath ?? scopeConfig.urlPath;
121128
return method.call(target, listener, {
122129
name: pluginName,
130+
...options,
123131
// resolve to the same base the entry pipeline uses ('assets' -> '/assets/',
124132
// './x' -> '/<name>/x/') so route matching sees a real pathname prefix (#1583)
125-
urlPath: scopeConfig.urlPath ? resolveBaseURLPath(pluginName, scopeConfig.urlPath) : undefined,
126-
host: scopeConfig.host || undefined,
127-
...options,
133+
urlPath: rawUrlPath ? resolveBaseURLPath(pluginName, rawUrlPath) : undefined,
134+
host: options?.host || scopeConfig.host || undefined,
128135
});
129136
};
130137
}
@@ -142,7 +149,7 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
142149
// isRootConfig is the loader's authoritative isRoot signal — it decides whether the
143150
// watcher overlays runtime env config (#1618). When a caller doesn't provide it,
144151
// OptionsWatcher falls back to its root-config filename heuristic.
145-
this.options = new OptionsWatcher(pluginName, configFilePath, this.#logger, isRootConfig)
152+
this.options = new OptionsWatcher(pluginName, configFilePath, this.#logger, isRootConfig, mount)
146153
.on('error', this.#handleError.bind(this))
147154
.on('change', this.#optionsWatcherChangeListener.bind(this)())
148155
.on('ready', this.#handleOptionsWatcherReady.bind(this));

components/componentLoader.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import * as scheduler from '../resources/scheduler/scheduler.ts';
2929
import { restartWorkers, getWorkerIndex } from '../server/threads/manageThreads.js';
3030
import { resetRestartNeeded, subscribeToRestartRequests } from './requestRestart.ts';
3131
import { trackScopeClose } from './scopeShutdown.ts';
32+
import { toScopeMount, type ScopeMount } from './scopeMount.ts';
3233
import { scopedImport } from '../security/jsLoader.ts';
3334
import { server } from '../server/Server.ts';
3435
import { Resources } from '../resources/Resources.ts';
@@ -63,6 +64,33 @@ let resources;
6364
* @param loadedPluginModules
6465
* @param loadedResources
6566
*/
67+
/**
68+
* The application mount an operator declared for `appName` in the root config.
69+
*
70+
* Applications in the components root are loaded by directory scan, not from a root-config
71+
* entry, so this is what makes a root-config entry authoritative for where an app is served:
72+
*
73+
* ```yaml
74+
* my-app:
75+
* host: api.example.com
76+
* urlPath: /v1
77+
* ```
78+
*
79+
* Works whether or not the entry also carries `package` — a payload-deployed app is mounted the
80+
* same way as an installed one. A built-in plugin's config block is never an application mount;
81+
* those keys (`http`, `mqtt`, …) configure the plugin itself.
82+
*/
83+
function rootConfigMount(appName: string): ScopeMount | undefined {
84+
if (Object.hasOwn(TRUSTED_RESOURCE_PLUGINS, appName)) return undefined;
85+
try {
86+
return toScopeMount(getConfigObj()?.[appName]);
87+
} catch (error) {
88+
// An invalid mount must not take down every other application's load.
89+
harperLogger.error(`Ignoring invalid routing configured for '${appName}': ${(error as Error).message}`);
90+
return undefined;
91+
}
92+
}
93+
6694
export async function loadComponentDirectories(loadedPluginModules?: Map<any, any>, loadedResources?: Resources) {
6795
if (loadedResources) resources = loadedResources;
6896
if (loadedPluginModules) loadedComponents = loadedPluginModules;
@@ -82,7 +110,12 @@ export async function loadComponentDirectories(loadedPluginModules?: Map<any, an
82110
const appName = appEntry.name;
83111
const appFolder = join(CF_ROUTES_DIR, appName);
84112
cfsLoaded.push(
85-
loadComponent(appFolder, resources, HDB_ROOT_DIR_NAME, { isRoot: false, autoReload: false, appName })
113+
loadComponent(appFolder, resources, HDB_ROOT_DIR_NAME, {
114+
isRoot: false,
115+
autoReload: false,
116+
appName,
117+
mount: rootConfigMount(appName),
118+
})
86119
);
87120
}
88121
}
@@ -94,6 +127,7 @@ export async function loadComponentDirectories(loadedPluginModules?: Map<any, an
94127
isRoot: false,
95128
autoReload: Boolean(process.env.DEV_MODE),
96129
appName: hdbAppFolder,
130+
mount: rootConfigMount(basename(hdbAppFolder)),
97131
})
98132
);
99133
}
@@ -316,6 +350,10 @@ export interface LoadComponentOptions {
316350
// (e.g. the deploy pre-flight validation) so their deploy-lifecycle listeners don't accumulate
317351
// across deploys (#1462).
318352
collectScopes?: Set<Scope>;
353+
// Routing the operator declared for this application in the root config (`host`/`urlPath` on
354+
// the application's entry). Applied to every plugin scope this load creates, and inherited by
355+
// components the application itself declares, so the whole subtree moves together.
356+
mount?: ScopeMount;
319357
}
320358

321359
/**
@@ -342,6 +380,7 @@ export async function loadComponent(
342380
isRoot,
343381
autoReload,
344382
appName,
383+
mount,
345384
} = options;
346385
applicationScope.allowedPath ??= realpathSync(componentDirectory);
347386
if (providedLoadedComponents) loadedComponents = providedLoadedComponents;
@@ -485,6 +524,12 @@ export async function loadComponent(
485524
autoReload: false,
486525
appName: appName || componentName,
487526
collectScopes: options.collectScopes,
527+
// `host`/`urlPath` on this entry route the component being loaded. For an
528+
// application (no plugin module of its own) that entry is the only place an
529+
// operator can say where the app is served — its own config.yaml declares the
530+
// plugins, not the deployment. An inherited mount wins so a nested component
531+
// can't escape the mount its parent application was given.
532+
mount: mount ?? toScopeMount(componentConfig),
488533
});
489534
componentFunctionality[componentName] = true;
490535
}
@@ -554,7 +599,10 @@ export async function loadComponent(
554599
// authoritative root-ness: only root-load scopes watch THE root config and
555600
// get the runtime env-config overlay (#1618) — an app component that happens
556601
// to ship a root-named config file does not
557-
isRoot
602+
isRoot,
603+
// A root-declared plugin reads its own `host`/`urlPath` straight from this
604+
// config, so only an inherited application mount applies here.
605+
mount
558606
);
559607

560608
if (options.collectScopes) {

components/operations.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ async function deployComponent(req) {
410410
};
411411
}
412412
if (req.urlPath !== undefined) applicationConfig.urlPath = req.urlPath;
413+
if (req.host !== undefined) applicationConfig.host = req.host;
413414
// Persist credential references (never tokens) so every cold install of this component —
414415
// reboot, new peer, rollback — re-resolves the credential from the store.
415416
if (credentialReferences.length) applicationConfig.credentials = credentialReferences;

components/operationsValidation.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,10 @@ function deployComponentValidator(req) {
461461
})
462462
.optional()
463463
.messages({ 'any.invalid': 'urlPath must not contain ".."' }),
464+
// Virtual hostname the component is served on. Like `urlPath`, this is deployment routing and
465+
// belongs on the root-config entry, not in the component's own config.yaml. `hostname()`
466+
// rejects a value carrying a port or path, which would never match the router's host compare.
467+
host: Joi.string().hostname().optional(),
464468
// Deploy credentials. The array is kind-heterogeneous: an entry's kind is implied by its
465469
// identifying key rather than a separate discriminator field, so a new kind is added as
466470
// another item alternative here without reshaping the field. Today: npm registry auth
@@ -490,7 +494,9 @@ function deployComponentValidator(req) {
490494
registryAuth: Joi.any().forbidden().messages({
491495
'any.unknown': `'registryAuth' has been renamed to 'credentials'`,
492496
}),
493-
}).with('urlPath', 'package');
497+
})
498+
.with('urlPath', 'package')
499+
.with('host', 'package');
494500

495501
return validator.validateBySchema(req, deployProjSchema);
496502
}

components/scopeMount.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { InvalidBaseURLPathError, resolveBaseURLPath } from './resolveBaseURLPath.ts';
2+
3+
/**
4+
* The routing an operator declared for an application in the *root* config, e.g.
5+
*
6+
* ```yaml
7+
* my-app:
8+
* package: '@my/app'
9+
* host: api.example.com
10+
* urlPath: /v1
11+
* ```
12+
*
13+
* Where an application is served is a deployment concern, not an application concern, so
14+
* the root config is authoritative: a checked-in `config.yaml` cannot pin the hostname or
15+
* mount point an operator has chosen. The mount is applied to every plugin scope loaded
16+
* for that application (and, transitively, to plugins the application itself declares).
17+
*/
18+
export interface ScopeMount {
19+
host?: string;
20+
urlPath?: string;
21+
}
22+
23+
/**
24+
* Normalizes a mount prefix to a leading-slash, no-trailing-slash form ('/v1'), or
25+
* undefined when it constrains nothing ('', '/', undefined) — matching
26+
* `middlewareChain.normalizeUrlPath`, so a mount that means "the root" composes to
27+
* exactly the plugin's own path rather than rewriting it.
28+
*/
29+
export function normalizeMountPath(urlPath: string | undefined): string | undefined {
30+
if (!urlPath) return undefined;
31+
if (urlPath.includes('..')) throw new InvalidBaseURLPathError(urlPath);
32+
let normalized = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;
33+
normalized = normalized.replace(/\/+$/, '');
34+
return normalized.length <= 1 ? undefined : normalized;
35+
}
36+
37+
/**
38+
* Returns the mount, or undefined when it declares no routing at all — so callers can
39+
* skip the overlay entirely and leave existing (unmounted) config objects untouched.
40+
*/
41+
export function toScopeMount(config: unknown): ScopeMount | undefined {
42+
if (!config || typeof config !== 'object') return undefined;
43+
const { host, urlPath } = config as ScopeMount;
44+
const mountHost = typeof host === 'string' && host ? host : undefined;
45+
const mountPath = normalizeMountPath(typeof urlPath === 'string' ? urlPath : undefined);
46+
if (!mountHost && !mountPath) return undefined;
47+
return { host: mountHost, urlPath: mountPath };
48+
}
49+
50+
/**
51+
* Composes an application mount with a plugin's own `urlPath`.
52+
*
53+
* The plugin part is resolved first (`resolveBaseURLPath` semantics: `'.'`/`'./x'` namespace
54+
* under the plugin name) and the mount is then prefixed, so app-internal structure survives
55+
* relocation: mount `/v1` + `static: { urlPath: assets }` → `/v1/assets/`. The result is
56+
* already absolute and slash-terminated, which makes it a fixed point of
57+
* `resolveBaseURLPath` — downstream consumers (the `server` proxy, `EntryHandler`,
58+
* `static`, `fastifyRoutes`) can keep resolving it without compounding the prefix.
59+
*/
60+
export function composeMountedUrlPath(
61+
mountPath: string | undefined,
62+
pluginName: string,
63+
pluginUrlPath: string | undefined
64+
): string | undefined {
65+
if (!mountPath) return pluginUrlPath;
66+
return `${mountPath}${resolveBaseURLPath(pluginName, pluginUrlPath)}`;
67+
}
68+
69+
/**
70+
* Overlays an application mount onto one plugin's config section.
71+
*
72+
* `host` is replaced outright — an operator remapping an app's hostname must win over a
73+
* value the app shipped, which is the whole point of moving routing to the root config.
74+
* `urlPath` is composed rather than replaced, because a plugin's `urlPath` doubles as its
75+
* app-internal base path (static's asset root, fastify's route prefix); replacing it would
76+
* silently relocate app-internal URLs and collapse distinct plugins onto one path.
77+
*
78+
* Returns `section` unchanged when there is no mount, so unmounted applications keep both
79+
* their exact config values and object identity.
80+
*/
81+
export function applyScopeMount<T>(section: T, pluginName: string, mount?: ScopeMount): T {
82+
if (!mount || section === undefined || section === null) return section;
83+
// A plugin enabled with a bare `true` (e.g. `rest: true`) still needs to carry the mount,
84+
// so promote it to an object rather than dropping the routing on the floor.
85+
const base: Record<string, unknown> = typeof section === 'object' ? { ...(section as object) } : {};
86+
if (mount.host) base.host = mount.host;
87+
const composed = composeMountedUrlPath(mount.urlPath, pluginName, base.urlPath as string | undefined);
88+
if (composed !== undefined) base.urlPath = composed;
89+
return base as T;
90+
}

0 commit comments

Comments
 (0)