feat: add host and port support to TuonoConfig (#366)

Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
This commit is contained in:
Valerio Ageno
2025-01-20 21:00:47 +01:00
committed by GitHub
parent 118172d27f
commit 1a3ccb112e
12 changed files with 334 additions and 234 deletions
@@ -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<InternalTuonoConfig, 'vite'> {
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<void> {
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')
}
+2
View File
@@ -0,0 +1,2 @@
export { loadConfig } from './load-config'
export { createJsonConfig } from './create-json-config'
@@ -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)
})
})
@@ -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<InternalTuonoConfig> => {
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
}
}
@@ -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,
}),
)
})
})
})
@@ -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<AliasOptions, ReadonlyArray<unknown>>).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 ?? [],
},
}
}
+1
View File
@@ -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'
+9 -4
View File
@@ -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<Plugin> = [
{
enforce: 'post',
@@ -110,6 +110,7 @@ const developmentSSRBundle = (): void => {
const developmentCSRWatch = (): void => {
blockingAsync(async () => {
const config = await loadConfig()
const server = await createServer(
mergeConfig<InlineConfig, InlineConfig>(
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<void> => {
await build({
root: '.tuono',
logLevel: 'silent',
@@ -202,6 +204,9 @@ const buildConfig = (): void => {
},
},
})
const config = await loadConfig()
await createJsonConfig(config)
})
}
+5
View File
@@ -0,0 +1,5 @@
import type { TuonoConfig } from '../config'
export interface InternalTuonoConfig extends Omit<TuonoConfig, 'server'> {
server: Required<NonNullable<TuonoConfig['server']>>
}
-127
View File
@@ -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,
}),
)
})
})
-103
View File
@@ -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<AliasOptions, ReadonlyArray<unknown>>).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<TuonoConfig> => {
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 => {
void (async (): Promise<void> => {
await callback()
+4
View File
@@ -1,6 +1,10 @@
import type { AliasOptions, DepOptimizationOptions, PluginOption } from 'vite'
export interface TuonoConfig {
server?: {
host?: string
port?: number
}
vite?: {
alias?: AliasOptions
optimizeDeps?: DepOptimizationOptions