diff --git a/packages/tuono/src/build/config/create-json-config.ts b/packages/tuono/src/build/config/create-json-config.ts new file mode 100644 index 00000000..122c3aa6 --- /dev/null +++ b/packages/tuono/src/build/config/create-json-config.ts @@ -0,0 +1,47 @@ +import fs from 'fs/promises' +import path from 'path' + +import type { InternalTuonoConfig } from '../types' + +import { + DOT_TUONO_FOLDER_NAME, + CONFIG_FOLDER_NAME, + SERVER_CONFIG_NAME, +} from '../constants' + +const CONFIG_PATH = path.join( + DOT_TUONO_FOLDER_NAME, + CONFIG_FOLDER_NAME, + SERVER_CONFIG_NAME, +) +/** + * This function is used to remove the `vite` property from the config object. + * The `vite` property is only used at build time, so it is not needed by either the server or the client. + */ +function removeViteProperties( + config: InternalTuonoConfig, +): Omit { + const newConfig = structuredClone(config) + delete newConfig['vite'] + return newConfig +} + +/** + * This function creates a JSON config file for the server, + * that will be shared with the client as a prop. + * The file will be saved at`.tuono/config/config.json`. + * + * The file is in JSON format to ensure it's easily read by the server, + * which is written in Rust. + */ +export async function createJsonConfig( + config: InternalTuonoConfig, +): Promise { + const jsonConfig = removeViteProperties(config) + + const fullPath = path.resolve(CONFIG_PATH) + const jsonContent = JSON.stringify(jsonConfig) + + // No need to manage error state. Tuono CLI will manage it. + await fs.writeFile(fullPath, jsonContent, 'utf-8') +} diff --git a/packages/tuono/src/build/config/index.ts b/packages/tuono/src/build/config/index.ts new file mode 100644 index 00000000..4c6cbdc5 --- /dev/null +++ b/packages/tuono/src/build/config/index.ts @@ -0,0 +1,2 @@ +export { loadConfig } from './load-config' +export { createJsonConfig } from './create-json-config' diff --git a/packages/tuono/src/build/config/load-config.spec.ts b/packages/tuono/src/build/config/load-config.spec.ts new file mode 100644 index 00000000..4d3d80a0 --- /dev/null +++ b/packages/tuono/src/build/config/load-config.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' + +import { loadConfig } from './load-config' + +describe('loadConfig', () => { + it('should error if the config does not exist', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + await loadConfig() + + expect(consoleErrorSpy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/tuono/src/build/config/load-config.ts b/packages/tuono/src/build/config/load-config.ts new file mode 100644 index 00000000..89dbb9cd --- /dev/null +++ b/packages/tuono/src/build/config/load-config.ts @@ -0,0 +1,35 @@ +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import type { TuonoConfig } from '../../config' + +import type { InternalTuonoConfig } from '../types' + +import { + DOT_TUONO_FOLDER_NAME, + CONFIG_FOLDER_NAME, + CONFIG_FILE_NAME, +} from '../constants' + +import { normalizeConfig } from './normalize-config' + +export const loadConfig = async (): Promise => { + try { + const configFile = (await import( + pathToFileURL( + path.join( + process.cwd(), + DOT_TUONO_FOLDER_NAME, + CONFIG_FOLDER_NAME, + CONFIG_FILE_NAME, + ), + ).href + )) as { default: TuonoConfig } + + return normalizeConfig(configFile.default) + } catch (err) { + console.error('Failed to load tuono.config.ts') + console.error(err) + return {} as InternalTuonoConfig + } +} diff --git a/packages/tuono/src/build/config/normalize-config.spec.ts b/packages/tuono/src/build/config/normalize-config.spec.ts new file mode 100644 index 00000000..b1abfd60 --- /dev/null +++ b/packages/tuono/src/build/config/normalize-config.spec.ts @@ -0,0 +1,135 @@ +import path from 'node:path' + +import { describe, expect, it, vi } from 'vitest' + +import type { TuonoConfig } from '../../config' + +import { normalizeConfig } from './normalize-config' + +const PROCESS_CWD_MOCK = 'PROCESS_CWD_MOCK' + +vi.spyOn(process, 'cwd').mockReturnValue(PROCESS_CWD_MOCK) + +describe('normalizeConfig', () => { + it('should empty base config if empty config is provided', () => { + const config: TuonoConfig = {} + + expect(normalizeConfig(config)).toStrictEqual({ + server: { host: 'localhost', port: 3000 }, + vite: { alias: undefined, optimizeDeps: undefined, plugins: [] }, + }) + }) + + it('should return an empty config if invalid values are provided', () => { + // @ts-expect-error testing invalid config + expect(normalizeConfig({ invalid: true })).toStrictEqual({ + server: { host: 'localhost', port: 3000 }, + vite: { alias: undefined, optimizeDeps: undefined, plugins: [] }, + }) + }) + + describe('server', () => { + it('should assign the host and port defined by the user', () => { + const config: TuonoConfig = { + server: { host: '0.0.0.0', port: 8080 }, + } + + expect(normalizeConfig(config)).toStrictEqual( + expect.objectContaining({ + server: expect.objectContaining({ + host: '0.0.0.0', + port: 8080, + }) as unknown, + }), + ) + }) + }) + + describe('vite - alias', () => { + it('should not modify alias pointing to packages', () => { + const libraryName = '@tabler/icons-react' + const libraryAlias = '@tabler/icons-react/dist/esm/icons/index.mjs' + const config: TuonoConfig = { + vite: { alias: { [libraryName]: libraryAlias } }, + } + + expect(normalizeConfig(config)).toStrictEqual( + expect.objectContaining({ + vite: expect.objectContaining({ + alias: { + '@tabler/icons-react': + '@tabler/icons-react/dist/esm/icons/index.mjs', + }, + }) as unknown, + }), + ) + }) + + it('should transform relative paths to absolute path relative to process.cwd()', () => { + const config: TuonoConfig = { + vite: { alias: { '@': './src', '@no-prefix': 'src' } }, + } + + expect(normalizeConfig(config)).toStrictEqual( + expect.objectContaining({ + vite: expect.objectContaining({ + alias: { + '@': path.join(PROCESS_CWD_MOCK, 'src'), + '@no-prefix': path.join(PROCESS_CWD_MOCK, 'src'), + }, + }) as unknown, + }), + ) + }) + + it('should not transform alias with absolute path', () => { + const config: TuonoConfig = { + vite: { alias: { '@1': '/src/pippo', '@2': 'file://pluto' } }, + } + + expect(normalizeConfig(config)).toStrictEqual( + expect.objectContaining({ + vite: expect.objectContaining({ + alias: { + '@1': '/src/pippo', + '@2': 'file://pluto', + }, + }) as unknown, + }), + ) + }) + + it('should apply previous behavior when using alias as list', () => { + const config: TuonoConfig = { + vite: { + alias: [ + { find: '1', replacement: '@tabler/icons-react-fun' }, + { find: '2', replacement: './src' }, + { find: '3', replacement: 'file://pluto' }, + ], + }, + } + + expect(normalizeConfig(config)).toStrictEqual( + expect.objectContaining({ + vite: expect.objectContaining({ + alias: [ + { + find: '1', + replacement: '@tabler/icons-react-fun', + }, + { + find: '2', + replacement: path.join(PROCESS_CWD_MOCK, 'src'), + }, + { + find: '3', + replacement: 'file://pluto', + }, + ], + }) as unknown, + }), + ) + }) + }) +}) diff --git a/packages/tuono/src/build/config/normalize-config.ts b/packages/tuono/src/build/config/normalize-config.ts new file mode 100644 index 00000000..25bf856d --- /dev/null +++ b/packages/tuono/src/build/config/normalize-config.ts @@ -0,0 +1,82 @@ +import path from 'node:path' + +import type { AliasOptions } from 'vite' + +import type { TuonoConfig } from '../../config' + +import type { InternalTuonoConfig } from '../types' + +import { DOT_TUONO_FOLDER_NAME, CONFIG_FOLDER_NAME } from '../constants' + +/** + * Normalize vite alias option: + * - If the path starts with `src` folder, transform it to absolute, prepending the tuono root folder + * - If the path is absolute, remove the ".tuono/config/" path from it + * - Otherwise leave the path untouched + */ +const normalizeAliasPath = (aliasPath: string): string => { + if (aliasPath.startsWith('./src') || aliasPath.startsWith('src')) { + return path.join(process.cwd(), aliasPath) + } + + if (path.isAbsolute(aliasPath)) { + return aliasPath.replace( + path.join(DOT_TUONO_FOLDER_NAME, CONFIG_FOLDER_NAME), + '', + ) + } + + return aliasPath +} + +/** + * From a given vite aliasOptions apply {@link normalizeAliasPath} for each alias. + * + * The config is bundled by `vite` and emitted inside {@link DOT_TUONO_FOLDER_NAME}/{@link CONFIG_FOLDER_NAME}. + * According to this, we have to ensure that the aliases provided by the user are updated to refer to the right folders. + * + * @see https://github.com/Valerioageno/tuono/pull/153#issuecomment-2508142877 + */ +const normalizeViteAlias = (alias?: AliasOptions): AliasOptions | undefined => { + if (!alias) return + + if (Array.isArray(alias)) { + return (alias as Extract>).map( + ({ replacement, ...userAliasDefinition }) => ({ + ...userAliasDefinition, + replacement: normalizeAliasPath(replacement), + }), + ) + } + + if (typeof alias === 'object') { + const normalizedAlias: AliasOptions = {} + for (const [key, value] of Object.entries(alias)) { + normalizedAlias[key] = normalizeAliasPath(value as string) + } + return normalizedAlias + } + + return alias +} + +/** + * Wrapper function to normalize the tuono.config.ts file + * + * @warning Exported for unit test. + * There is no easy way to mock the module export and change it in every test + * and also testing the error + */ +export const normalizeConfig = (config: TuonoConfig): InternalTuonoConfig => { + return { + server: { + host: config.server?.host ?? 'localhost', + port: config.server?.port ?? 3000, + }, + vite: { + alias: normalizeViteAlias(config.vite?.alias), + optimizeDeps: config.vite?.optimizeDeps, + plugins: config.vite?.plugins ?? [], + }, + } +} diff --git a/packages/tuono/src/build/constants.ts b/packages/tuono/src/build/constants.ts index b82bc74f..c298bebd 100644 --- a/packages/tuono/src/build/constants.ts +++ b/packages/tuono/src/build/constants.ts @@ -1,3 +1,4 @@ export const DOT_TUONO_FOLDER_NAME = '.tuono' export const CONFIG_FOLDER_NAME = 'config' export const CONFIG_FILE_NAME = 'config.mjs' +export const SERVER_CONFIG_NAME = 'config.json' diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index 9781b4d6..fea5b427 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -6,9 +6,9 @@ import { TuonoFsRouterPlugin } from 'tuono-fs-router-vite-plugin' import type { TuonoConfig } from '../config' -import { loadConfig, blockingAsync } from './utils' +import { blockingAsync } from './utils' +import { createJsonConfig, loadConfig } from './config' -const VITE_PORT = 3001 const VITE_SSR_PLUGINS: Array = [ { enforce: 'post', @@ -110,6 +110,7 @@ const developmentSSRBundle = (): void => { const developmentCSRWatch = (): void => { blockingAsync(async () => { const config = await loadConfig() + const server = await createServer( mergeConfig( createBaseViteConfigFromTuonoConfig(config), @@ -118,7 +119,8 @@ const developmentCSRWatch = (): void => { base: '/vite-server/', server: { - port: VITE_PORT, + host: config.server.host, + port: config.server.port + 1, strictPort: true, }, build: { @@ -184,7 +186,7 @@ const buildProd = (): void => { } const buildConfig = (): void => { - blockingAsync(async () => { + blockingAsync(async (): Promise => { await build({ root: '.tuono', logLevel: 'silent', @@ -202,6 +204,9 @@ const buildConfig = (): void => { }, }, }) + + const config = await loadConfig() + await createJsonConfig(config) }) } diff --git a/packages/tuono/src/build/types.ts b/packages/tuono/src/build/types.ts new file mode 100644 index 00000000..a4187588 --- /dev/null +++ b/packages/tuono/src/build/types.ts @@ -0,0 +1,5 @@ +import type { TuonoConfig } from '../config' + +export interface InternalTuonoConfig extends Omit { + server: Required> +} diff --git a/packages/tuono/src/build/utils.spec.ts b/packages/tuono/src/build/utils.spec.ts deleted file mode 100644 index f8eb18ea..00000000 --- a/packages/tuono/src/build/utils.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import path from 'node:path' - -import { describe, expect, it, vi } from 'vitest' - -import type { TuonoConfig } from '../config' - -import { loadConfig, normalizeConfig } from './utils' - -const PROCESS_CWD_MOCK = 'PROCESS_CWD_MOCK' - -vi.spyOn(process, 'cwd').mockReturnValue(PROCESS_CWD_MOCK) - -describe('loadConfig', () => { - it('should error if the config does not exist', async () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) - await loadConfig() - - expect(consoleErrorSpy).toHaveBeenCalledTimes(2) - }) -}) - -describe('normalizeConfig - vite', () => { - it('should empty base config if empty config is provided', () => { - const config: TuonoConfig = {} - - expect(normalizeConfig(config)).toStrictEqual({ - vite: { alias: undefined, optimizeDeps: undefined, plugins: [] }, - }) - }) - - it('should return an empty config if invalid values are provided', () => { - // @ts-expect-error testing invalid config - expect(normalizeConfig({ invalid: true })).toStrictEqual({ - vite: { alias: undefined, optimizeDeps: undefined, plugins: [] }, - }) - }) -}) - -describe('normalizeConfig - vite - alias', () => { - it('should not modify alias pointing to packages', () => { - const libraryName = '@tabler/icons-react' - const libraryAlias = '@tabler/icons-react/dist/esm/icons/index.mjs' - const config: TuonoConfig = { - vite: { alias: { [libraryName]: libraryAlias } }, - } - - expect(normalizeConfig(config)).toStrictEqual( - expect.objectContaining({ - vite: expect.objectContaining({ - alias: { - '@tabler/icons-react': - '@tabler/icons-react/dist/esm/icons/index.mjs', - }, - }) as unknown, - }), - ) - }) - - it('should transform relative paths to absolute path relative to process.cwd()', () => { - const config: TuonoConfig = { - vite: { alias: { '@': './src', '@no-prefix': 'src' } }, - } - - expect(normalizeConfig(config)).toStrictEqual( - expect.objectContaining({ - vite: expect.objectContaining({ - alias: { - '@': path.join(PROCESS_CWD_MOCK, 'src'), - '@no-prefix': path.join(PROCESS_CWD_MOCK, 'src'), - }, - }) as unknown, - }), - ) - }) - - it('should not transform alias with absolute path', () => { - const config: TuonoConfig = { - vite: { alias: { '@1': '/src/pippo', '@2': 'file://pluto' } }, - } - - expect(normalizeConfig(config)).toStrictEqual( - expect.objectContaining({ - vite: expect.objectContaining({ - alias: { - '@1': '/src/pippo', - '@2': 'file://pluto', - }, - }) as unknown, - }), - ) - }) - - it('should apply previous behavior when using alias as list', () => { - const config: TuonoConfig = { - vite: { - alias: [ - { find: '1', replacement: '@tabler/icons-react-fun' }, - { find: '2', replacement: './src' }, - { find: '3', replacement: 'file://pluto' }, - ], - }, - } - - expect(normalizeConfig(config)).toStrictEqual( - expect.objectContaining({ - vite: expect.objectContaining({ - alias: [ - { - find: '1', - replacement: '@tabler/icons-react-fun', - }, - { - find: '2', - replacement: path.join(PROCESS_CWD_MOCK, 'src'), - }, - { - find: '3', - replacement: 'file://pluto', - }, - ], - }) as unknown, - }), - ) - }) -}) diff --git a/packages/tuono/src/build/utils.ts b/packages/tuono/src/build/utils.ts index 9b9a5269..e26cd461 100644 --- a/packages/tuono/src/build/utils.ts +++ b/packages/tuono/src/build/utils.ts @@ -1,106 +1,3 @@ -import path from 'node:path' -import { pathToFileURL } from 'node:url' - -import type { AliasOptions } from 'vite' - -import type { TuonoConfig } from '../config' - -import { - DOT_TUONO_FOLDER_NAME, - CONFIG_FOLDER_NAME, - CONFIG_FILE_NAME, -} from './constants' - -/** - * Normalize vite alias option: - * - If the path starts with `src` folder, transform it to absolute, prepending the tuono root folder - * - If the path is absolute, remove the ".tuono/config/" path from it - * - Otherwise leave the path untouched - */ -const normalizeAliasPath = (aliasPath: string): string => { - if (aliasPath.startsWith('./src') || aliasPath.startsWith('src')) { - return path.join(process.cwd(), aliasPath) - } - - if (path.isAbsolute(aliasPath)) { - return aliasPath.replace( - path.join(DOT_TUONO_FOLDER_NAME, CONFIG_FOLDER_NAME), - '', - ) - } - - return aliasPath -} - -/** - * From a given vite aliasOptions apply {@link normalizeAliasPath} for each alias. - * - * The config is bundled by `vite` and emitted inside {@link DOT_TUONO_FOLDER_NAME}/{@link CONFIG_FOLDER_NAME}. - * According to this, we have to ensure that the aliases provided by the user are updated to refer to the right folders. - * - * @see https://github.com/Valerioageno/tuono/pull/153#issuecomment-2508142877 - */ -const normalizeViteAlias = (alias?: AliasOptions): AliasOptions | undefined => { - if (!alias) return - - if (Array.isArray(alias)) { - return (alias as Extract>).map( - ({ replacement, ...userAliasDefinition }) => ({ - ...userAliasDefinition, - replacement: normalizeAliasPath(replacement), - }), - ) - } - - if (typeof alias === 'object') { - const normalizedAlias: AliasOptions = {} - for (const [key, value] of Object.entries(alias)) { - normalizedAlias[key] = normalizeAliasPath(value as string) - } - return normalizedAlias - } - - return alias -} - -/** - * Wrapper function to normalize the tuono.config.ts file - * - * @warning Exported for unit test. - * There is no easy way to mock the module export and change it in every test - * and also testing the error - */ -export const normalizeConfig = (config: TuonoConfig): TuonoConfig => { - return { - vite: { - alias: normalizeViteAlias(config.vite?.alias), - optimizeDeps: config.vite?.optimizeDeps, - plugins: config.vite?.plugins ?? [], - }, - } -} - -export const loadConfig = async (): Promise => { - try { - const configFile = (await import( - pathToFileURL( - path.join( - process.cwd(), - DOT_TUONO_FOLDER_NAME, - CONFIG_FOLDER_NAME, - CONFIG_FILE_NAME, - ), - ).href - )) as { default: TuonoConfig } - - return normalizeConfig(configFile.default) - } catch (err) { - console.error('Failed to load tuono.config.ts') - console.error(err) - return {} - } -} - export const blockingAsync = (callback: () => Promise): void => { void (async (): Promise => { await callback() diff --git a/packages/tuono/src/config/types.ts b/packages/tuono/src/config/types.ts index 50e15ffb..cff248be 100644 --- a/packages/tuono/src/config/types.ts +++ b/packages/tuono/src/config/types.ts @@ -1,6 +1,10 @@ import type { AliasOptions, DepOptimizationOptions, PluginOption } from 'vite' export interface TuonoConfig { + server?: { + host?: string + port?: number + } vite?: { alias?: AliasOptions optimizeDeps?: DepOptimizationOptions