Skip to content

Commit b42fb34

Browse files
Replace tsdown with Vite for bundling and update loader API (#1606)
* Replace tsdown bundler with Vite loader Switch config loading from tsdown bundling to a minimal Vite dev-server approach. Remove tsdown and tryCatch/dynamicResult utils, add vite to dev/peerDependencies, update loader API and imports, and delete obsolete tests targeting the old bundler. * lint * more cleanup * LINT * tests * apply code suggestions * lint * add more test cases * lint
1 parent 6281424 commit b42fb34

21 files changed

Lines changed: 270 additions & 660 deletions

File tree

.changeset/nice-books-mate.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@withstudiocms/config-utils": minor
3+
"studiocms": minor
4+
---
5+
6+
Migrates to a Vite-based loader with native ESM and SSR fallback support; callers now use the filesystem-aware options API.

packages/@withstudiocms/config-utils/package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,14 @@
5353
}
5454
},
5555
"type": "module",
56-
"dependencies": {
57-
"tsdown": "catalog:"
58-
},
5956
"devDependencies": {
6057
"@types/node": "catalog:",
61-
"astro": "catalog:astrojs-current"
58+
"astro": "catalog:astrojs-current",
59+
"vite": "catalog:astrojs-current"
6260
},
6361
"peerDependencies": {
6462
"astro": "catalog:astrojs-min",
63+
"vite": "catalog:astrojs-min",
6564
"effect": "catalog:effect"
6665
}
6766
}

packages/@withstudiocms/config-utils/src/astro-integration-utils.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from 'node:fs';
12
import type { HookParameters } from 'astro';
23
import { Schema } from 'effect';
34
import { loadConfigFile } from './loader.js';
@@ -27,16 +28,16 @@ export const configResolverBuilder =
2728
const logger = l.fork(`${label}:config`);
2829

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

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

3839
// Parse the loaded config file using the provided effect schema
39-
const parsedConfig = await Schema.decodeUnknownPromise(effectSchema)(loadedConfigFile);
40+
const parsedConfig = await Schema.decodeUnknownPromise(effectSchema)(loadedViteConfigFile);
4041

4142
// Log a message indicating that the config file was loaded and parsed successfully
4243
logger.info('Config file loaded and parsed successfully.');
Lines changed: 53 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,167 +1,84 @@
1-
import { constants } from 'node:fs';
2-
import { access, unlink, writeFile } from 'node:fs/promises';
3-
import { fileURLToPath } from 'node:url';
4-
import { build as tsdown } from 'tsdown';
5-
import type {
6-
ImportBundledFileArgs,
7-
LoadAndBundleConfigFileArgs,
8-
LoadAndBundleConfigFileResult,
9-
} from './types.js';
10-
import { tryCatch } from './utils/tryCatch.js';
1+
import type fsType from 'node:fs';
2+
import { isRunnableDevEnvironment, type ViteDevServer } from 'vite';
3+
import { createMinimalViteDevServer } from './utils/createMinimalViteDevServer.js';
4+
import loadFallbackPlugin from './utils/vite-plugin-load-fallback.js';
115

126
/**
13-
* Bundle arbitrary `mjs` or `ts` file.
14-
* Simplified fork from Vite's `bundleConfigFile` function.
7+
* Options for loading a configuration file with Vite.
158
*
16-
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L961
9+
* @property root - The root directory for resolving imports.
10+
* @property configPaths - An array of possible configuration file paths to check, relative to `root`.
11+
* @property fs - The file system module to use for checking file existence.
1712
*/
18-
export async function bundleConfigFile({ fileUrl }: { fileUrl: URL }) {
19-
const result = await tsdown({
20-
cwd: process.cwd(),
21-
entry: fileURLToPath(fileUrl),
22-
format: 'esm',
23-
target: 'node18',
24-
platform: 'node',
25-
logLevel: 'silent',
26-
write: false,
27-
});
28-
29-
const chunks = result[0].chunks.filter((chunk) => chunk.type === 'chunk');
30-
31-
if (chunks.length === 0) {
32-
throw new Error('No output chunks found after bundling config file');
33-
}
34-
35-
const file = chunks.find((chunk) => chunk.fileName.endsWith('.mjs'))?.code;
36-
37-
if (!file) {
38-
throw new Error('Unexpected: no output file');
39-
}
40-
41-
return {
42-
code: file,
43-
};
13+
export interface LoadConfigWithViteOptions {
14+
root: URL;
15+
configPaths: string[];
16+
fs: typeof fsType;
4417
}
4518

4619
/**
47-
* Forked from Vite config loader, replacing CJS-based path concat with ESM only
20+
* Loads a configuration file using Vite's development server.
4821
*
49-
* @see https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts#L1074
22+
* @param options - The options for loading the configuration file.
23+
* @param options.root - The root directory for resolving imports.
24+
* @param options.configPaths - An array of possible configuration file paths to check, relative to `root`.
25+
* @param options.fs - The file system module to use for checking file existence.
26+
* @returns A promise that resolves to the configuration object, or an empty object if no config file is found.
5027
*/
51-
export async function importBundledFile({
52-
code,
28+
export async function loadConfigFile({
29+
configPaths,
5330
root,
54-
label = 'bundled-tmp.config',
55-
}: ImportBundledFileArgs): Promise<{ default?: unknown }> {
56-
// Write it to disk, load it with native Node ESM, then delete the file.
57-
const tmpFileUrl = new URL(
58-
`./${label}.timestamp-${Date.now()}-${Math.random().toString(36).substring(2, 9)}.mjs`,
59-
root
60-
);
61-
await writeFile(tmpFileUrl, code, { encoding: 'utf8' });
62-
try {
63-
return await import(/* @vite-ignore */ tmpFileUrl.toString());
64-
} finally {
65-
await tryCatch(unlink(tmpFileUrl));
66-
}
67-
}
68-
69-
/**
70-
* Loads and bundles a configuration file, then imports the bundled module.
71-
*
72-
* @param params - The arguments for loading and bundling the config file.
73-
* @param params.root - The root directory for resolving imports.
74-
* @param params.fileUrl - The URL or path to the configuration file to load.
75-
* @param params.label - A label used for logging or identification purposes.
76-
* @returns A promise that resolves to an object containing the imported module (`mod`)
77-
* and an array of its dependencies.
78-
*/
79-
export async function loadAndBundleConfigFile({
80-
root,
81-
fileUrl,
82-
label,
83-
}: LoadAndBundleConfigFileArgs): Promise<LoadAndBundleConfigFileResult> {
84-
// If no file URL is provided, return an empty result
85-
if (!fileUrl) {
86-
return { mod: undefined };
87-
}
88-
89-
// Bundle the configuration file and get its code and dependencies
90-
const { code } = await bundleConfigFile({
91-
fileUrl,
92-
});
93-
94-
// Import the bundled file using the provided root URL and optional label
95-
return {
96-
mod: await importBundledFile({ code, root, label }),
97-
};
98-
}
99-
100-
/**
101-
* Loads a configuration file from a list of possible paths relative to a given root URL.
102-
*
103-
* Iterates through the provided `configPaths`, checking for the existence of each file.
104-
* If a file is found, it is loaded and bundled, and its default export is returned as the configuration object.
105-
* If no valid configuration file is found, or if the file does not have a valid default export, the function returns `undefined`
106-
* or throws an error, respectively.
107-
*
108-
* @typeParam R - The expected shape of the configuration object.
109-
* @param root - The base URL to resolve configuration file paths against.
110-
* @param configPaths - An array of possible configuration file paths to check, relative to `root`.
111-
* @returns A promise that resolves to the configuration object of type `R`, or `undefined` if no valid config file is found.
112-
* @throws If a config file is found but does not have a valid default export.
113-
*/
114-
export async function loadConfigFile<R>(
115-
root: URL,
116-
configPaths: string[],
117-
label?: string
118-
): Promise<R | undefined> {
31+
fs,
32+
}: LoadConfigWithViteOptions): Promise<Record<string, unknown>> {
11933
let configFileUrl: URL | undefined;
12034

12135
// Check each path in the configPaths array to see if the file exists
12236
// If a file exists, set configFileUrl to that URL
12337
for (const path of configPaths) {
12438
const fileUrl = new URL(path, root);
12539
try {
126-
await access(fileUrl, constants.F_OK);
40+
await fs.promises.access(fileUrl, fs.constants.F_OK);
12741
configFileUrl = fileUrl;
12842
break;
12943
} catch {
13044
// File does not exist, continue to the next path
13145
}
13246
}
13347

134-
// If no config file was found, return undefined
135-
// This is important to avoid unnecessary errors when no config file is present
48+
// If no config file was found, return an empty object
13649
if (!configFileUrl) {
137-
return undefined;
50+
return {};
13851
}
13952

140-
// Load and bundle the configuration file
141-
// This will return the module and its dependencies
142-
const { mod: configMod } = await loadAndBundleConfigFile({
143-
root,
144-
fileUrl: configFileUrl,
145-
label,
146-
});
147-
148-
// If no module was returned, return undefined
149-
// This is important to handle cases where the config file might be empty or invalid
150-
if (!configMod) {
151-
return undefined;
53+
// Attempt to load config using Node's native ESM loader if the file has a .js, .cjs, or .mjs extension
54+
if (/\.[cm]?js$/.test(configFileUrl.pathname)) {
55+
try {
56+
const config = await import(`${configFileUrl.toString()}?t=${Date.now()}`);
57+
return config.default ?? {};
58+
} catch (e) {
59+
if (e && typeof e === 'object' && 'code' in e && e.code === 'ERR_DLOPEN_DISABLED') {
60+
throw e;
61+
}
62+
63+
console.debug('Failed to load config with Node', e);
64+
}
15265
}
15366

154-
// Check if the module has a default export
155-
// If not, throw an error indicating that the default export is missing or invalid
156-
if (!configMod.default) {
157-
throw new Error(
158-
'Missing or invalid default export. Please export your config object as the default export.'
159-
);
160-
}
67+
// Else try loading with Vite
68+
let server: ViteDevServer | undefined;
69+
try {
70+
const plugins = loadFallbackPlugin({ fs, root });
71+
server = await createMinimalViteDevServer(plugins);
16172

162-
// Return the default export of the module, which is expected to be of type R
163-
// This is the actual configuration object that will be used by the application
164-
// The type assertion ensures that the returned object conforms to the expected shape of R
165-
// This is important for type safety and to ensure that the configuration object has the expected properties
166-
return configMod.default as R;
73+
if (isRunnableDevEnvironment(server.environments.ssr)) {
74+
const environment = server.environments.ssr;
75+
const mod = await environment.runner.import(configFileUrl.toString());
76+
return mod.default ?? {};
77+
}
78+
return {};
79+
} finally {
80+
if (server) {
81+
await server.close();
82+
}
83+
}
16784
}

packages/@withstudiocms/config-utils/src/types.ts

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,5 @@
11
import type { Schema } from 'effect';
22

3-
/**
4-
* Arguments for importing a bundled file.
5-
*
6-
* @property code - The source code of the bundled file as a string.
7-
* @property root - The root URL used as the base for resolving file paths.
8-
* @property label - (Optional) A label for the temporary file, useful for identification or debugging.
9-
*/
10-
export interface ImportBundledFileArgs {
11-
code: string;
12-
root: URL;
13-
label?: string; // Optional label for the temporary file
14-
}
15-
16-
/**
17-
* Arguments for loading and bundling a configuration file.
18-
*
19-
* @property root - The root directory as a URL.
20-
* @property fileUrl - The URL of the configuration file to load, or undefined if not specified.
21-
* @property label - (Optional) A label for the temporary file, used for identification or logging.
22-
*/
23-
export interface LoadAndBundleConfigFileArgs {
24-
root: URL;
25-
fileUrl: URL | undefined;
26-
label?: string; // Optional label for the temporary file
27-
}
28-
29-
/**
30-
* The result of loading and bundling a configuration file.
31-
*
32-
* @property mod - The loaded module, which may contain a `default` export of any type, or be undefined if loading failed.
33-
*/
34-
export interface LoadAndBundleConfigFileResult {
35-
mod: { default?: unknown } | undefined;
36-
}
37-
383
/**
394
* Options for building an effect-based configuration resolver.
405
*
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createServer, type Plugin, type ViteDevServer } from 'vite';
2+
3+
/**
4+
* Creates a minimal dev server with a list of plugins. Use this instance for a one-shot usage.
5+
*
6+
* NOTE: This is intentionally in its own module to avoid pulling `vite`'s heavy `createServer`
7+
* (and transitively Rollup) into every file that imports from `viteUtils.ts`.
8+
*/
9+
export async function createMinimalViteDevServer(plugins: Plugin[] = []): Promise<ViteDevServer> {
10+
return await createServer({
11+
configFile: false,
12+
server: { middlewareMode: true, hmr: false, watch: null, ws: false },
13+
optimizeDeps: { noDiscovery: true },
14+
clearScreen: false,
15+
appType: 'custom',
16+
ssr: { external: true },
17+
plugins,
18+
});
19+
}

packages/@withstudiocms/config-utils/src/utils/dynamicResult.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

packages/@withstudiocms/config-utils/src/utils/index.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

packages/@withstudiocms/config-utils/src/utils/tryCatch.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

0 commit comments

Comments
 (0)