Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,27 @@ await loadConfig({

When using the `native` loader and `fresh` is enabled, `dependencies` contains absolute paths for files imported by the config file.

### withConfigMeta

Preserve the original config path and dependencies when loading through an adapter:

```ts
import { withConfigMeta } from '@rstackjs/load-config';

const config = { name: 'my-tool' };

export default withConfigMeta(config, {
filePath: '/project/project.config.ts',
dependencies: ['/project/shared.ts'],
});
```

`loadConfig` uses the supplied `filePath` and merges `dependencies` with the adapter file and its collected dependencies, removing duplicates.

- Use absolute paths. `filePath: null` means no underlying config was found; `dependencies` is optional.
- The helper modifies and returns the original config object. Repeated calls replace its metadata. Frozen or non-extensible objects are not supported.
- Call it on the final config object: object spread and JSON serialization discard the metadata.

## License

[MIT](./LICENSE).
26 changes: 19 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getConfigExport, isConfigFunction } from './helpers.js';
import { loadWithJiti } from './jiti.js';
import { getConfigMeta } from './meta.js';
import { JS_CONFIG_REGEXP, loadWithNative } from './native.js';
import { resolveConfigPath } from './resolve.js';
import type {
Expand All @@ -8,8 +9,10 @@ import type {
LoadConfigResult,
} from './types.js';

export { withConfigMeta } from './meta.js';
export type {
ConfigDefinition,
ConfigFileMeta,
ConfigLoader,
LoadConfigOptions,
LoadConfigResult,
Expand Down Expand Up @@ -84,6 +87,7 @@ export async function loadConfig<
}

const { configExport, dependencies } = loadedConfig;
let content: Config;

if (isConfigFunction(configExport)) {
const result = await configExport(...configParams);
Expand All @@ -92,16 +96,24 @@ export async function loadConfig<
throw new Error('The config function must return a config object.');
}

return {
content: result,
filePath: configPath,
dependencies,
};
content = result;
} else {
content = configExport;
}

return {
content: configExport,
const meta = getConfigMeta(content);
const result: LoadConfigResult<Config> = {
content,
filePath: configPath,
dependencies,
};

if (meta) {
result.filePath = meta.filePath;
result.dependencies = [
...new Set([...(meta.dependencies ?? []), configPath, ...dependencies]),
];
}

return result;
}
35 changes: 35 additions & 0 deletions src/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ConfigFileMeta } from './types.js';

// Share the key across fresh imports and separately bundled copies of the loader.
const CONFIG_META = Symbol.for('@rstackjs/load-config/meta');

/**
* Attach file metadata in place and return the original configuration object.
* Repeated calls replace the metadata. Requires an extensible, unfrozen object.
* Call this after merging the config: object spread does not preserve metadata.
*/
export function withConfigMeta<Config extends object>(
config: Config,
meta: ConfigFileMeta,
): Config {
Object.defineProperty(config, CONFIG_META, {
configurable: true,
value: {
filePath: meta.filePath,
dependencies: [...(meta.dependencies ?? [])],
} satisfies ConfigFileMeta,
});

return config;
}

export function getConfigMeta(config: unknown): ConfigFileMeta | undefined {
if (
config !== null &&
typeof config === 'object' &&
Object.hasOwn(config, CONFIG_META)
) {
return (config as { [CONFIG_META]: ConfigFileMeta })[CONFIG_META];
}
return undefined;
}
23 changes: 19 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,34 @@ export type LoadConfigOptions<Params extends unknown[] = []> = {
fresh?: boolean;
};

export type ConfigFileMeta = {
/**
* Absolute path of the actual configuration file.
* Use `null` when no underlying configuration file was found.
*/
filePath: string | null;
/**
* Absolute paths of additional configuration dependencies.
* These are merged with the adapter file and its collected dependencies.
*/
dependencies?: readonly string[];
};

export type LoadConfigResult<Config = unknown> = {
/**
* The loaded configuration object.
*/
content: Config;
/**
* The path to the loaded configuration file.
* Return `null` if the configuration file is not found.
* The path to the loaded configuration file, or the source set by `withConfigMeta`.
* Returns `null` if no configuration file was found, including an explicit
* `null` source set by `withConfigMeta`.
*/
filePath: string | null;
/**
* Absolute file paths of statically imported (relative) dependencies of the
* config file.
* Absolute paths of collected configuration dependencies. When `withConfigMeta`
* is used, also includes explicit dependencies and the loaded adapter file,
* with duplicates removed.
*/
dependencies: string[];
};
Expand Down
3 changes: 3 additions & 0 deletions tests/config-meta/actual.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { name } from './shared.mjs';

export default { name };
21 changes: 21 additions & 0 deletions tests/config-meta/adapter.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fileURLToPath } from 'node:url';
import { withConfigMeta } from '../../src/meta.ts';
import config from './actual.config.mjs';

const filePath = fileURLToPath(new URL('./actual.config.mjs', import.meta.url));
const dependency = fileURLToPath(new URL('./shared.mjs', import.meta.url));
const meta = { filePath, dependencies: [dependency, dependency] };

// Repeated calls replace metadata on the same object.
const result = withConfigMeta({ ...config }, { filePath: null });
withConfigMeta(result, meta);
export default result;

export const sync = (name: string) => withConfigMeta({ name }, meta);
export const asyncConfig = async (name: string) => sync(name);
export const missing = withConfigMeta({}, { filePath: null });
export const ordinary = {
content: 'ordinary',
filePath: 'ordinary',
dependencies: [],
};
80 changes: 80 additions & 0 deletions tests/config-meta/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { join } from 'node:path';
import { expect, test } from 'rstack/test';
import { loadConfig, withConfigMeta } from '../../src/index';

const configPath = join(import.meta.dirname, 'adapter.config.ts');
const sourcePath = join(import.meta.dirname, 'actual.config.mjs');
const dependencyPath = join(import.meta.dirname, 'shared.mjs');

const load = (exportName: string | false) =>
loadConfig({ path: configPath, exportName });

test('attaches metadata in place without changing enumerable fields', () => {
const config = { name: 'app' };
const result = withConfigMeta(config, { filePath: sourcePath });

expect(result).toBe(config);
expect(Object.keys(result)).toEqual(['name']);
expect(JSON.stringify(result)).toBe('{"name":"app"}');
expect(Object.getOwnPropertySymbols({ ...result })).toEqual([]);
});

test.each(['native', 'jiti'] as const)(
'%s: loads metadata from config exports',
async (loader) => {
for (const [exportName, name] of [
['default', 'shared'],
['sync', 'from params'],
['asyncConfig', 'from params'],
]) {
const result = await loadConfig({
path: configPath,
loader,
exportName,
configParams: ['from params'],
});

expect(result).toEqual({
content: { name },
filePath: sourcePath,
dependencies: [dependencyPath, configPath],
});
}
},
);

test('preserves null sources and omitted dependencies', async () => {
expect(await load('missing')).toEqual({
content: {},
filePath: null,
dependencies: [configPath],
});
});

test('ignores ordinary metadata-like fields and disabled exports', async () => {
expect(await load('ordinary')).toEqual({
content: { content: 'ordinary', filePath: 'ordinary', dependencies: [] },
filePath: configPath,
dependencies: [],
});
expect(await load(false)).toEqual({
content: {},
filePath: configPath,
dependencies: [],
});
});

test('merges collected dependencies across fresh helper instances', async () => {
const result = await loadConfig({ path: configPath, fresh: true });

expect(result).toEqual({
content: { name: 'shared' },
filePath: sourcePath,
dependencies: [
dependencyPath,
configPath,
join(import.meta.dirname, '../../src/meta.ts'),
sourcePath,
],
});
});
1 change: 1 addition & 0 deletions tests/config-meta/shared.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const name = 'shared';