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
6 changes: 6 additions & 0 deletions .changeset/nice-books-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@withstudiocms/config-utils": minor
"studiocms": minor
---

Migrates to a Vite-based loader with native ESM and SSR fallback support; callers now use the filesystem-aware options API.
7 changes: 3 additions & 4 deletions packages/@withstudiocms/config-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,14 @@
}
},
"type": "module",
"dependencies": {
"tsdown": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"astro": "catalog:astrojs-current"
"astro": "catalog:astrojs-current",
"vite": "catalog:astrojs-current"
},
"peerDependencies": {
"astro": "catalog:astrojs-min",
"vite": "catalog:astrojs-min",
"effect": "catalog:effect"
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import type { HookParameters } from 'astro';
import { Schema } from 'effect';
import { loadConfigFile } from './loader.js';
Expand Down Expand Up @@ -27,16 +28,16 @@ export const configResolverBuilder =
const logger = l.fork(`${label}:config`);

// Load the config file
const loadedConfigFile = await loadConfigFile<A>(astroRoot, configPaths, label);
const loadedViteConfigFile = await loadConfigFile({ configPaths, root: astroRoot, fs });

// If no config file was found, return the result of the effect with an empty object as input
if (!loadedConfigFile) {
logger.info('No config file found. Using default configuration.');
// If no config file is found, log a message and return the default configuration using the effect schema
if (Object.keys(loadedViteConfigFile).length === 0) {
logger.info('No config entries found. Using default configuration.');
return await Schema.decodeUnknownPromise(effectSchema)({});
}

// Parse the loaded config file using the provided effect schema
const parsedConfig = await Schema.decodeUnknownPromise(effectSchema)(loadedConfigFile);
const parsedConfig = await Schema.decodeUnknownPromise(effectSchema)(loadedViteConfigFile);

// Log a message indicating that the config file was loaded and parsed successfully
logger.info('Config file loaded and parsed successfully.');
Expand Down
189 changes: 53 additions & 136 deletions packages/@withstudiocms/config-utils/src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,167 +1,84 @@
import { constants } from 'node:fs';
import { access, unlink, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { build as tsdown } from 'tsdown';
import type {
ImportBundledFileArgs,
LoadAndBundleConfigFileArgs,
LoadAndBundleConfigFileResult,
} from './types.js';
import { tryCatch } from './utils/tryCatch.js';
import type fsType from 'node:fs';
import { isRunnableDevEnvironment, type ViteDevServer } from 'vite';
import { createMinimalViteDevServer } from './utils/createMinimalViteDevServer.js';
import loadFallbackPlugin from './utils/vite-plugin-load-fallback.js';

/**
* Bundle arbitrary `mjs` or `ts` file.
* Simplified fork from Vite's `bundleConfigFile` function.
* Options for loading a configuration file with Vite.
*
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961
* @property root - The root directory for resolving imports.
* @property configPaths - An array of possible configuration file paths to check, relative to `root`.
* @property fs - The file system module to use for checking file existence.
*/
export async function bundleConfigFile({ fileUrl }: { fileUrl: URL }) {
const result = await tsdown({
cwd: process.cwd(),
entry: fileURLToPath(fileUrl),
format: 'esm',
target: 'node18',
platform: 'node',
logLevel: 'silent',
write: false,
});

const chunks = result[0].chunks.filter((chunk) => chunk.type === 'chunk');

if (chunks.length === 0) {
throw new Error('No output chunks found after bundling config file');
}

const file = chunks.find((chunk) => chunk.fileName.endsWith('.mjs'))?.code;

if (!file) {
throw new Error('Unexpected: no output file');
}

return {
code: file,
};
export interface LoadConfigWithViteOptions {
root: URL;
configPaths: string[];
fs: typeof fsType;
}

/**
* Forked from Vite config loader, replacing CJS-based path concat with ESM only
* Loads a configuration file using Vite's development server.
*
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L1074
* @param options - The options for loading the configuration file.
* @param options.root - The root directory for resolving imports.
* @param options.configPaths - An array of possible configuration file paths to check, relative to `root`.
* @param options.fs - The file system module to use for checking file existence.
* @returns A promise that resolves to the configuration object, or an empty object if no config file is found.
*/
export async function importBundledFile({
code,
export async function loadConfigFile({
configPaths,
root,
label = 'bundled-tmp.config',
}: ImportBundledFileArgs): Promise<{ default?: unknown }> {
// Write it to disk, load it with native Node ESM, then delete the file.
const tmpFileUrl = new URL(
`./${label}.timestamp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}.mjs`,
root
);
await writeFile(tmpFileUrl, code, { encoding: 'utf8' });
try {
return await import(/* @vite-ignore */ tmpFileUrl.toString());
} finally {
await tryCatch(unlink(tmpFileUrl));
}
}

/**
* Loads and bundles a configuration file, then imports the bundled module.
*
* @param params - The arguments for loading and bundling the config file.
* @param params.root - The root directory for resolving imports.
* @param params.fileUrl - The URL or path to the configuration file to load.
* @param params.label - A label used for logging or identification purposes.
* @returns A promise that resolves to an object containing the imported module (`mod`)
* and an array of its dependencies.
*/
export async function loadAndBundleConfigFile({
root,
fileUrl,
label,
}: LoadAndBundleConfigFileArgs): Promise<LoadAndBundleConfigFileResult> {
// If no file URL is provided, return an empty result
if (!fileUrl) {
return { mod: undefined };
}

// Bundle the configuration file and get its code and dependencies
const { code } = await bundleConfigFile({
fileUrl,
});

// Import the bundled file using the provided root URL and optional label
return {
mod: await importBundledFile({ code, root, label }),
};
}

/**
* Loads a configuration file from a list of possible paths relative to a given root URL.
*
* Iterates through the provided `configPaths`, checking for the existence of each file.
* If a file is found, it is loaded and bundled, and its default export is returned as the configuration object.
* If no valid configuration file is found, or if the file does not have a valid default export, the function returns `undefined`
* or throws an error, respectively.
*
* @typeParam R - The expected shape of the configuration object.
* @param root - The base URL to resolve configuration file paths against.
* @param configPaths - An array of possible configuration file paths to check, relative to `root`.
* @returns A promise that resolves to the configuration object of type `R`, or `undefined` if no valid config file is found.
* @throws If a config file is found but does not have a valid default export.
*/
export async function loadConfigFile<R>(
root: URL,
configPaths: string[],
label?: string
): Promise<R | undefined> {
fs,
}: LoadConfigWithViteOptions): Promise<Record<string, unknown>> {
let configFileUrl: URL | undefined;

// Check each path in the configPaths array to see if the file exists
// If a file exists, set configFileUrl to that URL
for (const path of configPaths) {
const fileUrl = new URL(path, root);
try {
await access(fileUrl, constants.F_OK);
await fs.promises.access(fileUrl, fs.constants.F_OK);
configFileUrl = fileUrl;
break;
} catch {
// File does not exist, continue to the next path
}
}

// If no config file was found, return undefined
// This is important to avoid unnecessary errors when no config file is present
// If no config file was found, return an empty object
if (!configFileUrl) {
return undefined;
return {};
}

// Load and bundle the configuration file
// This will return the module and its dependencies
const { mod: configMod } = await loadAndBundleConfigFile({
root,
fileUrl: configFileUrl,
label,
});

// If no module was returned, return undefined
// This is important to handle cases where the config file might be empty or invalid
if (!configMod) {
return undefined;
// Attempt to load config using Node's native ESM loader if the file has a .js, .cjs, or .mjs extension
if (/\.[cm]?js$/.test(configFileUrl.pathname)) {
try {
const config = await import(`${configFileUrl.toString()}?t=${Date.now()}`);
return config.default ?? {};
} catch (e) {
if (e && typeof e === 'object' && 'code' in e && e.code === 'ERR_DLOPEN_DISABLED') {
throw e;
}

console.debug('Failed to load config with Node', e);
}
}

// Check if the module has a default export
// If not, throw an error indicating that the default export is missing or invalid
if (!configMod.default) {
throw new Error(
'Missing or invalid default export. Please export your config object as the default export.'
);
}
// Else try loading with Vite
let server: ViteDevServer | undefined;
try {
const plugins = loadFallbackPlugin({ fs, root });
server = await createMinimalViteDevServer(plugins);

// Return the default export of the module, which is expected to be of type R
// This is the actual configuration object that will be used by the application
// The type assertion ensures that the returned object conforms to the expected shape of R
// This is important for type safety and to ensure that the configuration object has the expected properties
return configMod.default as R;
if (isRunnableDevEnvironment(server.environments.ssr)) {
const environment = server.environments.ssr;
const mod = await environment.runner.import(configFileUrl.toString());
return mod.default ?? {};
}
return {};
} finally {
if (server) {
await server.close();
}
}
}
35 changes: 0 additions & 35 deletions packages/@withstudiocms/config-utils/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,5 @@
import type { Schema } from 'effect';

/**
* Arguments for importing a bundled file.
*
* @property code - The source code of the bundled file as a string.
* @property root - The root URL used as the base for resolving file paths.
* @property label - (Optional) A label for the temporary file, useful for identification or debugging.
*/
export interface ImportBundledFileArgs {
code: string;
root: URL;
label?: string; // Optional label for the temporary file
}

/**
* Arguments for loading and bundling a configuration file.
*
* @property root - The root directory as a URL.
* @property fileUrl - The URL of the configuration file to load, or undefined if not specified.
* @property label - (Optional) A label for the temporary file, used for identification or logging.
*/
export interface LoadAndBundleConfigFileArgs {
root: URL;
fileUrl: URL | undefined;
label?: string; // Optional label for the temporary file
}

/**
* The result of loading and bundling a configuration file.
*
* @property mod - The loaded module, which may contain a `default` export of any type, or be undefined if loading failed.
*/
export interface LoadAndBundleConfigFileResult {
mod: { default?: unknown } | undefined;
}

/**
* Options for building an effect-based configuration resolver.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createServer, type Plugin, type ViteDevServer } from 'vite';

/**
* Creates a minimal dev server with a list of plugins. Use this instance for a one-shot usage.
*
* NOTE: This is intentionally in its own module to avoid pulling `vite`'s heavy `createServer`
* (and transitively Rollup) into every file that imports from `viteUtils.ts`.
*/
export async function createMinimalViteDevServer(plugins: Plugin[] = []): Promise<ViteDevServer> {
return await createServer({
configFile: false,
server: { middlewareMode: true, hmr: false, watch: null, ws: false },
optimizeDeps: { noDiscovery: true },
clearScreen: false,
appType: 'custom',
ssr: { external: true },
plugins,
});
}
19 changes: 0 additions & 19 deletions packages/@withstudiocms/config-utils/src/utils/dynamicResult.ts

This file was deleted.

2 changes: 0 additions & 2 deletions packages/@withstudiocms/config-utils/src/utils/index.ts

This file was deleted.

33 changes: 0 additions & 33 deletions packages/@withstudiocms/config-utils/src/utils/tryCatch.ts

This file was deleted.

Loading
Loading